Zygote
Background worknerd

WorkManager internals

WorkManager looks like a simple enqueue API. Underneath it is a SQLite database, a constraint-aware scheduler and a JobService, and understanding that stack explains why your work did not run when you expected.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

WorkManager's API is four lines. Its behaviour is a database, a scheduler, a wake lock and a JobService — and almost every "WorkManager is unreliable" report is really one of those four.

TL;DR#

  • Every request is written to a Room-backed SQLite database before it is scheduled. That is why work survives process death.
  • On modern devices the actual scheduling is delegated to JobScheduler; on older ones it used an AlarmManager plus a receiver.
  • Constraints are conditions, not triggers. The system decides when to check them.
  • enqueueUniqueWork is how you avoid duplicate queueing; the name is the identity.
  • Expedited work runs immediately where the platform supports it, with a quota and a fallback to a foreground service.
  • Result.retry() with backoff is the only correct way to handle a transient failure. Returning success on failure is how a sync silently stops working.

Mental model#

Think of WorkManager as a very organised task diary with a scheduler attached.

When you write a task in the diary — enqueue() — it is written down before anyone acts on it. The diary is the source of truth. If the office burns down (the process dies) and is rebuilt, the diary is still there and the task is still owed.

A dispatcher reads the diary and decides what can happen now. Some tasks say "only when the network is available" or "only while charging"; the dispatcher asks the front desk (the system) whether those conditions hold. The front desk says yes, no, or "not now".

When the conditions are right, the front desk sends a message to the worker — your code — which does the task and writes the result back into the diary.

Where the analogy breaks#

A diary has one reader. WorkManager has a dispatcher inside your process and a JobScheduler inside system_server, and the second one can delay the first for hours. You are not in control of the queue; you are making a request to a system that has its own queue with its own policy.

The big picture#

Loading diagram…
Text description of this diagram

A ten-step sequence between six participants: your app, the WorkManager library, the Room-backed WorkDatabase, the GreedyScheduler and JobScheduler client, the JobSchedulerService in system_server, and your Worker. Enqueue writes a WorkSpec row to the database before scheduling. The row id is returned. The scheduler is asked to schedule it. Constraints become JobInfo conditions such as network type, charging, battery-not-low and idle. The system reports when constraints are met, possibly hours later because of Doze or standby buckets. The library's JobService starts, acquires a wake lock, and instantiates the Worker through the WorkerFactory. doWork runs on a background executor. The result is written back to the database as SUCCEEDED, FAILED or ENQUEUED for a retry. Observers see the WorkInfo from the database, which is why state survives configuration changes and process restarts.

Step through it and notice the two places where the flow can pause: the system deciding constraints are not yet met, and the database write happening before anything else.

Deep dive#

The database is the API#

WorkManager.enqueue() returns a Operation, not a result. The real state lives in WorkDatabase: a Room database with tables for WorkSpec, WorkProgress, WorkTag, Dependency and SystemIdInfo.1

This design decision explains almost every property of WorkManager:

  • It survives reboot. The database is on disk; on boot, WorkManager reschedules anything still enqueued.
  • It survives force-stop, in the sense that the rows remain, but scheduling resumes only when the app is opened again. That is an OS rule, not a WorkManager one.
  • Observation is cheap. getWorkInfoByIdLiveData() is a database query, not a callback registry.
  • Unique work is a database constraint. The unique name maps to a key in the table.

The scheduling backend#

On API 23 and above, WorkManager uses SystemJobService, a JobService that the library declares in its own manifest. Each WorkSpec becomes a JobInfo:2

WorkManagerJobInfo
NetworkType.CONNECTEDsetRequiredNetworkType(NETWORK_TYPE_ANY)
requiresCharging()setRequiresCharging(true)
requiresBatteryNotLow()setRequiresBatteryNotLow(true)
requiresDeviceIdle()setRequiresDeviceIdle(true)
requiresStorageNotLow()setRequiresStorageNotLow(true)
periodic worksetPeriodic(interval)

On API levels below 23, WorkManager falls back to an AlarmManager and BroadcastReceiver based scheduler.

unverified(exact backend behaviour for the oldest supported API levels has changed across WorkManager versions; check the release notes for the version you ship)

This mapping has a consequence people miss: WorkManager inherits every JobScheduler restriction. Doze defers jobs3. Standby buckets reduce the number of jobs per day.4 Android 16's quota accounting changes apply. WorkManager cannot make a job run when the platform refuses to run jobs.

Constraints, retries and backoff#

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)
    .setRequiresCharging(true)
    .build()
 
val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
    .setConstraints(constraints)
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
    .build()

PeriodicWorkRequest has a minimum interval and a flex window; the system may run it later than requested, and it will not run it earlier. The interval is a floor, not a promise.

Backoff applies only when the worker returns Result.retry(). EXPONENTIAL doubles the delay each attempt up to a cap; LINEAR increases it by a constant.

override suspend fun doWork(): Result {
    return try {
        api.sync()
        Result.success()
    } catch (e: IOException) {
        // Transient: the system will retry with backoff.
        Result.retry()
    } catch (e: HttpException) {
        if (e.code() in 500..599) Result.retry() else Result.failure()
    }
}

Chaining#

WorkManager is the only background API that models a pipeline:

WorkManager.getInstance(context)
    .beginUniqueWork(
        "publish-${postId}",
        ExistingWorkPolicy.REPLACE,
        OneTimeWorkRequestBuilder<CompressWorker>().build(),
    )
    .then(OneTimeWorkRequestBuilder<UploadWorker>().build())
    .then(OneTimeWorkRequestBuilder<NotifyWorker>().build())
    .enqueue()

A chain runs sequentially: each step's output becomes the next step's input through Data. If any step returns failure(), the rest of the chain is cancelled. Result.retry() pauses the chain and retries that step.

beginWith(listOf(a, b)) starts several steps in parallel and joins their outputs before the next step — the only combining construct in the API.

Expedited work#

For work that must start soon but is not user-visible, setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) asks the system to run it immediately. On API 31+, expedited work is backed by an expedited job; on older versions it may be backed by a foreground service, which is why the API requires a notification for those paths.

Expedited work has a quota. Once exhausted, the out-of-quota policy decides: run as normal work, or fail.1 This is not a way to bypass background limits; it is a way to spend a limited budget.

Testing#

work-testing provides TestDriver, TestListenableWorkerBuilder and an in-memory database, so workers can be tested without a device scheduler.1

@Test
fun uploadRetriesOnIoFailure() = runTest {
    val worker = TestListenableWorkerBuilder<UploadWorker>(context)
        .setInputData(workDataOf(KEY_PHOTO_ID to "42"))
        .build()
 
    val result = worker.doWork()
    assertEquals(Result.retry(), result)
}

The value of this is not coverage. It is that the retry logic is testable without waiting for a real scheduler, which is otherwise a multi-hour feedback loop.

Reading the state machine#

WorkInfo.State has more values than most people expect, and each one is a diagnosis.

StateWhat it meansWhat to check
ENQUEUEDWaiting for constraints or schedulingAre the constraints satisfiable on this device?
RUNNINGdoWork() is executingIs the worker stuck on a blocking call?
BLOCKEDWaiting on an upstream step in a chainWhich step is unfinished?
SUCCEEDEDReturned Result.success()—
FAILEDReturned Result.failure() or exhausted retriesRead the output data and the run attempt count
CANCELLEDCancelled by the app or by unique-work replacementWas REPLACE used where KEEP was meant?

A worker stuck in ENQUEUED is almost never a WorkManager bug. It is a constraint the device does not meet, a bucket that is too restrictive, or a job the system has decided not to run yet.

Diagnosing "it did not run"#

The order to check, cheapest first:

  1. adb shell dumpsys jobscheduler | grep <package> — is there a job at all?
  2. The state in the WorkDatabase — ENQUEUED, BLOCKED or absent?
  3. The constraints — does this device ever meet them?
  4. The standby bucket — is the app in rare or restricted?
  5. Battery optimisation — is the app exempt or restricted?

Each step rules out one layer, and the answer is usually in steps two and three.

Unique work as a correctness tool#

The unique-work APIs are not a convenience; they are the mechanism that keeps a queue from duplicating itself.

Three policies, three behaviours, and one common mistake:

  • KEEP leaves an existing unfinished work alone and drops the new request. This is right for "one sync at a time".
  • REPLACE cancels the existing work and starts the new request. This is right when the newest request supersedes the old one — and wrong when the old work has partial progress worth keeping.
  • APPEND_OR_REPLACE appends to the chain, or replaces if the chain has failed. This is the policy most sync features actually want.

The mistake is defaulting to REPLACE because it "always runs". It does always run — and it cancels the upload that was ninety percent finished.

How it evolved#

2015–2018GcmNetworkManagerPlay-services-backed scheduling
2016–2018Firebase JobDispatchera stopgap for JobScheduler on old devices
2018–nowWorkManagerone API, database-backed, chaining and unique work
Background and power changes12 releases
APIVersionWhat changed
11.0
  • The original android.* SDK: Activity, Service, BroadcastReceiver, ContentProvider
174.2 Jelly Bean
  • Daydream service
194.4 KitKat
  • AlarmManager becomes inexact by default for targeting apps
215.0 Lollipop
  • JobScheduler
  • Implicit service intents restricted for targeting apps
268.0 Oreo
  • Background service limits
289 Pie
  • Foreground service permission
2910
  • Background activity starts restricted
3011
  • Background location requires a second request
3112
  • Exact alarms need SCHEDULE_EXACT_ALARM
  • Foreground service launch restrictions from the background
3414 Upside Down Cake
  • Foreground service types required when targeting 34
  • User-initiated data transfer jobs
3515 Vanilla Ice Cream
  • Deprecated APIs: WindowInsetsController hiding, elevate the importance of foreground services
3616 Baklava
  • JobScheduler quota changes

Under the hood#

The controller directory is where "constraints" stop being an abstraction: each condition is a StateController that reports whether the device currently satisfies it.

Curiosity corner#

  • WorkManager's database is per-process by default. Multi-process apps need the work-multiprocess artifact, or the two processes will have separate views of the queue.
  • ExistingWorkPolicy.REPLACE cancels and re-enqueues, which can lose progress. KEEP and APPEND_OR_REPLACE are usually what you want.
  • Periodic work drifts. A six-hour periodic request is not a six-hour metronome; it is a minimum spacing, and the system may run it much later.

Try it#

Look at the WorkManager database

adb shell "run-as com.example.app sqlite3 databases/androidx.work.workdb 'select id, state, worker_class_name from WorkSpec;'"

Example output
WorkSpec|UploadWorker|ENQUEUED|0|...
Dump the jobs WorkManager scheduled

adb shell dumpsys jobscheduler | grep -B2 -A8 androidx.work

Example output
JOB #u0a123/1001: com.example.app/androidx.work.impl.background.systemjob.SystemJobService

Build it#

Enqueue a one-time worker with a network constraint, then turn on airplane mode and inspect the database. The row is ENQUEUED, not running, and no amount of waiting changes that until the constraint is satisfied. That is the mental model, made visible.

Check yourself#

Check yourself

1 / 12

Put these background restrictions in the order they were introduced.

Drag, or use the arrows to reorder.

  1. 1Doze defers network, jobs and alarms (API 23)
  2. 2Background activity starts are blocked (API 29)
  3. 3Background services are stopped after the app leaves the foreground (API 26)
  4. 4Foreground service types become mandatory (API 34)
  5. 5Alarms become inexact by default (API 19)
0 answered · 0 correct

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsWorkmanagerdeveloper.android.comaccessed 2026-10-27↩
  2. docsCustom WorkManager configurationdeveloper.android.comaccessed 2026-10-27↩
  3. docsOptimize for Doze and App Standbydeveloper.android.comaccessed 2026-10-27↩
  4. docsStandby bucketsdeveloper.android.comaccessed 2026-10-27↩
  5. docsBackground tasks overview | Background workdeveloper.android.comaccessed 2026-10-27↩
  6. docsBehavior changes: Apps targeting Android 13 or higherdeveloper.android.comaccessed 2026-10-27↩