Mobile Computing · Background Execution
Exact Alarms, Doze, and the Maintenance Window
How the platform suspends background work when the device is idle, what an exact alarm buys against that suspension, and why the delivery window widens with time.
An alarm is a request to run code at a time. On a phone the request passes through a state machine that decides when the device is allowed to be awake at all, and that state machine is not visible from the alarm API. The same call, with the same time argument, produces a launch within seconds on an active device and a launch hours later on one that has been sitting on a desk since the previous evening.
The state machine is worth understanding because it explains the two things that most often go wrong with alarms: they do not fire when they were asked to, and the reason is not a bug.
Idle is a set of escalating restrictions
Android’s doze mode is not a single state. It is a progression, entered when the device is stationary, unplugged, and the screen has been off for a while, and it deepens the longer those conditions hold.
On entry, the platform defers network access, suspends most background jobs and alarms, and stops syncing. The device is not asleep — the CPU still runs, the user can still receive a call — but the platform has decided that background work is not urgent enough to justify waking the radio. Maintenance windows then open at intervals, during which deferred work is allowed to run and the network is available. The first windows are relatively close together; the interval grows with the time the device stays idle, up to a maximum spacing on the order of hours.
App standby is a separate axis. It tracks per-app usage rather than device state: an app that has not been used for a period is placed in a lower bucket, and the buckets determine how much background work the app is allowed, how often its jobs may run, and whether its alarms are deferred. The buckets are computed from usage patterns and are not directly settable, though the app can observe its own bucket.
The two mechanisms compose. A device in deep doze with an app in a restricted bucket is the case where background work effectively stops until the device is used or plugged in.
What an exact alarm buys
The alarm API distinguishes between an alarm that must be delivered at a time and one that may be delivered within a window. The inexact form is the default for most scheduling methods and is what the platform prefers: it can move the delivery to a moment when the device is already awake, and it can align several alarms.
The exact form asks for delivery at the specified time. On modern Android this requires a permission that the user grants, and the platform restricts which apps may hold it — the documented categories are alarm clocks and calendar reminders, applications where a late delivery is a visible failure to the user. An app that is not in one of those categories cannot hold the permission, and requesting the exact form without it either fails or is silently downgraded depending on the version.
Even with the permission, exactness is not absolute. An exact alarm scheduled while the device is in doze is delivered at the specified time only if the platform permits it; the documented exception is a variant that is allowed to fire while idle, and even that variant is rate-limited, with the platform enforcing a minimum interval between such deliveries per app. The limit exists because an exact alarm is a device-wide wakeup, and a single app that schedules one per minute would negate the entire doze design.
// Two different requests that look similar.
val am = context.getSystemService(AlarmManager::class.java)
// Inexact: the platform may move this to an existing wakeup.
am.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
// Exact: requires the user-granted permission. Rate-limited while idle.
if (am.canScheduleExactAlarms()) {
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
} else {
am.setWindow(AlarmManager.RTC_WAKEUP, triggerAtMillis, windowMillis, pendingIntent)
}
The setWindow form is the honest fallback. It states a tolerance explicitly, which lets the platform schedule the alarm alongside other work while keeping the delivery inside a bound the app chose.
Clock choice determines what “at a time” means
Alarms are scheduled against one of two clocks, and the choice decides behaviour across sleep and across time changes.
An elapsed-realtime alarm counts time since boot, including time the device spent in doze but not time it spent powered off. A wall-clock alarm is an absolute time in the local timezone, and it is affected by manual clock changes, network time corrections, and timezone transitions.
The rule follows from the purpose. A timer that measures an interval — “retry in thirty minutes”, “expire this token after an hour” — belongs on the elapsed clock, because the interval is a duration and a clock jump should not lengthen or shorten it. A reminder that must happen at 07:30 local time belongs on the wall clock, because the user’s expectation is about the clock face, and after a timezone change the correct behaviour is to fire at 07:30 in the new zone, not thirty minutes after the old one.
The failure mode is a wall-clock alarm used for an interval. A device that crosses a timezone boundary while the alarm is pending fires it early or late by the offset, and the app’s own logs show a duration that does not match the request.
Reboot is a separate durability question
Alarms do not survive a reboot. The platform’s alarm registry is in memory, and a power cycle clears it. An app that schedules a weekly reminder and relies on the alarm alone discovers after a reboot that the reminder is gone.
The remedy is to listen for the boot-completed broadcast and reschedule from persisted state. That listener runs at a moment when the app has just started, may not have a network, and must not do substantial work; the correct action is to read the app’s own durable schedule and re-register the alarms, which is the reconciliation pattern from Durable Deferred Work and the Persistence Boundary applied to a different store.
There is a subtlety in what gets rescheduled. An alarm that was due during the powered-off interval is not delivered on boot; it is simply missed. The app must decide whether a missed alarm is a reason to run immediately or to skip to the next occurrence, and that decision is a property of the work, not of the alarm API.
The alarm is a wakeup, not a session
Delivery of an alarm starts the app or delivers a broadcast. It does not create an execution grant beyond the moment of delivery. An app that wakes at the alarm and then performs a ten-minute upload is relying on the process surviving, which the doze model does not promise: the platform may suspend it again as soon as the broadcast completes.
The correct shape is to use the wakeup to enqueue durable work and let the deferred-work system run it under whatever grant it can obtain. The alarm is then a hint that the time has come, and the work itself is governed by the machinery described in Timer Coalescing and the Deferrable Work Budget.
The alternative — holding a wake lock for the duration of the work — is the pattern that drains batteries, and it is the subject of the next article in this cluster.
When an alarm should not exist
Most alarms in an app should be deferred work instead. The distinguishing question is what happens if the work runs an hour late. If the answer is “nothing the user can perceive”, the request is a job with a constraint, not an alarm with a time.
An alarm is justified when the time itself is the requirement: a wake-up call, a medication reminder, a calendar notification, a departure alert that must precede a train. In each case the value of the work is destroyed by delay, which is precisely the condition that the exact-alarm permission is meant to gate.
Everything else — refreshing a feed, uploading a log, checking a mailbox, syncing a ledger — belongs to the job scheduler, where the platform can batch it with other work and run it in a maintenance window alongside everything else that is waiting. The energy saved by that batching is the reason the maintenance windows exist, and an app that schedules an alarm for periodic sync is paying for a wakeup it did not need.
Boundary cases worth knowing
A doze maintenance window is not a fixed duration, and work that does not complete inside one is suspended until the next. A job that needs several minutes of network activity may therefore take several windows to finish, and the app’s progress record must survive the interruption.
Standby buckets are recalculated, and an app can be demoted after a period of non-use even if it was previously unrestricted. A feature that worked when the app was used daily may stop working for a user who opens it monthly, and the difference is invisible from the app’s side.
The alarm’s pending intent survives a process death but not an app update that changes the component it targets. An update that renames a receiver class leaves the old pending intent pointing at a component that no longer exists, and the alarm fires into nothing. Cancelling pending alarms before an update, or keeping the component names stable, is the only reliable defence.
References
- Android Developers — Doze and app standby — Android Developers
- Android Developers — AlarmManager — Android Developers
- Android Developers — Schedule alarms — Android Developers