RizTech Academy logo
RizTech Academy
Work That Outlives the ScreenLesson 2 of 435 min

WorkManager for deferrable work

The last lesson ended with a table, and Mandi Bhav's daily refresh landed in its first row: it can wait an hour, and it must not be lost. WorkManager is the Jetpack library for exactly that. You describe the work and what it needs, WorkManager saves the description in its own small database, and hands it to Android's JobScheduler, which runs it when Doze, the standby bucket and the network allow, even if the app was swiped away, the process killed or the phone restarted in between. This lesson writes the worker, schedules it for seven every evening, and then breaks it in four ways, each of which compiles and fails quietly on a farmer's phone.

The libraries

AndroidX libraries are published to Google's Maven repository, not Maven Central. On 8 November 2026 its metadata gave:

Library Coordinates Version
WorkManager androidx.work:work-runtime-ktx 2.12.0
WorkManager test helpers androidx.work:work-testing 2.12.0
Hilt for WorkManager androidx.hilt:hilt-work 1.4.0
Its annotation processor androidx.hilt:hilt-compiler 1.4.0, the same family as module 7's hilt-lifecycle-viewmodel-compose

All four built first time with AGP 9.5.1 and Kotlin 2.4.21. In gradle/libs.versions.toml:

[versions]
work = "2.12.0"

[libraries]
androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt" }
androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" }
androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" }

and in app/build.gradle.kts:

implementation(libs.androidx.work.runtime)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
testImplementation(libs.androidx.work.testing)

One refresh, two callers

Module 9 was firm that the worker must not grow a second download path, and it does not need one. What it does need is the step that comes after a refresh: checking the price alerts, which lesson 4 builds. So the refresh and the check go into one class that both the Prices screen and the worker call, domain/RefreshPricesUseCase.kt:

@Singleton
class RefreshPricesUseCase @Inject constructor(
    private val prices: PriceRepository,
    private val alerts: AlertRepository,
    private val notifier: AlertNotifier,
) {
    suspend operator fun invoke(): Int {
        val changed = prices.refresh()
        checkAlerts()
        return changed
    }
    // checkAlerts() is lesson 4's.
}

prices.refresh() is module 9's single-flight function, unchanged: the same retries, the same transaction, and if the screen and the worker refresh at the same moment, one download. PricesViewModel now calls refreshPrices() where it called repository.refresh().

The worker

work/PriceRefreshWorker.kt:

@HiltWorker
class PriceRefreshWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val refreshPrices: RefreshPricesUseCase,
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        Log.i(TAG, "Daily refresh, attempt ${runAttemptCount + 1}")
        return try {
            val changed = refreshPrices()
            Log.i(TAG, "Daily refresh saved the prices: $changed changed")
            Result.success(workDataOf(KEY_CHANGED to changed))
        } catch (e: RefreshException) {
            val again = e.failure.isWorthRetryingLater && runAttemptCount < MAX_ATTEMPTS - 1
            Log.i(TAG, "Daily refresh failed: ${e.failure}; ${if (again) "retrying" else "giving up until tomorrow"}")
            if (again) Result.retry() else Result.failure()
        }
    }
}

with MAX_ATTEMPTS = 4. A CoroutineWorker's doWork is a suspend function, and if WorkManager has to stop the work (the network goes, or the system wants its time back), the coroutine is cancelled and the work runs again later. Module 9's rule holds here too: catch RefreshException and nothing else, so a cancellation is never mistaken for a failure.

What to return is a policy decision, and it uses a second rule beside module 9's isWorthRetryingSoon:

val RefreshFailure.isWorthRetryingLater: Boolean
    get() = when (this) {
        RefreshFailure.NoConnection, RefreshFailure.Unreachable, RefreshFailure.Timeout -> true
        is RefreshFailure.Server -> code >= 500 || code == 408 || code == 429
        RefreshFailure.BadData -> false
    }
Return WorkManager does
Result.success() Done. Periodic work waits for the next period
Result.retry() Runs it again after the backoff delay
Result.failure() Gives up. Periodic work still runs next period

A timeout is not worth retrying in two seconds, while a farmer watches a spinner; it is worth retrying in ten minutes, when the tower may have stopped being busy. A 404 or a file the app cannot read will not change in ten minutes, so the worker does not wake the phone four times to find that out.

Giving WorkManager Hilt's factory

WorkManager builds workers itself, by reflection, through a constructor taking a Context and WorkerParameters. Mandi Bhav's worker needs a third thing, the use case, so Hilt has to build it. @HiltWorker and @AssistedInject generate the factory; the Application hands it to WorkManager:

@HiltAndroidApp
class MandiBhavApplication : Application(), SingletonImageLoader.Factory, Configuration.Provider {

    @Inject
    lateinit var workerFactory: HiltWorkerFactory

    override val workManagerConfiguration: Configuration
        get() = Configuration.Builder()
            .setWorkerFactory(workerFactory)
            .setMinimumLoggingLevel(if (BuildConfig.DEBUG) Log.DEBUG else Log.ERROR)
            .build()
}

workManagerConfiguration is a property in current WorkManager; some pages still show the older getWorkManagerConfiguration() function. There is one more step, and it is the one people miss. By default WorkManager initialises itself at app start-up, before your Application has injected anything, with the default factory. You turn that off in AndroidManifest.xml, so that it waits and asks the Application:

<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false"
    tools:node="merge">
    <meta-data
        android:name="androidx.work.WorkManagerInitializer"
        android:value="androidx.startup"
        tools:node="remove" />
</provider>

with xmlns:tools="http://schemas.android.com/tools" on the <manifest> tag.

Trap one: leave the <provider> out. A scratch build without it compiled, installed, opened, and showed today's prices. When the worker ran:

E/WM-WorkerFactory: Could not instantiate com.riztechacademy.mandibhav.work.PriceRefreshWorker
E/WM-WorkerFactory: java.lang.NoSuchMethodException: com.riztechacademy.mandibhav.work.PriceRefreshWorker.<init> [class android.content.Context, class androidx.work.WorkerParameters]
I/WM-WorkerWrapper: Worker result FAILURE for Work [ id=4f61bed5-…, class=com.riztechacademy.mandibhav.work.PriceRefreshWorker, … ]

No crash, no dialog, no retry. On a real phone that is a daily refresh that has never once run, and nobody finds out.

Scheduling it

work/DailyRefresh.kt:

object DailyRefresh {
    const val WORK_NAME = "daily-price-refresh"
    val EVENING: LocalTime = LocalTime.of(19, 0)

    private val needsNetwork = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build()

    fun request(now: ZonedDateTime): PeriodicWorkRequest =
        PeriodicWorkRequestBuilder<PriceRefreshWorker>(1, TimeUnit.DAYS)
            .setConstraints(needsNetwork)
            .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
            .setInitialDelay(delayUntilNext(EVENING, now))
            .build()

    fun schedule(workManager: WorkManager, now: ZonedDateTime) {
        workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request(now))
    }
}

and MandiBhavApplication.onCreate calls DailyRefresh.schedule(WorkManager.getInstance(this), ZonedDateTime.now(clock)) on every start. Enqueueing hands the write to WorkManager's own background thread; with module 9's StrictMode policy on, a cold start logged no violation.

Choice Why
Once a day The prices change once a day. Every extra run is battery and data spent on nothing
From the next 7 pm After the markets close. delayUntilNext works out the delay; periodic work has no "at seven" setting, and the time will drift with Doze and the network
NetworkType.CONNECTED Any working network. A refresh is 3.5 KB; waiting for Wi-Fi would mean never, for a farmer with only mobile data
Backoff 10 minutes, exponential 10, then 20, then 40 minutes. WorkManager's default is 30 seconds; its minimum is 10 seconds
Unique, KEEP One schedule for the life of the install, however often the app is opened

What WorkManager handed JobScheduler is visible with adb shell dumpsys jobscheduler com.riztechacademy.mandibhav, scheduled at 23:53:

Network type: NetworkRequest [ NONE id=0, [ Capabilities: INTERNET&TRUSTED&VALIDATED&NOT_VCN_MANAGED&NOT_BANDWIDTH_CONSTRAINED … ] ]
Minimum latency: +19h7m1s827ms
Backoff: policy=1 initial=+10m0s0ms

Nineteen hours seven minutes is 19:00 the next day. The network it waits for is VALIDATED, module 9's "Android has checked it reaches the internet". And the job is not a repeating job at all: WorkManager schedules one run at a time and books the next when it finishes.

Trap two: enqueue instead of enqueueUniquePeriodicWork. A scratch build with workManager.enqueue(request(now)), opened three times from cold:

JOB androidx.work.systemjobscheduler:u0a239/0: … Minimum latency: +18h53m18s513ms
JOB androidx.work.systemjobscheduler:u0a239/1: … Minimum latency: +18h53m18s542ms
JOB androidx.work.systemjobscheduler:u0a239/2: … Minimum latency: +18h53m18s506ms

Three schedules, all for seven o'clock, and one more for every launch. After a month of daily use, thirty refreshes every evening. REPLACE instead of KEEP keeps the count at one, but cancels the waiting work, and any run in progress, every time the app opens, and books it afresh: a farmer who opens the app at a quarter past seven, while a Doze-delayed refresh is still waiting, moves it to tomorrow.

Surviving the swipe, measured

The scenario module 9 could not handle. Debug builds have a small receiver that runs the same worker once on demand (trap three explains why one is needed). With the test server told to wait 20 seconds, the run started, and three seconds into the download the app was swiped out of Recents:

00:01:01.739 I/PriceRefreshWorker: Daily refresh, attempt 1
00:01:01.743 I/OkHttp            : --> GET http://10.0.2.2:8777/api/v1/prices.json
00:01:04.848 I/ActivityManager   : Killing 31287:com.riztechacademy.mandibhav/u0a229 (adj 250): remove task
00:01:05.307 D/WM-Processor      : WorkerWrapper interrupted for 91dbaa6d-… (…PriceRefreshWorker), reason: 1
00:01:05.319 D/WM-SystemJobScheduler: Scheduling work ID 91dbaa6d-… (…PriceRefreshWorker), Job ID 3

The process was killed mid-download, and the bytes in flight were lost; that much no library can prevent. But the work itself was not. WorkManager had recorded it as running, noticed it had been interrupted, and booked it again after the backoff, earliest=+9m55s. Ten and a half minutes later, with nobody touching the phone:

00:11:28.721 I/PriceRefreshWorker: Daily refresh, attempt 2
00:11:28.754 I/OkHttp            : <-- 200 OK http://10.0.2.2:8777/api/v1/prices.json (12ms, 3534-byte body)
00:11:28.812 I/PriceRefreshWorker: Daily refresh saved the prices: 9 changed

That is the whole difference between this lesson and the last module: an application scope remembers nothing once the process is gone; WorkManager writes the work down before it starts it.

Retries stack the same way. With the test server stopped, one run made module 9's three quick attempts, at 0, 1 and 2 seconds, then returned Result.retry(), and the job went back to JobScheduler with earliest=+9m55s. Quick retries for a blip, slow ones for an outage.

Trap three: forcing a periodic run

The obvious way to test a job is to make JobScheduler run it now. Two surprises, both from the emulator:

adb shell cmd jobscheduler run -f com.riztechacademy.mandibhav 0
Could not find job 0 in package com.riztechacademy.mandibhav / user 0

WorkManager files its jobs under a namespace, androidx.work.systemjobscheduler, so the command needs -n androidx.work.systemjobscheduler. With it, JobScheduler ran the job, and WorkManager refused:

D/WM-WorkerWrapper: Delaying execution for com.riztechacademy.mandibhav.work.PriceRefreshWorker because it is being executed before schedule.

Periodic work runs at its time and no earlier, whoever asks. So the debug source set has work/RunRefreshReceiver.kt, which enqueues a one-time request for the same worker with the same constraint, and is declared in src/debug/AndroidManifest.xml only, protected by the DUMP permission that adb's shell holds and other apps do not:

adb shell am broadcast -n com.riztechacademy.mandibhav/.work.RunRefreshReceiver
adb shell am broadcast -n com.riztechacademy.mandibhav/.work.RunRefreshReceiver --el delay 60

The second form makes the work due in a minute, long enough to close the app or force Doze, which is how the last lesson's measurements were taken.

The static file only changes when the repository does

The daily refresh downloads api/v1/prices.json from GitHub. That file changes only when someone pushes a new one, so on most evenings the worker will log "Daily refresh saved the prices: 0 changed". That is correct behaviour, not a broken worker: it asked, and nothing was new. To watch a new day arrive, serve the API from your computer with the local.properties override from module 9, change prices.json, and run the receiver.

Testing it

work-testing builds a worker directly with your own factory, with no JobScheduler involved:

private fun worker(runAttemptCount: Int = 0) =
    TestListenableWorkerBuilder<PriceRefreshWorker>(context)
        .setWorkerFactory(factory)
        .setRunAttemptCount(runAttemptCount)
        .build()

@Test
fun theFourthTryGivesUpUntilTomorrow() = runTest {
    prices.failure = RefreshFailure.Server(503)

    assertEquals(ListenableWorker.Result.retry(), worker(runAttemptCount = 2).doWork())
    assertEquals(ListenableWorker.Result.failure(), worker(runAttemptCount = 3).doWork())
}

where factory builds PriceRefreshWorker(appContext, params, RefreshPricesUseCase(prices, alerts, notifier)) over the module's fakes. For the schedule, WorkManagerTestInitHelper.initializeTestWorkManager gives a real WorkManager on a synchronous executor, and its TestDriver meets constraints and delays by hand. PriceRefreshWorkerTest has ten tests: the results above, the schedule's interval, constraint and four-hour delay from 3 pm, KEEP leaving the first request in place, and the worker running only once both the delay and the network are met. All 154 unit tests pass.

Check your work

Why the worker calls RefreshPricesUseCase, not its own download. So the same single-flight refresh, retries and alert check run whichever caller starts them, and two refreshes at once share one download.

What happens without the manifest <provider> change. WorkManager starts with its default factory, cannot construct the worker, and logs Result FAILURE with no crash.

Why KEEP. enqueue added a schedule per launch (three after three starts); REPLACE would restart the 24 hours on every launch.

What the swipe did to the worker. Killed the process and lost the download; WorkManager rescheduled the work after the 10-minute backoff, and it saved the prices at 00:11:28 with nobody touching the phone.

Why cmd jobscheduler run did not run the refresh. It needs -n androidx.work.systemjobscheduler, and even then WorkManager will not run periodic work before its time.

Practice 4. With the server stopped, logcat showed three HTTP FAILED: java.net.ConnectException lines one and two seconds apart, module 9's quick retries, then "Daily refresh failed: Unreachable; retrying" and Worker result RETRY; the job came back with earliest=+9m55s.

Practice

  1. Add the libraries, RefreshPricesUseCase, the worker, the Application changes, the manifest change and DailyRefresh. Build, install, and find the job in adb shell dumpsys jobscheduler com.riztechacademy.mandibhav. Check its minimum latency against the time until seven.
  2. Add the debug receiver, serve a changed prices.json from your computer, run the receiver, and read PriceRefreshWorker in logcat.
  3. Repeat the swipe test: a server that waits 20 seconds, the receiver, then swipe the app away from Recents within a few seconds. Find remove task in logcat, then wait for attempt 2.
  4. Stop your server and run the receiver. Count the attempts in the OkHttp log before the worker gives its result, and read the job's earliest run time in dumpsys jobscheduler afterwards.
  5. Break it on purpose, one at a time, in a scratch copy installed under a different applicationIdSuffix: remove the manifest <provider>; replace enqueueUniquePeriodicWork with enqueue and cold-start three times. Read logcat and dumpsys jobscheduler for each.
  6. Harder: a trader asks for a refresh every two hours during market hours, 10 am to 4 pm, and none at night. Design it with WorkManager: one periodic request or several one-time requests, how the schedule moves to the next day, and what you do when Doze delays a run past four. Write it as code, and a test with TestDriver.

Official documentation

Next: foreground services, and why Mandi Bhav does not need one.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship