Skip to main content

WorkManager Deep Dive

WorkManager is the Android-blessed way to schedule reliable background work. It replaces JobScheduler, AlarmManager, GcmNetworkManager, SyncAdapter, IntentService, and Executors for anything that must survive process death, reboot, or Doze. If a user-initiated action needs to complete "eventually" — sync an upload, process an image, retry a failed request — it goes through WorkManager.

When to use what

┌────────────────────────────────────────────────────────────────┐
│ Need to run WHILE the app is foreground, tied to UI? │
│ → viewModelScope.launch { } │
│ │
│ Need to run in the NEXT few seconds, even if app backgrounded?│
│ → Foreground Service │
│ │
│ Need to run "eventually" (next hour, next network)? │
│ → WorkManager │
│ │
│ Need to run AT a specific wall-clock time (9:00 AM)? │
│ → AlarmManager │
└────────────────────────────────────────────────────────────────┘

Setup

// libs.versions.toml
workmanager = "2.10.0"
hilt-work = "1.2.0"

work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workmanager" }
work-testing = { module = "androidx.work:work-testing", version.ref = "workmanager" }
hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hilt-work" }
hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-work" }
@HiltAndroidApp
class MyApp : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory

override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.setMinimumLoggingLevel(if (BuildConfig.DEBUG) Log.DEBUG else Log.INFO)
.build()
}
<!-- AndroidManifest — disable default init; we provide our own Configuration -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove"/>
</provider>

Worker types

CoroutineWorker — the default

@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted ctx: Context,
@Assisted params: WorkerParameters,
private val repository: ProductRepository,
private val crashReporter: CrashReporter
) : CoroutineWorker(ctx, params) {

override suspend fun doWork(): Result {
val since = inputData.getLong("since", 0L)

return try {
val synced = repository.syncSince(since)
Result.success(workDataOf("synced_count" to synced))
} catch (c: CancellationException) {
throw c
} catch (e: IOException) {
// Transient — retry with backoff
Result.retry()
} catch (e: Throwable) {
crashReporter.recordException(e)
Result.failure(workDataOf("error" to (e.message ?: "Unknown")))
}
}
}

CoroutineWorker with progress

class UploadWorker @AssistedInject constructor(
@Assisted ctx: Context,
@Assisted params: WorkerParameters
) : CoroutineWorker(ctx, params) {

override suspend fun doWork(): Result {
val fileUri = inputData.getString("uri")?.toUri() ?: return Result.failure()
val bytes = fileUri.readBytes(applicationContext)
var uploaded = 0L

bytes.chunked(CHUNK_SIZE).forEach { chunk ->
uploadChunk(chunk)
uploaded += chunk.size
setProgress(workDataOf(
"progress" to uploaded,
"total" to bytes.size.toLong()
))
}
return Result.success()
}
}

Long-running foreground workers

For work that can't be interrupted (large uploads, continuous sync), make the worker foreground so the OS doesn't kill it:

class VideoUploadWorker @AssistedInject constructor(
@Assisted ctx: Context,
@Assisted params: WorkerParameters
) : CoroutineWorker(ctx, params) {

override suspend fun doWork(): Result {
setForeground(createForegroundInfo("Uploading 0%"))
// ...
}

override suspend fun getForegroundInfo(): ForegroundInfo = createForegroundInfo("Uploading…")

private fun createForegroundInfo(text: String): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, "uploads")
.setContentTitle("Uploading video")
.setContentText(text)
.setSmallIcon(R.drawable.ic_upload)
.setProgress(100, 0, true)
.build()

return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(NOTIF_ID, notification)
}
}
}
<!-- AndroidManifest — declare the foreground service type -->
<application>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false"
tools:node="merge"/>
</application>

Android 14+ requires a foregroundServiceType for every foreground service. Pick the right one: dataSync, mediaPlayback, location, etc.


Enqueueing work

One-time work

val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf("since" to lastSync))
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("sync")
.build()

WorkManager.getInstance(context).enqueue(request)

Unique work — deduplicate

WorkManager.getInstance(context).enqueueUniqueWork(
uniqueWorkName = "product_sync",
existingWorkPolicy = ExistingWorkPolicy.KEEP, // or REPLACE / APPEND / APPEND_OR_REPLACE
request = syncRequest
)
  • KEEP — if a worker with this name is pending, ignore the new one
  • REPLACE — cancel the pending one, enqueue the new one
  • APPEND — chain after the pending one
  • APPEND_OR_REPLACE — chain, but replace if the pending one cancelled

Periodic work (minimum interval: 15 minutes)

val periodicRequest = PeriodicWorkRequestBuilder<CleanupWorker>(
repeatInterval = 6, repeatIntervalTimeUnit = TimeUnit.HOURS,
flexTimeInterval = 1, flexTimeIntervalUnit = TimeUnit.HOURS
).setConstraints(Constraints.Builder()
.setRequiresBatteryNotLow(true)
.setRequiresDeviceIdle(true)
.build()
).build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
uniqueWorkName = "daily_cleanup",
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
request = periodicRequest
)

Expedited work (high-priority, runs within 10 minutes)

val expedited = OneTimeWorkRequestBuilder<NotificationWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()

Expedited quota is per-app, per-day. If quota is exhausted, the worker runs as regular. Use for user-visible actions (message send) where lag hurts UX.


Chaining workers

val wm = WorkManager.getInstance(context)

wm.beginWith(decodeRequest)
.then(resizeRequest)
.then(uploadRequest)
.enqueue()

// Diamond (multiple inputs)
wm.beginWith(listOf(compressAudio, compressVideo))
.then(muxRequest)
.then(uploadRequest)
.enqueue()

Output of one worker becomes input of the next:

// In worker A
Result.success(workDataOf("resized_uri" to resizedUri.toString()))

// In worker B
val uri = inputData.getString("resized_uri")?.toUri()

If any chained worker fails, downstream workers are cancelled.


Observing work

From ViewModel

class UploadViewModel @Inject constructor(
@ApplicationContext context: Context
) : ViewModel() {
private val wm = WorkManager.getInstance(context)

val uploadState: StateFlow<WorkInfo.State?> = wm
.getWorkInfosForUniqueWorkFlow("upload_${userId}")
.map { infos -> infos.firstOrNull()?.state }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

fun upload(fileUri: Uri) {
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(workDataOf("uri" to fileUri.toString()))
.build()
wm.enqueueUniqueWork("upload_${userId}", ExistingWorkPolicy.REPLACE, request)
}

fun cancel() {
wm.cancelUniqueWork("upload_${userId}")
}
}

@Composable
fun UploadProgress(viewModel: UploadViewModel = hiltViewModel()) {
val state by viewModel.uploadState.collectAsStateWithLifecycle()
when (state) {
WorkInfo.State.RUNNING -> LinearProgressIndicator()
WorkInfo.State.SUCCEEDED -> Text("Done")
WorkInfo.State.FAILED -> Text("Failed")
WorkInfo.State.CANCELLED -> Text("Cancelled")
else -> {}
}
}

Progress observation

val progressFlow = wm.getWorkInfoByIdFlow(requestId)
.mapNotNull { info ->
if (info.state == WorkInfo.State.RUNNING) {
val progress = info.progress
Progress(
uploaded = progress.getLong("progress", 0),
total = progress.getLong("total", -1)
)
} else null
}

Cancellation

// By unique name
wm.cancelUniqueWork("upload_123")

// By tag
wm.cancelAllWorkByTag("sync")

// By ID
wm.cancelWorkById(request.id)

// Everything
wm.cancelAllWork()

Cancellation flows through coroutine cancellation in the worker — doWork will throw CancellationException at the next suspension point.


Input / output data

// Input — limited to primitives, arrays, and Strings. Max 10 KB.
val data = workDataOf(
"userId" to "u1",
"retryCount" to 0,
"urls" to arrayOf("https://...", "https://...")
)

// Output same shape
Result.success(workDataOf("orderId" to "o1", "itemCount" to 3L))

Constraints

val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // Wi-Fi only
.setRequiresCharging(true)
.setRequiresBatteryNotLow(true)
.setRequiresDeviceIdle(true) // only when device is idle
.setRequiresStorageNotLow(true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.build()

Workers are deferred until all constraints are satisfied. If setRequiresDeviceIdle(true) + setRequiresCharging(true), the work runs while the phone is charging and idle — typically overnight.

Network types

NetworkTypeWhen it runs
NOT_REQUIREDAny network state
CONNECTEDAny connected network (Wi-Fi or mobile)
UNMETEREDWi-Fi or unmetered (won't use mobile data)
METEREDMetered only (rare)
NOT_ROAMINGConnected and not roaming

Backoff & retry policies

.setBackoffCriteria(
backoffPolicy = BackoffPolicy.EXPONENTIAL,
backoffDelay = 30, backoffDelayTimeUnit = TimeUnit.SECONDS
)
  • LINEAR — retry after N, 2N, 3N, ... seconds
  • EXPONENTIAL — retry after N, 2N, 4N, 8N, ... seconds (capped at 5 hours)

Default backoff: 30 seconds exponential. Retries run for up to 10 attempts by default (use .setBackoffCriteria + monitor runAttemptCount).

override suspend fun doWork(): Result {
if (runAttemptCount >= MAX_ATTEMPTS) {
return Result.failure(workDataOf("reason" to "max attempts"))
}
/* ... */
}

Replacing AsyncTask / IntentService migration

LegacyWorkManager replacement
AsyncTaskviewModelScope.launch OR CoroutineWorker
IntentServiceCoroutineWorker (probably with foreground)
JobIntentServiceCoroutineWorker
SyncAdapterPeriodic CoroutineWorker + Constraints
GcmNetworkManagerPeriodic CoroutineWorker
AlarmManager (non-clock)Periodic CoroutineWorker
AlarmManager (exact time)AlarmManager still valid for alarms

Testing

@RunWith(AndroidJUnit4::class)
class SyncWorkerTest {
private lateinit var context: Context

@Before fun setup() {
context = ApplicationProvider.getApplicationContext()
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setExecutor(SynchronousExecutor())
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}

@Test fun sync_success() {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf("since" to 0L))
.build()

val wm = WorkManager.getInstance(context)
wm.enqueue(request).result.get()

val info = wm.getWorkInfoById(request.id).get()
assertEquals(WorkInfo.State.SUCCEEDED, info.state)
}

@Test fun sync_retries_on_network_failure() {
val driver = WorkManagerTestInitHelper.getTestDriver(context)!!
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()

WorkManager.getInstance(context).enqueue(request).result.get()

driver.setAllConstraintsMet(request.id)
// ... assert retry
}
}

Testing a CoroutineWorker directly

@Test fun doWork_returns_success() = runTest {
val worker = TestListenableWorkerBuilder<SyncWorker>(context)
.setInputData(workDataOf("since" to 0L))
.setWorkerFactory(object : WorkerFactory() {
override fun createWorker(
appContext: Context, workerClassName: String, workerParameters: WorkerParameters
): ListenableWorker = SyncWorker(appContext, workerParameters, fakeRepo, fakeCrashReporter)
})
.build()

val result = worker.doWork()
assertEquals(Result.success(), result)
}

Common patterns

Outbox pattern (offline-first writes)

See Offline-First Architecture. Client inserts into a local outbox table; WorkManager flushes it to the backend when network is available.

Device-wide periodic sync

// Called once in Application.onCreate
val sync = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"background_sync",
ExistingPeriodicWorkPolicy.KEEP,
sync
)

Media processing pipeline

wm.beginWith(decodeRequest)
.then(resizeRequest)
.then(watermarkRequest)
.then(uploadRequest)
.enqueue()

Break heavy work into chained workers. Each can retry independently with its own backoff.

Push → WorkManager → local state update

// In FCM service
override fun onMessageReceived(message: RemoteMessage) {
if (message.data["sync"] == "true") {
val req = OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(applicationContext).enqueue(req)
}
}

Push triggers sync, worker runs expedited, Flow in UI updates.


Common anti-patterns

Anti-patterns

WorkManager mistakes

  • Using for short foreground-only work (wasteful)
  • No unique name — multiple duplicate workers pile up
  • Large inputData (> 10 KB)
  • Not re-throwing CancellationException
  • Foreground worker without correct foregroundServiceType
  • No backoff — retries every 30s forever
Best practices

Production patterns

  • viewModelScope for screen-scoped work
  • enqueueUniqueWork with ExistingWorkPolicy
  • Pass IDs / URIs in inputData; large data via DB
  • Re-throw CancellationException; return Result.retry() on transients
  • Declare foregroundServiceType in manifest
  • EXPONENTIAL backoff + maxAttempts check

Key takeaways

Practice exercises

  1. 01

    Outbox flush

    Build an OutboxWorker that reads pending rows from Room and POSTs them. Retry with EXPONENTIAL backoff on IOException.

  2. 02

    Periodic sync

    Schedule a 6-hour periodic sync with network + battery constraints. Verify it runs through Doze.

  3. 03

    Chained pipeline

    Chain decode → resize → watermark → upload workers. Pass output URIs as inputData between them.

  4. 04

    Foreground upload

    Build a foreground upload worker with progress notifications. Declare foregroundServiceType=dataSync.

  5. 05

    Test with helper

    Write a test using WorkManagerTestInitHelper. Verify a worker succeeds and produces expected output data.

Next

Continue to Services & Notifications for the full foreground service story, or Permissions & Biometric for runtime permissions.