Let's Make WorkManager Work: Constraints, Unique Work, and a Retry That Stops

Forty lines that run a job surviving the app being killed. Three decisions inside are worth stealing, and one line is a bug you can ship without noticing — because CancellationException is an Exception.

Let's Make WorkManager Work: Constraints, Unique Work, and a Retry That Stops

Here is a WorkManager setup that works: a Compose screen that enqueues an upload and watches its state, and a CoroutineWorker that does the job. About forty lines total.

Three decisions inside are worth stealing. One line is a bug that most people ship without noticing, and it's the interesting one, so let's get to it first.

1. Your catch block is swallowing cancellation#

Kotlin
return try {
    uploadFile(fileName)
    Result.success(workDataOf(KEY_RESULT to "Uploaded $fileName"))
} catch (e: Exception) {
    if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure()
}

That reads as careful error handling. It contains a bug.

CancellationException is an Exception. It extends IllegalStateException, which extends RuntimeException, which extends Exception. So catch (e: Exception) catches it.

When something cancels this work — the user, a constraint disappearing, WorkManager.cancelUniqueWork — the coroutine machinery throws CancellationException from the first suspension point it reaches. Here that's the delay inside uploadFile. The catch treats it as an upload failure, and returns Result.retry().

You cancelled the work. It comes back.

The fix is one clause, and it has to come first:

Kotlin
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure()
}

Kotlin matches catch clauses top to bottom, so the specific one has to be above the general one — the other order compiles fine and does nothing.

This isn't a WorkManager quirk. It's true of every try/catch around a suspend call. Cancellation in Kotlin coroutines works by throwing, which means broad exception handling silently opts you out of it. CoroutineWorker just makes it easy to hit, because retrying is right there as a return value.

2. Constraints are scheduling, not guards#

Kotlin
val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .build()

It's tempting to read this as "this worker only runs with a network". What it actually says is: don't start this worker until there's a network.

Nothing holds the network still afterwards. It can drop one millisecond into doWork, mid-upload, and WorkManager won't intervene — your code fails, and your retry handles it.

The distinction matters because it tells you where the error handling belongs. Constraints decide when work starts. Everything about the network being unreliable while work runs is your problem, and that's the whole reason Result.retry() exists.

3. What KEEP actually keeps#

Kotlin
workManager.enqueueUniqueWork(
    UNIQUE_WORK_NAME,
    ExistingWorkPolicy.KEEP,
    request,
)

Unique work is the good part here. Without it, an impatient user tapping the button five times gets five uploads. enqueueUniqueWork with a stable name means one.

But KEEP is narrower than it sounds. It ignores the incoming request only while existing work with that name is unfinished — pending or running. Once that work succeeds or fails, the name is free, and the next enqueue goes through normally.

So KEEP deduplicates work that's in flight. It doesn't mean "only ever once". If you need genuinely-once semantics, the flag lives in your own storage, not in WorkManager.

The alternatives, briefly:

  • REPLACE cancels the pending work and enqueues the new one. Right when the new request has fresher input.
  • APPEND runs the new work after the existing work finishes. Right for a queue where every item matters.
  • KEEP is right when the requests are interchangeable, which for "upload the current file" they usually are.

4. Observing work without leaking the subscription#

Kotlin
val workInfos by workManager
    .getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME)
    .collectAsStateWithLifecycle(initialValue = emptyList())

This is the modern shape and it's worth being deliberate about both halves.

getWorkInfosForUniqueWorkFlow gives a Flow rather than the older LiveData. collectAsStateWithLifecycle collects it only while the lifecycle is at least STARTED, so a backgrounded screen isn't recomposing on updates nobody can see.

Using plain collectAsState here would keep collecting while the app sits in the background. Not fatal for one flow — genuinely wasteful once a screen has several.

What I'd change#

The status line can lie.

Kotlin
val status = workInfos.firstOrNull()?.state?.name ?: "IDLE"

Finished work doesn't vanish immediately — WorkManager keeps it until it prunes. So the list can hold a completed run and a newly enqueued one, and firstOrNull picks by list position, which isn't ordered by recency. You can end up showing SUCCEEDED while a fresh upload sits in ENQUEUED.

Prefer the unfinished one when there is one:

Kotlin
val status = workInfos
    .firstOrNull { !it.state.isFinished }
    ?.state?.name
    ?: workInfos.firstOrNull()?.state?.name
    ?: "IDLE"

MAX_RETRIES = 3 gives four runs. runAttemptCount is 0 on the first execution, so runAttemptCount < 3 is true for attempts 0, 1 and 2 — three retries after the original, four executions total. Name it MAX_RETRIES and you'll read it as three. An off-by-one that only shows up on a bad day is a bad off-by-one.

Backoff is never configured, so the defaults decide when retries happen — exponential, starting around thirty seconds. Fine for an upload. Wrong if you wanted the user to see a fast second attempt, and worth setting explicitly so the choice is visible.

CoroutineWorker doesn't run on Dispatchers.IO. doWork runs on Dispatchers.Default, which is sized for CPU work. Real file and network calls belong on IO — either withContext(Dispatchers.IO) around the blocking part, or override coroutineContext on the worker.

Output Data is small and temporary. There's a size cap in the low kilobytes, and the value only survives while the WorkInfo does. It's a convenience for a UI watching right now, not a place to put results.

There's no way to cancel. The screen can start work but not stop it, which makes the cancellation bug above hard to notice. A cancel button and cancelUniqueWork is a few lines, and it's how you'd find that problem.

Wrapping up#

Four ideas, only one of them about WorkManager:

Catching Exception around suspend code is a decision, not a default. Cancellation travels as an exception, so a broad catch opts you out of it.

Know whether an API schedules or guarantees. Constraints schedule. The difference tells you where error handling lives.

Read policy names suspiciously. KEEP keeps less than it sounds like.

Count your retries by executions, not by the constant's name.

Until then, let's make this work.

Keep reading

More breakdowns

Android Architecture

Let's Make SOLID Work: The Principles and Patterns Behind Android Architecture

AsyncTask died, Fragments were rewritten twice, XML gave way to Compose. What survived every migration was the shape of the code underneath. Here are the five principles and nine patterns that decide it.

· 6 min read
Android Architecture

Let's Make ViewModels Work: A Look Inside ViewModel.kt

Let's break down the actual source of ViewModel.kt — why it's abstract, how it survives rotation, and how viewModelScope quietly saves you from leaking network calls.

· 3 min read
Kotlin & Core Concepts

Let's Make a Callback Suspend: suspendCancellableCoroutine, Line by Line

Wrapping a callback API in a suspend function is four lines. The fifth line — invokeOnCancellation — is the one that decides whether cancelling actually stops anything, and it's the one people leave out.

· 4 min read