Services & Background Work Explained
ANDROID › Components
Every Android Service follows one of two lifecycles, and knowing which one you are relying on for a given use case is exactly what interviewers listen for. A **started** service is launched with startService() or startForegroundService(). It runs independently of whoever launched it: onStartCommand() fires for each start request, and the service keeps running until it calls stopSelf() itself or something calls stopService(). A **bound** service exists to serve a client connection instead. You call bindService(), the service's onBind() returns an IBinder the client uses to talk to it directly, and the service has no life of its own beyond that connection: once the last bound client unbinds, or is itself destroyed, the system tears the service down. A single Service class can implement both onStartCommand() and onBind() and support either pattern at once, but a service that is only ever bound, never started, does not respond to stopSelf() at all, because there is no started lifecycle for stopSelf() to end.
context.startService(Intent(context, SyncService::class.java))
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
A Service does not get its own thread, and it does not get its own process, by default it runs on exactly the same main thread as your UI. That surprises people who assume the name Service implies a background thread the way it sounds like it should. If onStartCommand() does blocking or slow work directly, you block the main thread and risk an ANR, the exact same failure mode you would get from doing that work in an Activity. The fix is the same as anywhere else on Android: move blocking work onto a background thread or a coroutine dispatcher yourself, the framework will not do it for you just because the class is called Service.
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Thread { doHeavyWork() }.start()
return START_NOT_STICKY
}
The value you return from onStartCommand() tells the system what to do if it kills the service under memory pressure while it is running. START_STICKY recreates the service afterward, but with a **null** intent, since there was nothing pending to redeliver, so sticky services must be written to tolerate a null intent gracefully. START_NOT_STICKY does the opposite: the service is not recreated unless there is a start intent still pending. START_REDELIVER_INTENT sits between them, it recreates the service and redelivers the exact last start intent, which is what you want when the work is tied to that intent's data and needs to resume from where it left off rather than starting over blind.
Android 8.0 (API 26) changed the ground rules for background services. While your app is not in the foreground, the system gives its started services only a short window before stopping them outright, and repeatedly calling startService() from a background app becomes a common source of silent bugs, work that quietly never runs and nobody notices until a user reports it missing. That single platform change is why a plain background Service is now almost always the wrong default. The two replacements split by what the work actually needs: work the user can see and that is ongoing becomes a foreground service, work that is deferrable and just needs to eventually happen becomes a WorkManager job.
context.startService(Intent(context, AnalyticsService::class.java))
WorkManager.getInstance(context)
.enqueue(OneTimeWorkRequestBuilder<AnalyticsWorker>().build())
A foreground service is for work the user can see and knows is ongoing: music playback, an active download, a live location trip. It trades away the background-service limits for one obligation, it must show a persistent notification the whole time it runs, so the user always knows something is happening. You start it with startForegroundService(), and then, from inside the service, you must call startForeground(id, notification) within roughly five seconds. Miss that window and the system does not just quietly ignore it, it raises a timeout that can crash the process outright. That five-second rule exists precisely so apps cannot use startForegroundService() as a backdoor into unrestricted background work: you promote to the foreground immediately and visibly, or you do not get to run.
context.startForegroundService(Intent(context, UploadService::class.java))
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIFICATION_ID, buildNotification())
doWork()
return START_NOT_STICKY
}
API 34 (Android 14) tightened foreground services further: every foreground service must now declare a specific foregroundServiceType in the manifest, such as location, dataSync, or mediaPlayback, and it must satisfy that type's own runtime requirements. For a location type that means holding a location permission at runtime, not just declaring FOREGROUND_SERVICE in the manifest and calling it done. Skip the declaration, or fail to meet the type's preconditions, and the service throws at runtime rather than silently running with reduced guarantees. The manifest permission from earlier Android versions is necessary but no longer sufficient on its own.
<service
android:name=".LocationService"
android:foregroundServiceType="location" />
Two more version-specific rules stack on top of the notification requirement. On Android 12 (API 31), a non-exempt app that is currently in the background can no longer call startForegroundService() at all, the system throws ForegroundServiceStartNotAllowedException instead of starting it. The usual fallback is to defer the work through WorkManager's expedited work instead of fighting the restriction. On Android 13 (API 33), the mandatory notification also needs the runtime POST_NOTIFICATIONS permission to actually display, and for most foreground service types the user can now dismiss that notification without the service itself being stopped.
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()
)
}
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, uploading logs or syncing data being the classic examples. Unlike a Service, a WorkManager request is persisted in its own on-device database the moment you enqueue it, so process death and even a reboot do not lose it, the system reschedules it automatically once conditions allow. Constraints let you gate that work on real-world conditions, only run on an unmetered network, only when charging, only when the battery is not low, without you writing any of that polling logic yourself.
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.build()
val uploadWork = OneTimeWorkRequestBuilder<UploadLogsWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueue(uploadWork)
Under the hood, WorkManager does not invent its own scheduling mechanism, on API 23 and above it delegates to JobScheduler, falling back to a combination of AlarmManager and a BroadcastReceiver on older versions. You write one API and WorkManager picks the right backend for the device it happens to be running on. It also handles retries for you: a worker that fails can return Result.retry(), and WorkManager reschedules it using a backoff policy you configure, exponential by default, with a delay that grows on each subsequent attempt rather than hammering the job over and over immediately. That combination, one API, automatic backend selection, and built-in retry with backoff, is what JobScheduler alone does not give you for free.
val work = OneTimeWorkRequestBuilder<MyWorker>()
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
Periodic work through WorkManager has a hard floor: the minimum interval between runs of a PeriodicWorkRequest is fifteen minutes. Ask for less and WorkManager does not throw, it silently clamps the interval up to that minimum, which is a common source of confusion when a periodic job seems to run less often than requested. That floor exists for the same reason constraints exist, WorkManager is deliberately battery-conscious: before it will even attempt periodic or constrained work, it checks that every declared constraint, network type, charging state, battery level, device idle, is currently satisfied, and simply waits if one is not.
val work = PeriodicWorkRequestBuilder<SyncWorker>(10, TimeUnit.MINUTES)
.build()
val work2 = PeriodicWorkRequestBuilder<SyncWorker>(
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS
).build()
Picking the right background tool comes down to two questions: does the work need an exact wall-clock time, and does it need to survive process death? AlarmManager exists for the first case only, precise delivery at a specific moment, an alarm clock, a calendar reminder, using setExactAndAllowWhileIdle() to fire even through Doze. Everything deferrable, or anything that must be retried and guaranteed eventually, belongs to WorkManager instead, including periodic jobs. And for work that is quick, in-process, and does not need to outlive the current app session at all, plain coroutines scoped to viewModelScope or lifecycleScope are usually the right call, reaching for a Service or WorkManager there is over-engineering.
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
val pi = PendingIntent.getBroadcast(
this, 0, Intent(this, AlarmReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE
)
alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerAtMillis, pi)
A Worker's doWork() already runs on a background thread, but it is a plain synchronous function, calling a suspend function from inside it means wrapping the call in runBlocking, which defeats most of the point of using coroutines in the first place. CoroutineWorker exists to fix exactly that: it exposes a suspend doWork() you override directly, no runBlocking needed, and critically, it is cooperatively cancelled when WorkManager stops the work, the same cancellation behavior any other coroutine gets. For any worker whose real work is calling suspend functions, repository calls, network requests through a suspend API, CoroutineWorker is the idiomatic base class, not Worker and not RxWorker.
IntentService used to be the standard way to serialize a queue of background requests onto a single worker thread, handling them one at a time in onHandleIntent(). It is deprecated now for the same reason a plain background Service is discouraged: it relies on the same background-execution behavior that API 26 and later restrict, so on modern Android it can simply stop running while your app is backgrounded. The recommended replacement is WorkManager for the general case, deferrable, guaranteed, and constraint-aware, with JobIntentService available as a transitional bridge for code that is not ready to migrate yet. Resurrecting a plain Service with your own manual queue and thread is going backwards, not forwards.
class OldService : IntentService("OldService") {
override fun onHandleIntent(intent: Intent?) { upload(intent) }
}
class UploadWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
override fun doWork(): Result {
upload(inputData.getString("url"))
return Result.success()
}
}