Background Work & Services
WorkManager, foreground services, service types, AlarmManager, Doze, background limits
- 01
Your upload runs in
viewModelScopeand users say it dies when they leave the screen — what does WorkManager guarantee instead, and what does it refuse to promise?EasyWorkManager guarantees that enqueued work eventually runs to completion — across process death, app exit and reboot — and deliberately refuses to promise when.
// ❌ Cancelled in onCleared() — a rotation or a Back press loses the upload, // and nothing persisted remembers that the user asked for it. // fun upload(uri: Uri) = viewModelScope.launch { repo.upload(uri) } // ✅ The request reaches WorkManager's database before enqueue() returns fun upload(context: Context, uri: Uri) { … - 02
Product wants the feed polled every five minutes. Why can a
PeriodicWorkRequestnot do that, and what is the flex interval actually for?EasyThe platform scheduler's minimum period is 15 minutes, so
PeriodicWorkRequestBuilder<FeedWorker>(5, TimeUnit.MINUTES)does not throw — it is silently clamped to 15, and Doze stretches even that.// Silently clamped to the 15-minute floor: no exception, no warning val tooFast = PeriodicWorkRequestBuilder<FeedWorker>(5, TimeUnit.MINUTES).build() // Honest version: once an hour, inside the last 15 minutes of the hour. // The flex window is what lets the system batch your wakeup with other apps'. val feed = PeriodicWorkRequestBuilder<FeedWorker>( … - 03
How do you pass a payload into a Worker and get a result back out, and what breaks the moment that payload is a 2 MB image?
EasyYou pass a
Databundle in and return one fromResult.success(...), and 2 MB breaks it immediately:Datais capped atData.MAX_DATA_BYTES— 10 240 bytes serialized — and going over throwsIllegalStateExceptionwhen WorkManager writes the request to its database.// ❌ Bytes through Data — IllegalStateException on enqueue: // "Data cannot occupy more than 10240 bytes when serialized" // val bad = workDataOf("image" to jpegBytes) // ✅ Content goes to disk, Data carries the pointer val file = File(context.cacheDir, "upload-${UUID.randomUUID()}.jpg") … - 04
Your backup Worker requires an unmetered network and the user walks out of Wi-Fi halfway through the upload. What does WorkManager do to the running Worker?
MediumIt stops it. Constraints are monitored for the whole run, not just checked at the start: WorkManager cancels the Worker, calls
onStopped(), moves the work back to ENQUEUED, and runs it again from the top once the constraint holds.class BackupWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { override suspend fun doWork(): Result { val queuedAt = inputData.getLong("queuedAt", 0L) // Nothing expires an unsatisfiable constraint — decide staleness yourself if (System.currentTimeMillis() - queuedAt > 7.days.inWholeMilliseconds) { … - 05
You enqueue the sync on every app start; now users get duplicate syncs, and the period you changed six months ago still never took effect. What went wrong?
MediumPlain
enqueue()creates a fresh independent request each time, and the periodic half has the opposite bug:ExistingPeriodicWorkPolicy.KEEPpreserves whatever schedule the first install created, so a new interval shipped in a new version is ignored forever.// ❌ A brand new, independent request on every app start // WorkManager.getInstance(ctx).enqueue(OneTimeWorkRequestBuilder<SyncWorker>().build()) // ✅ One name plus KEEP: repeated starts join the run that is already queued WorkManager.getInstance(ctx).enqueueUniqueWork( "sync-now", … - 06
A Worker gets a 500 from the server, returns
Result.retry(), and a week later it is still retrying. How do you make it give up?MediumNothing stops a retry loop except your own code —
Result.retry()has no attempt limit, so you count withrunAttemptCountand switch toResult.failure()once you are past the budget.class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { override suspend fun doWork(): Result = try { api.sync() Result.success() } catch (e: HttpException) { … - 07
Three photo uploads must all finish before a single publish Worker runs. How do you express that, and what happens to publish if one upload fails?
MediumbeginWith(listOf(a, b, c)).then(publish).enqueue()— a list inbeginWithfans out and runs in parallel,thenjoins, andpublishstarts only once all three succeed.val uploads = photos.map { photo -> OneTimeWorkRequestBuilder<UploadWorker>() .setInputData(workDataOf("path" to photo.path)) .build() } … - 08
Your
Workercopies files in awhileloop and the log shows it still running long after WorkManager stopped the job. What did you get wrong about cancellation?MediumStopping is cooperative.
// ❌ Nothing interrupts a blocking doWork() — the loop runs on past the stop // class CopyWorker(ctx: Context, p: WorkerParameters) : Worker(ctx, p) { // override fun doWork(): Result { // for (file in files) copy(file) // isStopped is never read // return Result.success() // } … - 09
Your upload screen draws its progress bar from
WorkInfo. The user leaves the app, comes back, and the bar sits at zero while the upload is clearly still running — why?MediumWorkInfo.progressonly exists for the duration of one run of one worker, and theUUIDyour ViewModel was observing died with the process.// WRONG — a UUID held in memory, and a bar driven by per-run progress class UploadViewModel(app: Application) : AndroidViewModel(app) { private var id: UUID? = null // gone with the process fun start(req: OneTimeWorkRequest) { id = req.id WorkManager.getInstance(app).enqueue(req) … - 10
Product wants the sync to happen "now, not in fifteen minutes". What does
setExpeditedactually buy you, and what happens once the app's quota is gone?MediumsetExpeditedasks the system to run a one-time request as soon as it can out of a limited per-app budget, andOutOfQuotaPolicyis your answer to the only interesting question: what should happen when that budget is empty.val sync = OneTimeWorkRequestBuilder<SyncWorker>() .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setConstraints( Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() ) .build() … - 11
Your upload service worked for years and now crashes on Android 14 with
MissingForegroundServiceTypeException. What does the platform want from you in 2026?MediumSince Android 14 a foreground service must declare a type, hold the runtime permission matching that type, pass the same type to
startForeground, and — before you can ship — be justified per type in a Play Console declaration.// AndroidManifest.xml // <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> // <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> // <service // android:name=".UploadService" // android:foregroundServiceType="dataSync" … - 12
A teammate writes a
Serviceto run the nightly sync and binds to it from the Activity to read its progress. What is wrong with each half of that?MediumThe sync should not be a
Serviceat all — a backgrounded app cannot even start one — and binding to read progress solves a shared-state problem with an inter-process tool.// WRONG — a Service as a work runner, plus a binder to read progress class SyncService : Service() { inner class LocalBinder : Binder() { fun progress() = percent } override fun onBind(intent: Intent?): IBinder = LocalBinder() override fun onStartCommand(i: Intent?, f: Int, id: Int): Int { repo.syncBlocking() // runs on the main thread — ANR … - 13
Build me an alarm clock that still rings at 6:00 after the phone spent the night in Doze and was rebooted at 3am. What does that actually take?
HardAn exact alarm posted with
setAlarmClock, a special-access permission you had to ask the user for, and a boot receiver that rebuilds every alarm from your own database — becauseAlarmManagerremembers nothing across a reboot.class AlarmScheduler(private val ctx: Context, private val dao: AlarmDao) { private val am = ctx.getSystemService(AlarmManager::class.java) fun armNext() { val next = dao.nextEnabled() ?: return … - 14
The same nightly sync fires reliably on a Pixel and never runs on your users' Xiaomi and Samsung phones. What is going on, and what can you honestly do about it?
HardThree separate mechanisms are holding you back — Doze, your App Standby bucket, and an OEM battery manager that is not part of AOSP at all — and only the first two behave the way the documentation says.
// Snapshot the restrictions in play, attach it to your sync telemetry data class BackgroundHealth( val manufacturer: String, val bucket: String, val batteryOptimised: Boolean, val backgroundRestricted: Boolean, … - 15
The server needs the client to pull new data the moment it changes. How do you drive that from a high-priority FCM message without burning the app's quota or its battery?
HardTreat the push as a wake-up signal rather than as the work:
onMessageReceivedenqueues a unique WorkManager job and returns, and only the messages that genuinely cannot wait go out at high priority.class AppMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { // Was our high priority honoured, or did FCM downgrade us for sending // high-priority data messages that never produced a notification? if (message.priority != message.originalPriority) { … - 16
One user swears the sync worker "never runs" and you cannot reproduce it. How do you find out what the scheduler actually did with your job?
HardYou ask the scheduler directly —
dumpsys jobschedulerand WorkManager's diagnostics broadcast both print which constraints are unmet — and then you add the telemetry that would have answered this without a device in your hands.# What did the scheduler decide about our job? adb shell dumpsys jobscheduler | sed -n '/com.example.app/,/^$/p' # JOB #u0a231/12: ... #com.example.app/androidx.work.impl.background.systemjob.SystemJobService # Required constraints: CONNECTIVITY BATTERY_NOT_LOW # Satisfied constraints: BATTERY_NOT_LOW <- no network: this is your answer # Standby bucket: RARE …