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
- Add the libraries,
RefreshPricesUseCase, the worker, the Application changes, the manifest change andDailyRefresh. Build, install, and find the job inadb shell dumpsys jobscheduler com.riztechacademy.mandibhav. Check its minimum latency against the time until seven. - Add the debug receiver, serve a changed
prices.jsonfrom your computer, run the receiver, and readPriceRefreshWorkerin logcat. - 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 taskin logcat, then wait for attempt 2. - Stop your server and run the receiver. Count the attempts in the
OkHttplog before the worker gives its result, and read the job's earliest run time indumpsys jobschedulerafterwards. - Break it on purpose, one at a time, in a scratch copy installed under a different
applicationIdSuffix: remove the manifest<provider>; replaceenqueueUniquePeriodicWorkwithenqueueand cold-start three times. Read logcat anddumpsys jobschedulerfor each. - 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
- Define work requests — Constraints, periodic and flex intervals, backoff and expedited work.
- Custom WorkManager configuration and initialisation — Removing the default initialiser and providing your own configuration.
- Hilt and Jetpack integrations —
@HiltWorkerandHiltWorkerFactory. - Integration tests with WorkManager —
WorkManagerTestInitHelper,TestDriverand test workers. - WorkManager releases — What changed in each version, and the current one.
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