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.
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
AlarmManagerplus a receiver. - Constraints are conditions, not triggers. The system decides when to check them.
enqueueUniqueWorkis 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#
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
| WorkManager | JobInfo |
|---|---|
NetworkType.CONNECTED | setRequiredNetworkType(NETWORK_TYPE_ANY) |
requiresCharging() | setRequiresCharging(true) |
requiresBatteryNotLow() | setRequiresBatteryNotLow(true) |
requiresDeviceIdle() | setRequiresDeviceIdle(true) |
requiresStorageNotLow() | setRequiresStorageNotLow(true) |
| periodic work | setPeriodic(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.
| State | What it means | What to check |
|---|---|---|
ENQUEUED | Waiting for constraints or scheduling | Are the constraints satisfiable on this device? |
RUNNING | doWork() is executing | Is the worker stuck on a blocking call? |
BLOCKED | Waiting on an upstream step in a chain | Which step is unfinished? |
SUCCEEDED | Returned Result.success() | — |
FAILED | Returned Result.failure() or exhausted retries | Read the output data and the run attempt count |
CANCELLED | Cancelled by the app or by unique-work replacement | Was 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:
adb shell dumpsys jobscheduler | grep <package>— is there a job at all?- The state in the WorkDatabase —
ENQUEUED,BLOCKEDor absent? - The constraints — does this device ever meet them?
- The standby bucket — is the app in
rareorrestricted? - 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:
KEEPleaves an existing unfinished work alone and drops the new request. This is right for "one sync at a time".REPLACEcancels 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_REPLACEappends 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#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 17 | 4.2 Jelly Bean |
|
| 19 | 4.4 KitKat |
|
| 21 | 5.0 Lollipop |
|
| 26 | 8.0 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 34 | 14 Upside Down Cake |
|
| 35 | 15 Vanilla Ice Cream |
|
| 36 | 16 Baklava |
|
Under the hood#
- JobScheduler.javathe platform API the library delegates to
- JobSchedulerService.javawhere jobs are queued, constrained and run
- JobService.javathe service the library's SystemJobService extends
- the constraint controllers: connectivity, battery, idle, storage
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-multiprocessartifact, or the two processes will have separate views of the queue. ExistingWorkPolicy.REPLACEcancels and re-enqueues, which can lose progress.KEEPandAPPEND_OR_REPLACEare 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#
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|...adb shell dumpsys jobscheduler | grep -B2 -A8 androidx.work
Example output
JOB #u0a123/1001: com.example.app/androidx.work.impl.background.systemjob.SystemJobServiceBuild 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
Put these background restrictions in the order they were introduced.
Drag, or use the arrows to reorder.
- 1Doze defers network, jobs and alarms (API 23)
- 2Background activity starts are blocked (API 29)
- 3Background services are stopped after the app leaves the foreground (API 26)
- 4Foreground service types become mandatory (API 34)
- 5Alarms become inexact by default (API 19)
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.
- docsWorkmanagerdeveloper.android.comaccessed 2026-10-27↩
- docsCustom WorkManager configurationdeveloper.android.comaccessed 2026-10-27↩
- docsOptimize for Doze and App Standbydeveloper.android.comaccessed 2026-10-27↩
- docsStandby bucketsdeveloper.android.comaccessed 2026-10-27↩
- docsBackground tasks overview | Background workdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 13 or higherdeveloper.android.comaccessed 2026-10-27↩