What you’ll study
- What WorkManager is for, and what it promises: eventually, not now.
- The database row as the single source of truth.
- Two schedulers: one in your process, one in the system’s
JobScheduler. - The states a request moves through, and how results move it.
- Retries, the backoff maths, and periodic work.
- Recovery after crashes, force-stops and reboots.
- Edge cases: device makers, Doze, quotas, long-running work.
How it connects
Everything follows from one decision: write the work to a database before doing anything. Because the row is the truth, schedulers can come and go: one runs work fast while the app is open, the other wakes the app up later. Because every run ends by updating the row, retries and periodic work are just state changes. And because the row survives what system jobs don’t (reboots, force-stops, crashes), WorkManager can rebuild everything on the next start, which is also why a worker can run twice.
Read it at your level
| Level | Read | You should be able to |
|---|---|---|
| Student | The basics, the high level, the simulator | Explain what WorkManager is and why a plain thread isn’t enough |
| Fresher | + the states diagram | Read a WorkInfo state and say what happens next |
| Junior | + Why it helps, cross questions | Pick constraints and backoff, make workers idempotent, stay under the 10 KB Data limit |
| Mid | + The low level 1–5, The map | Explain the two schedulers, how a run is kept single, the backoff formula and unique-work policies |
| Senior | + The low level 6, Edge cases | Reason about force-stops, reboots, device makers’ limits, expedited quotas and multiple processes |
The basics
Some work has to happen even if the user leaves your app: uploading a photo, syncing a database, sending logs. You can’t just start a thread, because Android may kill your process at any moment. WorkManager is the Jetpack library for work that must finish eventually, even across process death and reboots.
You describe the work once:
val upload = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(
Constraints(requiredNetworkType = NetworkType.CONNECTED)
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30.seconds)
.build()
WorkManager.getInstance(context).enqueue(upload)
and WorkManager promises to run UploadWorker.doWork() when the constraints are met, retrying if
you ask it to. It does not promise to run it now, or at an exact time.
The rest of this guide answers one question: how does a library keep that promise when it can’t keep a thread alive?
The high level
The short answer: it writes the work down before doing anything else. Every request becomes a row in a small database inside your app. Everything after that is about getting the row run.
enqueue(request)
│
▼
WorkDatabase (Room, "androidx.work.workdb") ◄── single source of truth
│ one row per request: state, constraints, attempts, next run time
│
├──► GreedyScheduler in your process, no wakeups:
│ runs work now if the app is alive
│
└──► SystemJobScheduler hands a job to Android's JobScheduler:
the system wakes your app later
│
▼ (whichever gets there first)
Processor ──► WorkerWrapper ──► your Worker.doWork()
│
▼
Result: success / retry / failure ──► written back to the row
Three ideas carry the whole design:
- The database is the truth. Schedulers can lose their jobs (reboot, force-stop, a crash). The row can’t, so WorkManager can always rebuild the jobs from the rows.
- Two schedulers, one runner. The in-process scheduler makes work fast while your app is open; the system scheduler makes it happen when it isn’t. Both feed the same runner, which makes sure a request runs once.
- Every run ends by updating the row. Success, retry and failure are just state changes, and the next schedule is computed from the new state.
The states a request moves through
┌────────── BLOCKED (waiting for a chain parent)
▼
enqueue ─► ENQUEUED ─► RUNNING ─► SUCCEEDED
▲ │ └──► FAILED (also fails everything after it)
│ retry / │
└── system ──┘ CANCELLED (from any unfinished state)
stop
Periodic work never reaches SUCCEEDED or FAILED: every run, whatever its result, goes back to ENQUEUED for the next period.
Checkpoint
Before going deeper, you should be able to say:
- Every request becomes a row in
androidx.work.workdbbefore anything runs. - Two schedulers race to run it;
Processormakes sure it runs only once at a time. - Success, retry and failure are state changes on that row.
- Periodic work never finishes: every run goes back to ENQUEUED.
Play the simulator once with retry() and once with a kill mid-run before reading on.
The low level
1. Start-up
WorkManager starts itself through App Startup: its manifest adds an InitializationProvider
entry for WorkManagerInitializer, which runs before your Application.onCreate(). If you need a
custom WorkerFactory (for Hilt, say), you remove that entry and implement
Configuration.Provider, and WorkManager initialises itself on first use instead.
Initialisation builds two schedulers (createSchedulers in WorkManagerImplExt.kt):
GreedyScheduler: runs work inside your process. Its own comment calls it a scheduler that “intentionally does not acquire any WakeLocks” and tries to run work “before the process gets killed”. It only works in your app’s default process.SystemJobScheduler: converts each row to aJobInfoand hands it to the platformJobScheduler. The system later startsSystemJobService, which asks WorkManager to run it. (Older versions also had anAlarmManagerscheduler for very old Android; the current source only has the JobScheduler one.)
Start-up also runs ForceStopRunnable, the safety net covered in step 6.
2. enqueue() writes rows
EnqueueRunnable inserts, in one database transaction:
- a
WorkSpecrow per request: worker class, inputData, constraints, backoff, delay, period,runAttemptCount,lastEnqueueTime, state; Dependencyrows for chains (beginWith(a).then(b)makesbdepend ona, andbstarts in BLOCKED);WorkNamerows for unique work andWorkTagrows for tags.
Unique work is resolved here, against the existing rows with the same name:
| Policy | If unfinished work with that name exists |
|---|---|
KEEP |
Keep the old one; ignore the new request |
REPLACE |
Cancel and delete the old one; enqueue the new one |
APPEND |
Run the new one after the old chain’s last items (if they failed or were cancelled, the new one is too) |
APPEND_OR_REPLACE |
Append, but drop failed or cancelled parents and start a fresh chain |
Two details from EnqueueRunnable itself: KEEP only keeps old work that is ENQUEUED or RUNNING
(otherwise it replaces it), and APPEND treats every row with that name still in the database as
a parent, finished ones included until they are pruned. That is why appending after a failure
fails.
Periodic unique work has its own policies: KEEP, UPDATE (change the request in place, without
cancelling a running worker or resetting the period) and CANCEL_AND_REENQUEUE.
3. Scheduling the rows
Schedulers.schedule() reads eligible rows and gives them to both schedulers, with limits:
- JobScheduler has a cap on pending jobs per app, so WorkManager only hands it
maxSchedulerLimitrows at a time: 20 by default, 50 at most. On Android 6.0 (API 23) it uses half, because work there is scheduled twice. - The in-process scheduler takes up to 200 rows.
For JobScheduler, SystemJobInfoConverter maps your request onto a JobInfo:
| Your request | JobInfo |
|---|---|
| Network, charging, idle, battery/storage not low | setRequiredNetwork…, setRequiresCharging, … |
| Initial delay or backoff wait | setMinimumLatency |
| Backoff policy | setBackoffCriteria |
setExpedited (Android 12+, first attempt, no delay) |
setExpedited(true) |
| Content URI triggers | addTriggerContentUri |
| Everything | setPersisted(false) |
That last line is deliberate. The comment says: “We don’t want to persist these jobs because we reschedule these jobs on BOOT_COMPLETED.” WorkManager trusts its own database across reboots, not JobScheduler’s.
4. Running: Processor and WorkerWrapper
When a scheduler decides a row is ready, it calls Processor.startWork(). Both schedulers may try
to start the same row; the processor keeps a token per run and logs “is already enqueued for
processing” for the second attempt instead of running it twice.
WorkerWrapper then:
- Checks the row is still ENQUEUED (it may have been cancelled), and that periodic or backed-off work isn’t early.
- Merges inputs from chain parents with the request’s
InputMerger. - Creates your worker through the
WorkerFactory. - For expedited work on Android 11 and lower, calls your
getForegroundInfo()and runs as a foreground service, because those versions have no expedited jobs. - Calls
startWork().Worker.doWork()runs on the configuration’s executor, a pool ofmax(1, min(cores - 1, 4))threads.CoroutineWorker.doWork()runs onDispatchers.Defaultunless you override it.
5. Writing the result back
| Result | One-time work | Periodic work |
|---|---|---|
success() |
SUCCEEDED, output saved, children unblocked | Back to ENQUEUED, next period |
retry() |
ENQUEUED, runAttemptCount + 1, next run after backoff |
Same |
failure() |
FAILED, and every dependent too | Back to ENQUEUED, next period |
| Stopped by the system | ENQUEUED again, no attempt counted | Same |
Backoff comes from WorkSpec.calculateNextRunTime():
LINEAR: delay = backoff × runAttemptCount
EXPONENTIAL: delay = backoff × 2^(runAttemptCount − 1)
next run = lastEnqueueTime + min(delay, 5 hours)
defaults: EXPONENTIAL, 30 s limits: at least 10 s, at most 5 h
So with the defaults, retries wait 30 s, 1 min, 2 min, 4 min, and so on, up to five hours.
A system stop (constraints no longer met, or the system ending the job) sets the row back to
ENQUEUED and records the stopReason (visible in WorkInfo from Android 12) without counting an
attempt. Call setBackoffForSystemInterruptions() on the request if you want those stops to back
off too.
Periodic work: the interval must be at least 15 minutes and the flex window at least 5 minutes, and the first run can happen right away.
6. Surviving reboots, crashes and force-stops
This is where the “database is the truth” idea pays off. On every start, ForceStopRunnable:
- Reconciles JobScheduler with the database, cancelling jobs that no longer match a row.
- Resets RUNNING rows to ENQUEUED, and deletes all saved progress. If the process died mid-run, that work will run again from the beginning.
- Detects a force-stop. A force-stop cancels an app’s jobs and alarms. On Android 11+ it reads
ApplicationExitInfofor aREASON_USER_REQUESTEDexit; on older versions it keeps a sentinelPendingIntentalarm that a force-stop silently removes. Either way, it reschedules everything.
After a reboot, RescheduleReceiver (listening for BOOT_COMPLETED) wakes the app so the
same rescheduling happens. That is why WorkManager adds the RECEIVE_BOOT_COMPLETED permission to
your manifest.
Finished rows are deleted when the database opens, once they are more than a day old (or
older than keepResultsForAtLeast) and nothing still depends on them.
Why it helps
- “My worker ran twice.” Expected. A crash mid-run resets the row and runs it again. Make
doWork()idempotent: upload with a client-generated id, write with upserts. - “It didn’t run at 9:00.” WorkManager isn’t an alarm clock. Delays are minimums, and the system
batches jobs. Use
AlarmManager.setExactAndAllowWhileIdle(with its permission rules) for user-visible exact times. - “It stopped after the user force-stopped the app.” Android cancels everything until the app is opened again. WorkManager can only reschedule once your process starts.
- “Retries take hours.” Exponential backoff doubles every attempt, up to five hours. Pick the
policy and delay for your case, and cap attempts yourself with
runAttemptCount. - “Data too large.” Input and output
Datais stored in the row and limited to 10 KB. Pass a file path or database id instead. - “Work vanished from my
getWorkInfosquery.” Finished work is pruned after a day.
The map
| Idea | Where it lives in AndroidX |
|---|---|
| Start on app launch | WorkManagerInitializer, androidx.startup.InitializationProvider |
| Custom setup | Configuration, Configuration.Provider, WorkerFactory |
| The database | WorkDatabase (androidx.work.workdb), WorkSpec, Dependency, WorkName |
| Enqueue and unique work | EnqueueRunnable, ExistingWorkPolicy, ExistingPeriodicWorkPolicy |
| Choosing what to schedule | Schedulers.schedule(), Configuration.maxSchedulerLimit |
| In-process scheduler | GreedyScheduler, DelayedWorkTracker, WorkConstraintsTracker |
| System scheduler | SystemJobScheduler, SystemJobInfoConverter, SystemJobService |
| Run once | Processor, StartStopToken |
| Run a worker | WorkerWrapper, WorkerFactory, InputMerger |
| Expedited below Android 12 | getForegroundInfo(), SystemForegroundService |
| Retry timing | WorkSpec.calculateNextRunTime(), BackoffPolicy, WorkRequest limits |
| Recovery | ForceStopRunnable, RescheduleReceiver |
| Cleanup | CleanupCallback in WorkDatabase.kt, keepResultsForAtLeast |
Edge cases
- OEM battery savers. Some manufacturers delay or kill background jobs far more aggressively than stock Android. WorkManager can’t run work the system won’t start; test on those devices.
- Doze and App Standby buckets. Jobs for apps in rare buckets run less often and in maintenance windows. Delays of hours are normal for an app the user hasn’t opened in days.
- Multiple processes. The in-process scheduler only runs in the default process. Other
processes rely on JobScheduler, or need the
work-multiprocessartifact. - Clock changes. Periodic work re-bases its next run on the current time after every run, so a changed clock can’t make it run in a tight loop.
- Expedited quota. Expedited jobs have a system quota. When it runs out,
OutOfQuotaPolicydecides: run as normal work, or drop the request. - Long work. Normal jobs have an execution time limit. For long uploads, call
setForeground()with aForegroundInfoso the work runs as a foreground service, and declare the service type your work needs on Android 14+. - Constraints that flap. If the network drops mid-run, the job is stopped and the worker is
told through
isStopped/cancellation. It goes back to ENQUEUED without using up an attempt.
Cross questions
Why does WorkManager need its own database if JobScheduler already stores jobs?
JobScheduler loses an app's jobs on force-stop, has a cap on pending jobs, and can't express chains, unique names, tags or input merging. WorkManager keeps the full truth in its own rows, deliberately creates non-persisted jobs, and rebuilds them on every start and after every boot.
If both schedulers can start the same work, why doesn't it run twice?
Processor tracks a token per run. A second start for a row that is already running is logged and ignored, and WorkerWrapper also refuses to run a row that isn't ENQUEUED any more.
Can work still run twice?
Yes. If the process dies while a worker is RUNNING, the next start resets the row to ENQUEUED and it runs again from the beginning. Design workers to be idempotent.
What is the difference between Worker, CoroutineWorker and ListenableWorker?
ListenableWorker is the base: startWork() returns a future. Worker runs blocking doWork() on the configuration's executor. CoroutineWorker runs a suspending doWork() on Dispatchers.Default, and cancels the coroutine when the work is stopped.
Why is the minimum periodic interval 15 minutes?
It matches the platform's own minimum period (JobInfo.getMinPeriodMillis()). Background work is batched to save battery. For anything more frequent while the app is open, use a coroutine in the app's scope, not WorkManager.
How does an expedited request behave on Android 11 versus 12?
On 12+ it becomes a JobScheduler expedited job (first attempt only, no delay, within a quota). On 11 and lower WorkManager runs it as a foreground service, using the notification from your getForegroundInfo(), so you must implement it.
A chain is A → B → C and B fails. What happens to C?
C is marked FAILED too: failure walks every dependent of B. With APPEND on unique work, a new request appended after a failed one also fails; APPEND_OR_REPLACE starts a fresh chain instead.
Your worker returns retry() five times with default settings. How long has it waited in total?
Exponential from 30 s: 30 s + 1 min + 2 min + 4 min + 8 min, about 15.5 minutes, plus however long the system took to start each job.
How do you observe progress, and what happens to it after a crash?
Call setProgress() in the worker and observe getWorkInfoByIdFlow() (or LiveData). Progress is stored in its own table and is deleted on every start, so after a crash it starts empty.
How would you test a worker?
Use work-testing: TestListenableWorkerBuilder to call doWork() directly, or WorkManagerTestInitHelper with TestDriver to satisfy constraints and delays yourself.
Revision: key points
- WorkManager guarantees “eventually, when the constraints are met”, never an exact time.
- The Room database
androidx.work.workdbis the truth; system jobs are rebuilt from it. GreedySchedulerruns work in your process;SystemJobSchedulerhands it toJobSchedulerwithsetPersisted(false).- JobScheduler gets at most
maxSchedulerLimitrows at a time: 20 by default, 50 at most. - Backoff is exponential by default, starting at 30 s, at least 10 s, capped at 5 hours.
- Periodic work needs at least a 15-minute interval and a 5-minute flex, and never ends SUCCEEDED or FAILED.
- A failure fails every dependent;
APPENDafter a failure fails too. - A system stop doesn’t count as an attempt unless you call
setBackoffForSystemInterruptions(). - On every start, RUNNING rows go back to ENQUEUED: workers must be safe to run twice.
Datais capped at 10 KB, and finished work is pruned after a day.
Then close the page and try the cross questions from memory.
The fine print
The flow above is the current AndroidX source (October 2026). Defaults and limits change between releases, so check the version you ship. Device makers can and do change how background jobs are scheduled, which is why the only real guarantee is “eventually, if the system lets it”.
Next in this series: how a ViewModel survives rotation, in Activities and in Compose.