← Back to android roadmap

How WorkManager Actually Runs Your Work

From enqueue() to doWork() and back: the database row, the two schedulers, retries, periodic work, reboots and force-stops, read from the AndroidX source. With a simulator, the class map, edge cases and cross questions.

  • Android
  • WorkManager
  • Background work
  • Internals
On this page
  1. What you’ll study
  2. The basics
  3. The high level
  4. Checkpoint
  5. The low level
  6. Why it helps
  7. The map
  8. Edge cases
  9. Cross questions
  10. Revision: key points
  11. The fine print

What you’ll study

  1. What WorkManager is for, and what it promises: eventually, not now.
  2. The database row as the single source of truth.
  3. Two schedulers: one in your process, one in the system’s JobScheduler.
  4. The states a request moves through, and how results move it.
  5. Retries, the backoff maths, and periodic work.
  6. Recovery after crashes, force-stops and reboots.
  7. 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:

  1. 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.
  2. 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.
  3. 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.

Interactive simulator: enqueue work with constraints, flip the network, kill the process, reboot or force-stop, and watch the database row and both schedulers react. Needs JavaScript.

Checkpoint

Before going deeper, you should be able to say:

  • Every request becomes a row in androidx.work.workdb before anything runs.
  • Two schedulers race to run it; Processor makes 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 a JobInfo and hands it to the platform JobScheduler. The system later starts SystemJobService, which asks WorkManager to run it. (Older versions also had an AlarmManager scheduler 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 WorkSpec row per request: worker class, input Data, constraints, backoff, delay, period, runAttemptCount, lastEnqueueTime, state;
  • Dependency rows for chains (beginWith(a).then(b) makes b depend on a, and b starts in BLOCKED);
  • WorkName rows for unique work and WorkTag rows 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 maxSchedulerLimit rows 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:

  1. Checks the row is still ENQUEUED (it may have been cancelled), and that periodic or backed-off work isn’t early.
  2. Merges inputs from chain parents with the request’s InputMerger.
  3. Creates your worker through the WorkerFactory.
  4. For expedited work on Android 11 and lower, calls your getForegroundInfo() and runs as a foreground service, because those versions have no expedited jobs.
  5. Calls startWork(). Worker.doWork() runs on the configuration’s executor, a pool of max(1, min(cores - 1, 4)) threads. CoroutineWorker.doWork() runs on Dispatchers.Default unless 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:

  1. Reconciles JobScheduler with the database, cancelling jobs that no longer match a row.
  2. Resets RUNNING rows to ENQUEUED, and deletes all saved progress. If the process died mid-run, that work will run again from the beginning.
  3. Detects a force-stop. A force-stop cancels an app’s jobs and alarms. On Android 11+ it reads ApplicationExitInfo for a REASON_USER_REQUESTED exit; on older versions it keeps a sentinel PendingIntent alarm 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 Data is stored in the row and limited to 10 KB. Pass a file path or database id instead.
  • “Work vanished from my getWorkInfos query.” 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-multiprocess artifact.
  • 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, OutOfQuotaPolicy decides: run as normal work, or drop the request.
  • Long work. Normal jobs have an execution time limit. For long uploads, call setForeground() with a ForegroundInfo so 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

  1. WorkManager guarantees “eventually, when the constraints are met”, never an exact time.
  2. The Room database androidx.work.workdb is the truth; system jobs are rebuilt from it.
  3. GreedyScheduler runs work in your process; SystemJobScheduler hands it to JobScheduler with setPersisted(false).
  4. JobScheduler gets at most maxSchedulerLimit rows at a time: 20 by default, 50 at most.
  5. Backoff is exponential by default, starting at 30 s, at least 10 s, capped at 5 hours.
  6. Periodic work needs at least a 15-minute interval and a 5-minute flex, and never ends SUCCEEDED or FAILED.
  7. A failure fails every dependent; APPEND after a failure fails too.
  8. A system stop doesn’t count as an attempt unless you call setBackoffForSystemInterruptions().
  9. On every start, RUNNING rows go back to ENQUEUED: workers must be safe to run twice.
  10. Data is 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.