Background 9 min read

WorkManager 2.12: Your Background Jobs Finally Leave a Paper Trail

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.

New
work-analytics artifact with WorkMetricsInfo
Exp.
Scheduling event listener API
Changed
Default dispatcher is now Dispatchers.Default

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:

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:

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:

  1. Pull it into a debug build and add the work-analytics artifact. Wire getFinishedWorkMetricsInfoFlow into 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.
  2. Audit your CoroutineWorker subclasses for Dispatchers.Default compatibility. Any blocking call without withContext(Dispatchers.IO) is now a shared-pool concern. grep for blocking function calls inside doWork() implementations and fix them.
  3. 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.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment