Back to Blog
DevelopmentSeptember 22, 202613 min read

Doze and the Process Lifecycle

The system decides when your app gets to work, and that decision is not yours. Doze, App Standby buckets, and why scheduling a task is not the same as running it.

IM
Ignacio MelendezFull-Stack & Game Developer
Doze and the Process Lifecycle

New feature. A notification that shows up every 15 minutes. We added a log to check in the console that it fires correctly. Implemented, tested on the emulator, and every fifteen minutes the notification appears and the console prints the log. No delays. We tried it on a real phone and left it running while we went to a meeting and then to lunch. When we came back, there were only four runs in three hours.

No exception, no network error, no app crash. The scheduled work simply had not run. And yet the system was doing exactly what it had promised to do. The ones who had misunderstood the contract were us.

1. Shouldn't there be a clock here?

When you write every fifteen minutes in a backend, that code runs every fifteen minutes. That order is a Cron. If the machine that has to run it is powered on, it happens. But this same model cannot be carried over as is to mobile. And that is the root of the problem.

The difference is that on Android (and on iOS), scheduling periodic work is not an order, it is a request. What you get is not "run this every fifteen minutes" but "do not run this BEFORE fifteen minutes have passed". That guarantees nothing about the real execution happening within fifteen minutes. Instead, fifteen minutes may pass, or six hours, and then it runs. The problem is that both of those are correct behavior according to the documentation.

The emulator feeds this confusion. It always runs with the screen on, plugged into a virtual power source and with no battery restrictions. It is the scenario where every power saving policy is disabled, so it confirms the wrong mental model with a precision that will never happen again in production. It is the best of all happy path cases.

This article is about AOSP, the Android that Google documents. On a real phone there is also a manufacturer layer on top that squeezes a lot harder, and I wrote about that in Android permissions change depending on the phone brand. What follows here is the floor: it happens even on a clean Pixel.

2. Doze: the window that opens less and less

Doze, or "rest" mode, arrived in Android 6 and it is the central piece. When the device has gone a while without interaction, with the screen off and unplugged, the system considers it idle and enters Doze. From that moment on, the following things happen to your application:

  • Network access is suspended.
  • wakelock calls are ignored.
  • AlarmManager alarms are deferred.
  • Scheduled job entries do not run.
  • Syncs are not launched.

None of this is cancelled. It is stored and it piles up. Every so often the system opens a maintenance window, picks up everything pending, lets it run for a short while and goes back to sleep. The work is not lost, it is batched and delayed. And that is the important part.

On top of that, the windows are not regular. During the first few minutes they open often. But the longer the device stays still, the further apart they get. A phone sitting on a nightstand all night goes from windows every few minutes to windows every several hours. That is why the failure has such a characteristic shape: it works fine for the first few hours, but after a while it stops collecting data properly.

A bit later, with Android 7, a light Doze was added, which kicks in with just the screen off even while the phone is moving. This Doze is less aggressive, but it means there is another environment where periodic work does not run freely.

The official solutions are few and all of them have limits:

  • setExactAndAllowWhileIdle and setAndAllowWhileIdle skip Doze, but they are capped at roughly one alarm every nine minutes per app.
  • A high priority push message, which puts the app on a temporary allowlist for a few seconds. It can work for certain scenarios, but if the app needs heavy work or long routines, the window of action is very limited.
  • The battery optimization exemption, which the user has to grant by hand. (And depending on the manufacturer and its layer, it may not help much.)

REQUEST_IGNORE_BATTERY_OPTIMIZATIONS looks like the solution to everything and it is not. Google Play policy restricts its use to a handful of specific cases (alarms, real time communication, some health apps), and asking for it without fitting one of them is grounds for rejection in store review.

3. The bucket is assigned to you, you don't ask for it

Doze is a property of the device. App Standby Buckets, which arrived in Android 9, are a property of the specific app. And they are the part most people do not know about.

The system classifies every installed app into a bucket based on how that particular user uses it:

  • Active: they are using it right now.
  • Working set: they use it often.
  • Frequent: they use it regularly, but not every day.
  • Rare: they hardly ever open it.
  • Restricted (added in Android 11): the harshest bucket, for apps that consume a lot and are used little.

Each level carries a quota, which represents how many job entries it can run per day or how much network access is deferred, among many others. Dropping from working set to rare can mean going from dozens of daily runs to a couple of them, completely breaking the app's functionality.

What matters is who decides which level that application belongs to. And unfortunately, the developer does not decide it. The user decides it, through how often they use the app. You can read which bucket you are in with UsageStatsManager, but that is purely informational, you cannot change it.

This has a consequence worth internalizing early: the development device lives permanently in ACTIVE, because you open the app twenty times a day to test it. The user who installed it three weeks ago and has not touched it since lives in RARE. Testing happens in an environment the real user base barely inhabits.

Doze Mode can be reproduced locally without waiting weeks using these commands:

Bash
1adb shell am set-standby-bucket com.your.package rare
2adb shell dumpsys battery unplug
3adb shell cmd deviceidle force-idle

The last one forces entry into Doze. adb shell cmd deviceidle step manually advances to the next state and is useful to trigger a maintenance window without waiting.

4. Scheduling is not the same as running

With all of this understood, there are three options and each one answers a different question.

  1. AlarmManager: For time events that are visible to the user: an alarm clock, a calendar reminder, a countdown. It does not work for background jobs (although it used to be used for that). Since Android 12 exact alarms need the SCHEDULE_EXACT_ALARM permission, and since Android 13 that permission no longer comes granted out of the box on new installs. So you have to send the user to settings to enable it, and they have to enable it themselves. The alternative is USE_EXACT_ALARM, which is granted on its own and the user cannot revoke it, but Google Play states that this feature is reserved for apps whose purpose is precisely that (alarm clocks, calendars).
  2. JobScheduler: The system's low level API. It lets you declare constraints and the system decides when to fire. It works, but it leaves you to handle persistence across reboots and retries by hand.
  3. WorkManager: A constraint declarer with a persistence guarantee. The developer does not state when they want something to run, they specify which conditions make it meaningful to run at all. The system fits it wherever it can. In exchange, it guarantees that the work survives the app being closed and the device restarting.
1// The API does not accept an interval shorter than 15 minutes, and even then
2// that number is the minimum before execution, not the cadence it will happen at.
3private val SYNC_INTERVAL = 15.minutes
4
5fun scheduleSync(context: Context) {
6    val constraints = Constraints.Builder()
7        // You declare conditions, not moments. That is the important part.
8        .setRequiredNetworkType(NetworkType.CONNECTED)
9        .setRequiresBatteryNotLow(true)
10        .build()
11
12    val request = PeriodicWorkRequestBuilder<SyncWorker>(SYNC_INTERVAL.toJavaDuration())
13        .setConstraints(constraints)
14        // If the work fails, the system spaces out the retries on its own.
15        .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
16        .build()
17
18    WorkManager.getInstance(context).enqueueUniquePeriodicWork(
19        "sync",
20        // KEEP avoids duplicating the work on every app launch.
21        ExistingPeriodicWorkPolicy.KEEP,
22        request,
23    )
24}

A background job does not run when you want it to. It runs when the system can. The only way for that to stop being a problem is for your code not to care when.

5. The process dies and the work has to survive

The other half of the matter is not the clock, it is the process. Generally, the app is not a server that is always up. It is a process that gets created and destroyed, and the system is the one deciding when to do it, whenever it suits. And the worst part is that there are at least four different ways for an application to die. Each one behaves differently:

  • RAM pressure. The system needs RAM and kills the process of the app that matters least at that moment. You cannot decide that your app is important at that precise moment, so it is down to luck. There is no callback, no warning, no guarantee that onDestroy gets called. It is the most common and the most silent.
  • Manual swipe. The user removes the app from the recents list (what is known as a "swipe"). On AOSP this should not kill scheduled work, and in practice it depends on the device. The only possible warning is receiving an onTaskRemoved if you use a Service.
  • Configuration change / app update. The process restarts for whatever reason. Anything scheduled with WorkManager survives because it lives in the database, but everything that was in memory does not.
  • Force Stop. The user goes into settings and taps "Force stop". This is manual. And it is different from all of the above. In these cases alarms and job entries are cancelled, and the app is left in an "unresponsive" state until somebody opens it by hand. There is no way around it.

In React Native there is an extra: the JavaScript thread does not survive (in general). HeadlessJS spins up a JS context without an Activity to handle a background event, and it works. But that context also freezes with Doze and dies with the process. If something really has to hold up, it has to live in native code (Kotlin).

6. Designing for a clock that is not yours

Once you accept that you cannot choose the moment the application dies, the work becomes a design question with three properties to negotiate.

The first one is idempotency. WorkManager guarantees at least one execution, but not exactly one. A job that was killed halfway gets launched again in full. So if the task was incrementing a counter or inserting a row without a key, the data gets duplicated with no explanation. Anything that runs in the background has to be able to run twice without consequences.

The second one is checkpoints. A job that processes five hundred items and dies at the four hundredth has to resume at the four hundredth, not start from zero (it would duplicate work and items). Assuming each maintenance window gives you a few seconds to act (not minutes), the unit of work has to be small by design.

Finally, persistence. The third one is to stop asking "what needs doing now" and start asking "what has changed since the last time I finished the process cleanly". That is a persisted watermark, and it is what turns a missed execution into a slightly bigger batch next time instead of a hole in the data.

1// Small on purpose: a maintenance window gives you seconds.
2private const val BATCH_SIZE = 50
3
4class SyncWorker(
5    context: Context,
6    params: WorkerParameters,
7    // Injected via WorkerFactory: the default factory does not know how to build it.
8    private val repo: SyncRepository,
9) : CoroutineWorker(context, params) {
10
11    override suspend fun doWork(): Result {
12        // We do not ask what time it is, we ask where we left off.
13        val watermark = repo.lastSuccessfulWatermark()
14        val batch = repo.changesSince(watermark, limit = BATCH_SIZE)
15
16        if (batch.isEmpty()) return Result.success()
17
18        return try {
19            repo.upload(batch)
20            // The checkpoint is committed only once the batch is uploaded.
21            repo.commitWatermark(batch.last().timestamp)
22            // There was more work left: we ask for another turn instead of stretching this one.
23            if (batch.size == BATCH_SIZE) Result.retry() else Result.success()
24        } catch (e: IOException) {
25            // Transient failure: exponential backoff takes care of it.
26            Result.retry()
27        } catch (e: SerializationException) {
28            // Permanent failure: retrying a thousand times will not fix it.
29            repo.quarantine(batch)
30            Result.failure()
31        }
32    }
33}

7. The foreground service is borrowed time

Everything above assumes the work can wait. However, there are products where it cannot. In the electric vehicle telemetry app the data only existed while the car was moving. If the process was not alive at that moment, the trip was not recorded and there was no way to recover it afterwards. (We considered a batches system to store the result and upload it later once connectivity came back, but that was pretty weak and brought its own bugs.)

For those cases Android offers an explicit deal. A mechanism called a foreground service that comes with a persistent notification. The user is told that work is happening, and in exchange the system does not freeze the application. It is the only mechanism that turns "whenever I can" into "for as long as this lasts".

Unfortunately, the deal comes with fine print (and since Android 14, a lot more of it). Every foreground service has to declare a foregroundServiceType, and the type is not an administrative label: it changes the conditions under which you can start it and the guarantees you get. It is also another point that gets reviewed before publishing to the store and that needs justification.

XML
1<!-- The type determines which permission you need and where you can start it from. -->
2<service
3    android:name=".TripRecordingService"
4    android:foregroundServiceType="connectedDevice|location"
5    android:exported="false" />

connectedDevice for an active BLE connection, location for location tracking, mediaPlayback for playback. And dataSync, which is the one people pick by default when they do not know which to put (myself included at the start), and it is exactly the one that gives the fewest guarantees.

Since Android 15, dataSync services have a cap of six hours every twenty four. When they hit it, the system throws a timeout and stops them. In general, they cannot be started from the background either. Using dataSync for something that has to last all day will end up getting it killed after six hours.

The notification also has to give the user something: if it says something concrete like "recording trip, 12 km" instead of something generic like "the app is running", the user understands why it is there and does not go looking for a way to dismiss it. It also tells them the app is working correctly, even when they do not have it open in the foreground or the phone unlocked.

8. What you can control

The clock is not something you can edit or control.

Knowing this limitation, you can avoid tricks from the start, so they do not age badly. What you can do is:

  1. Decide what happens when the clock comes back. Turning an "incident" into a delay.
  2. Window size. With the time limit, you have to build jobs that last seconds, to make sure they always fit inside that window.
  3. Inform the user. Notifications are an ally here. An app that shows "last sync: 4 hours ago" next to a refresh button is an app that works even when the system has kept it frozen all night.
  4. What you measure. Log every run, both expected and real. That way you get the percentage of runs that actually happen, and it stops being a feeling and becomes concrete data.

Background work on Android is not a promise the system makes. It is a permission that gets renewed every so often. The architecture that holds up is not the one that gets the most executions, it is the one that does not depend on any single one.

Related Articles

View all articles
Android Permissions Change Depending on the Phone Brand

Android Permissions Change Depending on the Phone Brand

Xiaomi, Samsung, Huawei... every manufacturer has its own permission hell. Why you end up writing your own permission manager, and how to tame the per-brand quirks.

The Journey of an Error: From a Backend 500 to the Message the User Sees

The Journey of an Error: From a Backend 500 to the Message the User Sees

An error crosses four layers before it reaches the user: the HTTP interceptor, the mapping to domain errors, the default behavior policy, and the render safety net. Three implementations (TypeScript, Kotlin and Swift) of the same pattern.

Repository Pattern: Your UI Shouldn't Know Where Data Comes From

Repository Pattern: Your UI Shouldn't Know Where Data Comes From

An interface that hides whether data comes from the network, cache, or disk. How to swap the source without touching a single screen.