WorkManager for Background Tasks

Background sync that dies overnight? Learn WorkManager: the right API for guaranteed background work on modern Android. Covers one-time and periodic requests, constraints, chaining, retry/backoff, passing data, observing from UI, and debugging workers delayed by Doze or OEM battery optimizers.

Written by Projectech5 min readPublished
For B.E./B.Tech Computer Science and IT students building Android apps that need reliable background work for academic and final-year projects Topics: Kotlin, WorkManager, Android
Illustration of an Android phone with background task scheduling diagram showing workers, constraints, and periodic sync arrows.
Illustration generated for this guide.
In this guide

Your app needs to upload sensor data every hour, sync the local database nightly, or send a reminder notification each morning. You write a background thread, test it — works perfectly. Then you demo it the next day and nothing happened overnight. Welcome to modern Android: the OS aggressively kills background work to save battery (Doze mode, App Standby, background execution limits), and raw threads, timers, and even old AlarmManager hacks don't survive it.

WorkManager is Google's answer: the recommended API for background work that must guaranteed run, even if the app is closed or the device restarts. This guide covers when to use it (and when not to), the core API, constraints, chaining, periodic work, and debugging.

When WorkManager is (and isn't) the answer

Need Right tool
Upload/sync data reliably, survive app kill and reboot WorkManager
Run every 15+ minutes periodically WorkManager (PeriodicWorkRequest)
Download a large file with progress + retry WorkManager (with foreground service type for long tasks)
Fire exactly at 8:00 AM sharp AlarmManager (exact alarms need special permission)
Run while the user watches a screen (music, navigation) Foreground Service
Push-triggered updates from server FCM push (see the FCM guide)
Sub-second timing, real-time guarantees None on Android — not a real-time OS

The key distinction: WorkManager guarantees eventual execution, not exact timing. A periodic worker set to 15 minutes might run at 15, 22, or 40 minutes depending on Doze — the OS batches background work to save battery. If your project genuinely needs exact timing (an alarm clock app), that's AlarmManager with SCHEDULE_EXACT_ALARM permission, not WorkManager.

Core concepts

  • Worker: the class containing your background logic (Worker for synchronous, CoroutineWorker for suspend functions — prefer CoroutineWorker).
  • WorkRequest: a single unit of work — OneTimeWorkRequest or PeriodicWorkRequest.
  • Constraints: conditions for running (network connected, charging, battery not low, storage not low).
  • WorkManager: the scheduler you enqueue requests with.
class SyncWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result {
        return try {
            val api = SensorApi.create()  // inject via Hilt in real apps
            val pending = database.syncDao().getPending()
            api.uploadReadings(pending)
            database.syncDao().markUploaded(pending.map { it.id })
            Result.success()
        } catch (e: Exception) {
            Result.retry()  // WorkManager retries with backoff
        }
    }
}

Enqueue it with constraints:

val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .setRequiresBatteryNotLow(true)
            .build()
    )
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
    .build()

WorkManager.getInstance(context).enqueue(syncRequest)

Three result types drive the retry logic: Result.success() (done), Result.failure() (give up — observe via WorkInfo), Result.retry() (try again with backoff). For network flakiness, retry() with exponential backoff is almost always right; for permanent errors (bad credentials), fail fast and surface it in the UI.

Periodic work

val periodicSync = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    )
    .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "hourly_sync",
    ExistingPeriodicWorkPolicy.KEEP,  // don't duplicate on app restart
    periodicSync
)

Critical details:

  • Minimum interval is 15 minutes. Shorter intervals are silently clamped to 15. (If your project needs tighter polling, reconsider the design — push via FCM instead.)
  • Use enqueueUniquePeriodicWork with KEEP or UPDATE, not plain enqueue — otherwise every app start schedules another copy and you get duplicate workers.
  • Flex interval lets you specify when within the period the work runs — useful for "sync sometime during each hour" without caring exactly when.
  • Periodic work survives reboots automatically. No BOOT_COMPLETED receiver needed — another thing WorkManager handles for you.

Chaining and unique work

Chain dependent tasks — compress, then upload, then clean up — so each runs only if the previous succeeded:

WorkManager.getInstance(context)
    .beginUniqueWork("upload_pipeline", ExistingWorkPolicy.REPLACE, compressRequest)
    .then(uploadRequest)
    .then(cleanupRequest)
    .enqueue()

For fan-out/fan-in (process 5 files, then merge results), beginWith(listOf(...)).then(mergeRequest) runs the list in parallel and the merge after all complete.

Passing data in and out

// Input
val request = OneTimeWorkRequestBuilder<UploadWorker>()
    .setInputData(workDataOf("sensor_id" to 42))
    .build()

// Inside the Worker
val sensorId = inputData.getInt("sensor_id", -1)

// Output (observed by the UI)
return Result.success(workDataOf("uploaded_count" to pending.size))

Observe from the UI with getWorkInfoByIdLiveData — show "Syncing..." / "Last synced" states honestly instead of pretending background work is instant.

Debugging: why didn't my worker run?

  1. Check with App Inspection. Android Studio's Background Task Inspector (App Inspection → Background Task Inspector) shows enqueued, running, and finished workers live — the fastest way to see what's scheduled.
  2. Constraints not met? A worker requiring network + charging sits in ENQUEUED until both are true. During testing, relax constraints.
  3. Doze delays. On a real device, periodic work may be hours late in Doze. Test with adb commands to force idle mode if you need to verify Doze behavior.
  4. Manufacturer battery optimizers. Some OEMs (Xiaomi, Oppo, Vivo, Samsung) kill background work beyond what AOSP does. For a college demo this usually doesn't matter; for a deployed app, users must exempt it from battery optimization — document this in your report's limitations.
  5. Minimum 15-minute clamp — if you asked for 5 minutes and see 15, that's the clamp, not a bug.

Common mistakes

  • Expecting exact timing from periodic work — it's "roughly every N minutes, OS permitting."
  • Plain enqueue for periodic work — duplicates pile up on every app launch; always use the unique variants.
  • Doing UI work in a Worker — no toasts, no view updates; return data and let the UI observe WorkInfo.
  • Long tasks without foreground service type — work running over ~10 minutes should use setForeground() with a notification, or the OS may stop it.
  • Swallowing exceptions and returning success — failed uploads marked successful means data loss. Return retry() or failure() honestly.
  • Testing only with the app open. The whole point is background reliability — test with the app swiped away and the screen off.

Where to go from here

More project guides

More in Android