Services & Background Work Explained
ANDROID › Components
Every Service you write is either **started** or **bound**, and sometimes both. Call startService() (or startForegroundService()) and the service runs independently of whoever launched it, onStartCommand() fires for each start request, and it keeps running until it calls stopSelf() or someone calls stopService(). Call bindService() instead and the service exists only to serve a client connection, onBind() returns an IBinder the client uses to talk to it directly, and once the last client unbinds, the system tears the service down:
// Started: fire-and-forget, service manages its own lifetime
context.startService(Intent(context, SyncService::class.java))
// Bound: client gets an IBinder, service dies when unbound
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
A single Service class can support both patterns at once, onStartCommand() for independent work and onBind() for direct calls, but knowing which lifecycle you're relying on for a given use case is exactly what interviewers are listening for.
A Service does **not** get its own thread or process by default, it runs on the host app's main thread, exactly where your UI runs. Do blocking work in onStartCommand() and you'll ANR the app:
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// runs on the MAIN thread; heavy work here blocks the UI
Thread { doHeavyWork() }.start() // must offload yourself
return START_NOT_STICKY
}
That return value matters too, it tells the system what to do if it kills the service under memory pressure. START_STICKY recreates the service with a **null** intent (no pending work to redeliver), START_NOT_STICKY doesn't restart unless there's a pending start intent, and START_REDELIVER_INTENT recreates the service and **redelivers the last intent**, so work tied to that intent's data can be retried from where it left off.
Since Android 8.0 (API 26), the system restricts what a plain background Service can do while your app isn't in the foreground, apps get only a short window before their started services are stopped. That's why calling startService() from a background app is a common source of silent bugs on modern Android:
// BAD on API 26+: dropped or stopped once the app leaves the foreground
context.startService(Intent(context, AnalyticsService::class.java))
// GOOD: WorkManager runs regardless of the app's foreground state
WorkManager.getInstance(context)
.enqueue(OneTimeWorkRequestBuilder<AnalyticsWorker>().build())
The fix depends on what the work actually needs. User-visible, ongoing work becomes a **foreground service**. Deferrable work that just needs to eventually happen becomes a **WorkManager** job. A raw background Service for anything but momentary, app-in-foreground work is now almost always the wrong tool.
A **foreground service** is for user-noticeable, ongoing work, music playback, an active download, a live location trip. It trades the background limits for a requirement: it must show an ongoing notification so the user always knows it's running.
// Caller:
context.startForegroundService(Intent(context, UploadService::class.java))
// Inside the service, within ~5 seconds of being started:
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIFICATION_ID, buildNotification())
doWork()
return START_NOT_STICKY
}
You start it with startForegroundService(), then must call startForeground(id, notification) inside the service within roughly 5 seconds or the system raises a timeout that can crash the process. You also need the FOREGROUND_SERVICE manifest permission, and on API 34, a declared foregroundServiceType matching the work, plus that type's own permission.
Two more version-specific rules stack on top. On Android 12 (API 31), a non-exempt app that's currently in the background can no longer call startForegroundService(), the system throws ForegroundServiceStartNotAllowedException instead. The usual fallback is expedited WorkManager work:
try {
context.startForegroundService(Intent(context, SyncService::class.java))
} catch (e: ForegroundServiceStartNotAllowedException) {
WorkManager.getInstance(context).enqueue(
OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
)
}
On Android 13 (API 33), showing that mandatory notification also now needs the runtime POST_NOTIFICATIONS permission, and users can dismiss the notification for most foreground service types without stopping the service itself.
**WorkManager** is Jetpack's answer to deferrable, guaranteed background work, the kind that must eventually run even if the app process dies or the device reboots, like syncing logs. It persists requests in its own database and picks the best backing scheduler for the OS version (JobScheduler on API 23+), so you code against one API regardless of platform:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.build()
val uploadWork = OneTimeWorkRequestBuilder<UploadLogsWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueue(uploadWork)
Constraints (network type, charging, battery-not-low, device idle) let the system defer execution until conditions are right, and because the request is persisted, it survives process death and even a reboot without any extra code from you.
Picking the right tool comes down to two questions: does it need an *exact* time, and does it need to be *deferrable*? AlarmManager is for precise wall-clock delivery, alarms, reminders, using setExactAndAllowWhileIdle(). Everything else that's background work belongs to WorkManager, including periodic jobs:
// Requesting under 15 minutes is silently clamped up to the minimum
val work = PeriodicWorkRequestBuilder<SyncWorker>(10, TimeUnit.MINUTES).build()
PeriodicWorkRequest has a hard floor of **15 minutes** between runs, requesting less doesn't error, it's just clamped up. And for quick, in-process concurrency that doesn't need to survive process death, plain coroutines (viewModelScope, lifecycleScope) are usually the right call, not a Service or WorkManager at all.
Inside a Worker's doWork() you're already on a background thread, but it's not coroutine-friendly, calling a suspend function there needs runBlocking, which defeats the point. CoroutineWorker fixes that with a suspend doWork() that's cooperatively cancelled when the work stops:
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val data = repository.fetchData() // suspend call, no runBlocking needed
saveLocally(data)
return Result.success()
}
}
That's the whole map: started versus bound for lifecycle, foreground services for user-visible ongoing work with their version-specific notification and type rules, WorkManager for deferrable guaranteed work, and AlarmManager for exact timing. IntentService, the old serialized-queue helper, is deprecated for the same reason a plain background Service is discouraged, it relies on background execution that API 26+ restricts, so WorkManager is its modern replacement.