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#
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:
} 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#
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#
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:
REPLACEcancels the pending work and enqueues the new one. Right when the new request has fresher input.APPENDruns the new work after the existing work finishes. Right for a queue where every item matters.KEEPis right when the requests are interchangeable, which for "upload the current file" they usually are.
4. Observing work without leaking the subscription#
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.
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:
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.


