Skip to content
All posts

WorkManager in 2026: unique work, chaining, and testing background jobs on Android

A practical guide to WorkManager on Android — unique work policies, chaining, expedited work, and how to actually test a background job before it ships.

MFKAPPS 6 min read

Most WorkManager code I see in the wild is a single OneTimeWorkRequest enqueued and forgotten. That works right up until the user taps a button twice, the app process dies mid-job, or a reviewer asks how you know the job actually ran. WorkManager’s real value isn’t “schedule this later” — it’s the guarantees around uniqueness, chaining, and retry that keep a background job correct when the world around it is unreliable. Most of that value goes unused because the API surface for it is easy to skip past.

I use WorkManager across three apps for three different jobs — a nightly export in Subly, pantry data recompute in Stocky, and CSV backup writes in Granyn — and the bugs I’ve shipped all trace back to skipping one of these three pieces.

Unique work: the guard against duplicate jobs

The most common WorkManager bug isn’t a crash, it’s a duplicate. A user taps “export” twice before the first tap’s spinner shows, and now two identical export jobs are queued. enqueue() alone does nothing to prevent this — it happily queues both.

enqueueUniqueWork is the fix, and the policy argument is the part people get wrong:

fun scheduleExport(context: Context) {
    val request = OneTimeWorkRequestBuilder<ExportWorker>()
        .setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.NOT_REQUIRED)
                .build(),
        )
        .build()

    WorkManager.getInstance(context).enqueueUniqueWork(
        "monthly_export",
        ExistingWorkPolicy.KEEP,
        request,
    )
}

The three ExistingWorkPolicy values map to three different intents, and picking the wrong one is the actual bug:

  • KEEP — if work with this name is already pending or running, drop the new request. Right for “export” — a second tap shouldn’t restart or duplicate the first.
  • REPLACE — cancel the existing work and start fresh. Right when the new request carries updated input data that makes the old one stale — the user changed the export’s date range mid-flight.
  • APPEND_OR_REPLACE — chain onto the existing work if it hasn’t started, or start a new chain otherwise. Rare; mostly for sequential jobs that must run in the order they were requested.

enqueueUniquePeriodicWork takes the same policy argument for recurring jobs, and the same reasoning applies: a nightly recompute job should almost always be KEEP or UPDATE, not REPLACE — replacing resets the periodic schedule’s anchor time, which quietly shifts when the job runs.

Chaining: sequencing without a hand-rolled callback

A backup flow is rarely one step — serialize data, compress it, write it to disk, verify the write. Chaining WorkRequests expresses this as data, not as nested callbacks:

val serialize = OneTimeWorkRequestBuilder<SerializeWorker>().build()
val compress = OneTimeWorkRequestBuilder<CompressWorker>().build()
val write = OneTimeWorkRequestBuilder<WriteToDiskWorker>().build()
val verify = OneTimeWorkRequestBuilder<VerifyBackupWorker>().build()

WorkManager.getInstance(context)
    .beginUniqueWork("full_backup", ExistingWorkPolicy.REPLACE, serialize)
    .then(compress)
    .then(write)
    .then(verify)
    .enqueue()

Each worker’s output becomes the next worker’s input automatically, through Data:

class SerializeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val path = serializeToTempFile()
        return Result.success(workDataOf("serialized_path" to path))
    }
}

class CompressWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val path = inputData.getString("serialized_path") ?: return Result.failure()
        val compressedPath = compress(path)
        return Result.success(workDataOf("compressed_path" to compressedPath))
    }
}

Data is deliberately small — it’s backed by a size-limited internal database, not a general-purpose payload channel. Pass file paths and IDs, not the file contents themselves. If a step in the chain fails, Result.failure() stops everything downstream from running; the chain doesn’t silently continue with missing input.

Retry and backoff: don’t let the default surprise you

Result.retry() tells WorkManager to try the worker again, and by default it waits 30 seconds, then backs off exponentially, capped at 5 hours. For a job with real network dependency, the default is usually fine. For a job that’s retrying because of a local, transient failure — a file lock, a momentary low-storage condition — 30 seconds is often too long for something the user is actively waiting on.

Set the policy explicitly rather than trusting the default silently:

OneTimeWorkRequestBuilder<WriteToDiskWorker>()
    .setBackoffCriteria(
        BackoffPolicy.LINEAR,
        10, TimeUnit.SECONDS,
    )
    .build()

And distinguish Result.retry() from Result.failure() deliberately in the worker itself — this is the part people get backwards:

override suspend fun doWork(): Result {
    return try {
        writeBackupFile()
        Result.success()
    } catch (e: IOException) {
        if (runAttemptCount < 3) Result.retry() else Result.failure()
    } catch (e: SecurityException) {
        // Permission won't fix itself by retrying.
        Result.failure()
    }
}

A permission error retried five times over five hours isn’t resilience, it’s a job silently failing five times before the user ever finds out. Only retry the failure modes that time can actually fix.

Expedited work: for the job the user is watching

Not every background job can wait for WorkManager’s scheduler to decide when to run. If a user taps “export now” and expects it to start within seconds — not whenever the system’s Doze heuristics allow — mark it expedited:

OneTimeWorkRequestBuilder<ExportWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()

Expedited work runs immediately (subject to the app’s daily execution quota) and gets a short grace period even if the app moves to the background mid-run. The OutOfQuotaPolicy argument decides what happens once the app has spent its quota for the day: RUN_AS_NON_EXPEDITED_WORK_REQUEST falls back to normal scheduling instead of throwing. Reach for this only for genuinely user-initiated, user-visible work — using it for routine background sync defeats the battery-friendly scheduling that’s WorkManager’s whole point.

Testing: the step almost everyone skips

WorkManager ships a test artifact specifically so a worker doesn’t need a running app to verify. Almost nobody uses it, and it’s the difference between finding an input-data bug in code review and finding it from a user’s bug report.

@RunWith(AndroidJUnit4::class)
class ExportWorkerTest {

    @Before
    fun setup() {
        val config = Configuration.Builder()
            .setExecutor(SynchronousExecutor())
            .build()
        WorkManagerTestInitHelper.initializeTestWorkManager(
            ApplicationProvider.getApplicationContext(),
            config,
        )
    }

    @Test
    fun exportWorker_writesFile_onSuccess() {
        val request = OneTimeWorkRequestBuilder<ExportWorker>().build()
        val workManager = WorkManager.getInstance(
            ApplicationProvider.getApplicationContext(),
        )

        workManager.enqueue(request).result.get()
        val info = workManager.getWorkInfoById(request.id).get()

        assertThat(info.state).isEqualTo(WorkInfo.State.SUCCEEDED)
    }
}

SynchronousExecutor makes the job run inline instead of on a background thread, so the test doesn’t need a sleep or a latch to wait for completion. This catches the two bugs that chaining and retry logic hide especially well in manual testing: a worker that silently swallows an exception and returns success() anyway, and a chain step that reads the wrong key out of inputData.

What actually mattered

Across the three jobs I run on WorkManager, the pattern that held up wasn’t clever — it was consistent: name every unique job explicitly and pick its ExistingWorkPolicy on purpose, chain multi-step jobs instead of nesting callbacks, retry only failures that time can fix, and write the WorkManagerTestInitHelper test before trusting a chain in production. None of these are exotic APIs. They’re the parts of WorkManager that are easy to leave at their defaults, and the defaults are wrong often enough to matter.