Background work has always been the most opaque part of Android development. You enqueue a worker, it disappears into the OS scheduling black box, and when something goes wrong — a failed sync that users never see, a periodic job that silently stops running, a worker that keeps retrying without anyone noticing — you're left with nothing but WorkInfo.State.FAILED and a prayer. Crash reporters can't catch it. Logs rarely survive long enough. Production users just see stale data and move on.
WorkManager 2.12.0-alpha01, released July 15, 2026, starts to fix this. The headline is a new work-analytics artifact that ships WorkMetricsInfo — a structured record of exactly what happened to any piece of work: how long it ran, how many times it was stopped, why it stopped, how many explicit retries were triggered, and timestamps for the full lifecycle from enqueue to finish. It's the paper trail that background debugging has been missing.
The work-analytics Artifact
The metrics APIs live in a separate artifact so existing apps don't carry the weight if they don't need it. Add it alongside your existing WorkManager dependency:
// build.gradle.kts
dependencies {
implementation("androidx.work:work-runtime-ktx:2.12.0-alpha01")
implementation("androidx.work:work-analytics:2.12.0-alpha01")
}
The separation is deliberate. WorkManager's core runtime is already a substantial dependency that many apps pulled in before Hilt existed and before coroutines were idiomatic. Keeping analytics opt-in means apps that only need basic enqueue/observe behavior stay lean.
WorkMetricsInfo: What You Can Now Observe
The central new type is WorkMetricsInfo. It tracks everything that happened to a unit of work after it was enqueued:
// Retrieve metrics for a specific work request
val metricsRepo: WorkMetricsInfoRepository = WorkManager
.getInstance(context)
.workMetricsInfoRepository
val query = WorkMetricsQuery.Builder()
.addTag("sync-contacts")
.build()
metricsRepo.getWorkMetricsInfo(query).collect { metricsInfoList ->
metricsInfoList.forEach { info ->
Log.d("WorkMetrics", """
Worker: ${info.workerClassName}
Duration: ${info.workerDurationMillis}ms
Total runtime: ${info.totalRuntimeMillis}ms
Run attempts: ${info.runAttemptCount}
Explicit retries: ${info.explicitRetryCount}
Stop reasons: ${info.stopReasonCounts}
Enqueued at: ${info.enqueueTimestamp}
First started: ${info.firstStartTimestamp}
Finished at: ${info.finishTimestamp}
""".trimIndent())
}
}
Let me break down what each field actually tells you:
workerDurationMillis— wall-clock time spent insidedoWork(). This is the execution time, not the total lifecycle time. A worker that took 200ms to actually run but waited 45 minutes in the scheduler has aworkerDurationMillisof 200ms.totalRuntimeMillis— cumulative execution time across all run attempts. If a worker failed and retried three times, each running for 2 seconds before throwing,totalRuntimeMillisis 6 seconds.runAttemptCount— total number of timesdoWork()was called, including the initial attempt and all retries.explicitRetryCount— how many times the worker returnedResult.retry(). Separating this fromrunAttemptCountlets you distinguish "retried because the network was unavailable" from "retried because the OS killed it mid-run."stopReasonCounts— aMap<Int, Int>of WorkInfo stop reason codes to occurrence counts. Finally you can see whether your worker is being stopped bySTOP_REASON_CONSTRAINT_NETWORK,STOP_REASON_TIMEOUT, or the dreadedSTOP_REASON_DEVICE_STATE.enqueueTimestamp,firstStartTimestamp,finishTimestamp— the full lifecycle in milliseconds. The delta between enqueue and firstStart is your scheduling latency — how long the OS made you wait.
For Nodat, where background sync of note collections has to happen reliably without draining battery, this is exactly the data I've wanted to make sense of why certain users occasionally see stale content. The existing WorkInfo tells you the current state; WorkMetricsInfo tells you the history.
WorkMetricsQuery: Filtering What You Get Back
You don't have to collect metrics for every worker in the app. WorkMetricsQuery lets you filter by tag, worker class, or unique work name:
val query = WorkMetricsQuery.Builder()
.addTag("upload")
.setWorkerClass(ImageUploadWorker::class.java)
.build()
// One-shot fetch for finished work
val finished = metricsRepo.getWorkMetricsInfo(query).first()
// Flow for observing as work completes
metricsRepo.getFinishedWorkMetricsInfoFlow(query)
.collect { info -> reportToAnalytics(info) }
The getFinishedWorkMetricsInfoFlow API is the one I'd wire into a monitoring pipeline. In a production app, you could feed finished metrics directly to your event-logging layer — Firebase Analytics, a custom endpoint, whatever you already use for behavioral analytics. That gives you background task success rates, retry distributions, and scheduling latency as first-class product metrics rather than a dev-only debugging tool.
Retention Configuration
Metrics don't accumulate forever. The retention window defaults to 7 days and is configurable per-WorkMetricsInfoRepository at initialization:
// API 26+
val repo = WorkMetricsInfoRepository(
workManager = WorkManager.getInstance(context),
retentionDuration = Duration.ofDays(14)
)
// API 23+ fallback
val repo = WorkMetricsInfoRepository(
workManager = WorkManager.getInstance(context),
retentionTime = 14,
retentionTimeUnit = TimeUnit.DAYS
)
This uses the same pruning mechanism as WorkManager's internal database, so old records don't pile up. For most apps, the 7-day default is fine — crash/issue investigations rarely need to look further back than that.
Scheduling Event Listeners (Experimental)
Separate from the metrics API is a new experimental listener for WorkManager's scheduling decisions in real time:
@OptIn(ExperimentalWorkManagerApi::class)
WorkManager.getInstance(context)
.addSchedulingEventListener { event ->
when (event) {
is SchedulingEvent.WorkEnqueued -> { /* ... */ }
is SchedulingEvent.WorkStarted -> { /* ... */ }
is SchedulingEvent.WorkStopped -> { /* ... */ }
is SchedulingEvent.WorkFinished -> { /* ... */ }
}
}
This is lower-level than WorkMetricsInfo and more useful during development than in production. The main use case is understanding the OS scheduling decisions as they happen — not after the fact. If you're debugging why a periodic job isn't running on a device with Doze enabled, or why a worker with network constraints starts immediately on some devices and waits 20 minutes on others, this gives you the live signal you'd otherwise have to reconstruct from ADB logs.
Important: the scheduling event listener is marked @ExperimentalWorkManagerApi. The API surface is likely to shift. Use it for debugging and tooling, not as a production monitoring primitive — that's what getFinishedWorkMetricsInfoFlow is for.
Worker Class Name Now in WorkInfo
A smaller but welcome addition: WorkInfo now exposes the worker class name via WorkInfo.workerClassName. Before this release, if you observed a WorkInfo object from a WorkQuery result and the work was tagged with a generic tag like "sync", you couldn't tell which specific ListenableWorker subclass was behind it without keeping a separate mapping yourself.
WorkManager.getInstance(context)
.getWorkInfosByTagLiveData("sync")
.observe(this) { workInfoList ->
workInfoList.forEach { info ->
Log.d("WM", "Class: ${info.workerClassName}, State: ${info.state}")
}
}
Useful in large apps where the same tag is applied across multiple worker types and you want to log or route differently per class.
The Dispatchers.Default Change — and Why It Matters
This is the one that could cause real behavioral changes if you're not expecting it. WorkManager has historically used its own internal thread pool for coroutine dispatch inside CoroutineWorker. As of 2.12.0-alpha01, the default has changed to Dispatchers.Default.
What changes: if your CoroutineWorker.doWork() does CPU-bound work directly on the incoming dispatcher (without explicitly switching to Dispatchers.IO for blocking calls), the thread pool it runs on changes. For pure coroutine code that already uses structured concurrency correctly, this is invisible. For code that blocks the calling thread inside doWork(), you may now be blocking a thread on the shared Default pool, which is shared with the rest of your app's coroutines.
The right move is what the Kotlin coroutines guide has always recommended: use withContext(Dispatchers.IO) for any blocking I/O inside a worker, and let the default dispatcher handle computation. If you were relying on WorkManager's internal pool as a free "extra threads for blocking work" mechanism, this is the push to fix that properly.
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
// Computation on the Default dispatcher — fine as-is
val processed = processLocalData()
// Blocking I/O must use IO dispatcher explicitly
val result = withContext(Dispatchers.IO) {
database.sync(processed)
}
return if (result.isSuccess) Result.success() else Result.retry()
}
}
If you want to keep the old behavior while evaluating the migration, you can supply a custom dispatcher via WorkManager.Configuration. But I'd treat that as a short-term hold, not a permanent fix.
Bug Fixes Worth Knowing About
A few of the bug fixes in this release are worth calling out because they affect real production behavior, not edge cases:
- Periodic work failing to reschedule after uncaught exceptions — if your
PeriodicWorkRequestthrew an unexpected exception that wasn't caught insidedoWork(), it wasn't being rescheduled. Fixed. - Retried work failing to schedule with the OS — workers returning
Result.retry()could silently fail to register with the system scheduler under certain conditions. Fixed. - Background network requests failing on Android 15 — a regression in how network constraint tracking worked on API 35. Fixed.
- Process crashes from missing ACCESS_NETWORK_STATE permission — the network constraint callback registration was throwing when the permission wasn't declared. Now handled gracefully with a security exception catch.
- ConcurrentModificationException in the execution listener dispatch — race condition under concurrent task completion. Fixed.
The periodic rescheduling fix is the one I'd prioritize testing. If you ship any periodic workers — daily sync, cache refresh, analytics flush — set up a Macrobenchmark or manual test that throws inside doWork() on purpose and confirms the next period fires on schedule.
What to Actually Do Today
This is an alpha, so the standard rules apply: don't ship it to production as the primary version, but do evaluate it on a branch. Specifically:
- Pull it into a debug build and add the
work-analyticsartifact. WiregetFinishedWorkMetricsInfoFlowinto your existing logging pipeline and watch what comes back. The stop reason counts alone will likely teach you something about your workers that you didn't know before. - Audit your CoroutineWorker subclasses for
Dispatchers.Defaultcompatibility. Any blocking call withoutwithContext(Dispatchers.IO)is now a shared-pool concern.grepfor blocking function calls insidedoWork()implementations and fix them. - Test your periodic workers under error conditions. The rescheduling fix is exactly the kind of thing that was always broken but never reliably caught in CI. A worker that throws on the first attempt and then never fires again is hard to notice until a user complains about stale data weeks later.
Production timeline estimate: WorkManager's alpha-to-stable cadence has historically run several months. The metrics API surface is new enough that it'll likely see at least one beta cycle with API changes before stabilizing. I'd expect a stable 2.12.0 around Q1 2027, but the analytics artifact is stable enough to evaluate and build dashboards around today.
Background work observability has been a gap in the Android developer toolchain for a long time. The existing flow-based WorkInfo observation is fine for UI state, but it tells you almost nothing useful about why a job failed or how long it waited before the OS got around to running it. WorkMetricsInfo is the right abstraction for that — structured, filterable, retention-bounded, and composable with whatever analytics pipeline you already run. It's early and the API will change, but the direction is exactly right.
No comments yet. Be the first to leave one!