An Android component for work that outlives UI, runs without a foreground interaction, and may span multiple app sessions. Understand the types, the process model, the threading gotchas — and when to not use a Service at all.
Android's process lifecycle is adversarial to background work. The OS can kill any process at any moment to reclaim memory — and it does so aggressively, prioritizing visible UI. If you start a background thread in your Activity and the user navigates away, the Activity may be destroyed, but the thread lives on without any lifecycle anchor. When the system needs memory, it kills the process — and your thread dies instantly, with no warning and no cleanup callback.
A Service is a component that tells the OS: "I have work in progress that matters." The system tracks Service lifecycle separately from UI lifecycle, can give it higher OOM priority, and delivers explicit lifecycle callbacks (onCreate, onStartCommand, onDestroy) so cleanup is possible. It's the difference between a bare thread and a process that the OS respects.
"Service is not about background threading — it's about declaring to the OS that your process has work in progress that justifies staying alive."
Music must keep playing while the user browses other apps. The player lives in a Foreground Service — the OS won't kill it, and the notification tells the user it's running. MediaSession ties the controls together.
Uploading a 200MB video must survive screen-off and app backgrounding. A Foreground Service with a progress notification keeps the upload alive regardless of UI state.
Syncing usage data doesn't need to run immediately — it can wait for charging and Wi-Fi. A raw Service would waste battery; WorkManager with constraints is the right tool.
Each service type makes a different promise to the OS. Choosing wrong costs you either battery (keeping a foreground service when you don't need one) or reliability (background service getting killed mid-work).
Started by startService(Intent) or startForegroundService(). Runs until it calls stopSelf() or the caller calls stopService(). The caller gets no return value.
Return values from onStartCommand matter:
Started by bindService(). Returns an IBinder the client uses to call methods directly. The service lives while at least one client is bound — when the last client unbinds, onUnbind() is called and the service can be destroyed.
Can be both started and bound simultaneously — common pattern for long-lived services that also expose a control API.
Must call startForeground(id, notification) within 5 seconds of start (or the system ANRs you). Shows a persistent, non-dismissible notification. Gets near-foreground OOM priority — will not be killed while the notification is up.
API 34+: must declare a foregroundServiceType in the manifest (mediaPlayback, location, camera, etc.) and hold the corresponding runtime permission.
class MyService : Service() { private val binder = LocalBinder() // Called once when the service is first created // Runs on main thread. Initialize resources here, NOT in constructor. override fun onCreate() { super.onCreate() // init DB, network, worker threads... } // Called each time startService() is called — can be called multiple times! override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // For foreground: must call within 5 seconds startForeground(1, buildNotification()) // Do work on a SEPARATE THREAD — this is still the main thread! CoroutineScope(Dispatchers.IO).launch { doWork(intent) stopSelf(startId) // use startId to avoid stopping mid-new-request } return START_NOT_STICKY // don't restart — WorkManager handles retry } // Called when a client binds. Return null for started-only services. override fun onBind(intent: Intent): IBinder = binder // Called when ALL clients unbind override fun onUnbind(intent: Intent): Boolean { return true // true = onRebind() called on next bind } // Final cleanup — called before process death override fun onDestroy() { stopForeground(STOP_FOREGROUND_REMOVE) super.onDestroy() } inner class LocalBinder : Binder() { fun getService() = this@MyService } }
Every Service goes through a well-defined state machine. The OS drives these transitions — and kills the process at exact boundaries. Understanding when each callback fires is critical for correctness.
Service class exists in the manifest but no instance in memory. No resources allocated. The Binder stub is not registered with ServiceManager.
Called once. Runs on the main thread. Initialize members, open DB connections, create HandlerThread. Never called again until the service is destroyed and re-created. Do NOT start long work here.
Called every time startService() is called, even if the service is already running. Each call gets a new startId. Use stopSelf(startId) not stopSelf() to avoid stopping a service that received a newer start request during async work.
Called when bindService() resolves. The returned IBinder is delivered to the client's ServiceConnection.onServiceConnected(). If multiple clients bind, onBind() is called only once — subsequent clients receive the same IBinder.
Called when: stopSelf() called and no bound clients remain, or the system kills the service for memory. You are not guaranteed to receive this. If the OOM killer sends SIGKILL directly, onDestroy() never fires. Use it for best-effort cleanup only.
Every Service callback — onCreate(), onStartCommand(), onBind(), onDestroy() — runs on the main thread. Network calls, database queries, file I/O, or any blocking operation will freeze the UI and trigger ANR after 5 seconds (or 200ms for broadcasts). Always dispatch to a background thread immediately inside onStartCommand().
The OS assigns each process an OOM adj score. Lower score = higher priority = last to be killed. A Service directly affects this score:
| State | OOM Priority | Kill Risk |
|---|---|---|
| Foreground Service (notif visible) | ≈ Foreground App | Very Low |
| Bound to visible Activity | ≈ Visible App | Low |
| Started Service (background) | Service Process | Moderate |
| No Service, app cached | Cached Process | High |
The return value of onStartCommand() tells the OS what to do if the service process is killed:
// Restart with null Intent — for always-on services return START_STICKY // Don't restart — for one-shot tasks (prefer WorkManager) return START_NOT_STICKY // Restart AND resend last Intent — for data-driven work return START_REDELIVER_INTENT // ⚠ START_STICKY pitfall: null Intent can NPE override fun onStartCommand( intent: Intent?, // NULLABLE — null on sticky restart! flags: Int, startId: Int ): Int { intent ?: return START_STICKY // handle null safely // ... process intent return START_STICKY }
A Bound Service exposes an IBinder interface that clients use to call methods directly. There are three levels of binding, each with increasing complexity and cross-process capability.
Same-process only. Client gets a direct reference to the Service object. Zero IPC overhead. Used for Activity-to-Service communication within one app.
Cross-process via a single-threaded message queue. Client sends Message objects; service replies via replyTo Messenger. Serialization via Bundle. Good for simple command/response patterns without full AIDL overhead.
Full cross-process RPC. Define an .aidl interface; the build system generates stub/proxy classes. Methods are called directly by name — the Binder driver handles marshaling. Multiple clients can make concurrent calls on the Binder thread pool. This is how system services (ActivityManager, WindowManager) expose their APIs.
// CLIENT SIDE: Activity binding to a local Service private var service: MyService? = null private var bound = false private val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName, binder: IBinder) { // Downcast to your LocalBinder — safe in same process service = (binder as MyService.LocalBinder).getService() bound = true } override fun onServiceDisconnected(name: ComponentName) { // Called only on unexpected disconnect (crash, kill) bound = false } } override fun onStart() { super.onStart() Intent(this, MyService::class.java).also { intent -> bindService(intent, connection, BIND_AUTO_CREATE) } } override fun onStop() { super.onStop() unbindService(connection) // ALWAYS unbind in the symmetric lifecycle callback bound = false }
// IMyService.aidl — build system generates Stub/Proxy interface IMyService { int getStatus(); void performAction(in String action, in Bundle params); // 'in' = client→service, 'out' = service→client, 'inout' = both } // Service implementation override fun onBind(intent: Intent) = object : IMyService.Stub() { // Runs on Binder thread pool — must be thread-safe override fun getStatus() = currentStatus override fun performAction(action: String, params: Bundle) { // Binder.getCallingUid() = real caller UID, unforgeable checkPermission(Binder.getCallingUid()) doWorkAsync(action, params) } }
This is the single most common Android misconception. A Service runs on the application's main thread by default. Calling startService() does NOT create a background thread. If you do any I/O, parsing, or heavy computation inside a Service callback without dispatching to a worker thread, you will block the UI and cause ANR.
A Thread subclass with a built-in Looper/Handler message queue. Good for serialized work where order matters. Each Message or Runnable is processed one at a time on the background thread.
Use a CoroutineScope tied to the service lifecycle. Cancel the scope in onDestroy() to clean up all pending work. Use Dispatchers.IO for I/O and Dispatchers.Default for CPU work.
Use Executors.newFixedThreadPool(N) when you need parallel work. Remember: Binder method calls on a Bound Service already run on the Binder thread pool (up to 16 threads), so for bound services you may not need a separate executor.
IntentService (deprecated API 30) was the classic solution: it automatically created a worker thread and processed onHandleIntent() calls serially. Today, replace it entirely with WorkManager + CoroutineWorker for deferrable work, or a started Service with coroutines for immediate background tasks. IntentService has no battery awareness, no retry logic, and no constraint support.
Since API 21, Android has steadily restricted raw Services in favour of more battery-aware mechanisms. Here are the main alternatives — their capabilities, limitations, and the scenarios where each wins.
The definitive modern replacement for most Service use cases. Built on JobScheduler (API 21+) and AlarmManager for older devices. Supports constraints, retry with backoff, chaining, periodic work, and work cancellation. Results persist across process restarts — if the app is killed mid-work, WorkManager restarts it.
When to use: Any deferrable background task — sync, upload, cleanup, analytics. If your work can wait seconds or minutes, use WorkManager.
When NOT to use: Exact timing (sub-minute), ongoing streaming, or work that needs to stay alive while the user is actively waiting (use Foreground Service instead).
The system-level API that WorkManager wraps. Schedules jobs with constraints (network type, charging, idle). The OS batches jobs across apps to minimize radio/CPU wake-ups — critical for battery life. Survives app restart; the system persists jobs and re-delivers after reboot.
When to use: Directly when you need platform-specific scheduling features not yet exposed by WorkManager, or when building platform-level code outside of the Jetpack layer.
When NOT to use: Most apps — WorkManager is a safer, better-tested abstraction over it with backward compatibility.
The only mechanism for exact-time task execution. Other background mechanisms can be deferred by the OS (especially in Doze mode). AlarmManager with setExactAndAllowWhileIdle() can wake the device at a precise time even in Doze. API 31+ requires SCHEDULE_EXACT_ALARM permission with user approval.
When to use: Calendar reminders, medication alarms, scheduled notifications — anything where the user expects a specific clock time to trigger something.
When NOT to use: Polling/sync — use WorkManager with periodic work. General background tasks — AlarmManager doesn't survive process death.
For work that's tied to the UI lifecycle, viewModelScope and lifecycleScope cancel coroutines automatically when the ViewModel/Lifecycle is destroyed. Zero Service boilerplate. StateFlow and SharedFlow replace in-process event buses (and LocalBroadcastManager) with type-safe, backpressure-aware reactive streams.
When to use: Any work that should live only while a ViewModel or UI component is alive. Network calls, database reads, local processing. Also for in-app event broadcasting replacing BroadcastReceiver.
When NOT to use: Work that must outlive the UI. If the user leaves the app, scope-tied coroutines are cancelled.
The system's built-in HTTP download engine. Handles large file downloads with automatic pause/resume on connectivity changes, progress notifications, retry on failure, and post-download actions. Downloads survive app death because they run in a system process, not yours.
When to use: Downloading large files (APKs, media, datasets) that might take minutes and need reliability across connectivity drops.
When NOT to use: Small API calls, streaming data, custom authentication flows, or cases where you need fine-grained download control not exposed by the API.
Firebase Cloud Messaging can deliver a high-priority data message that wakes the device even in Doze. Your FirebaseMessagingService.onMessageReceived() runs, giving you ~10 seconds to do work (enqueue a WorkManager job, show a notification, etc.). No persistent connection or always-on service needed — the FCM socket is managed by GMS.
When to use: Server-initiated work — refresh user data when the server has new content, trigger a sync, show a notification. Replaces polling entirely.
When NOT to use: Client-initiated work, work that must run on a schedule independent of the server.
| Requirement | Service | WorkManager | Coroutine Scope | JobScheduler | AlarmManager | FCM |
|---|---|---|---|---|---|---|
| Outlives UI / app death | ✓ Foreground | ✓ | ✗ | ✓ | ✓ (fires intent) | ✓ |
| Exact timing (to the second) | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
| Battery-aware batching | ✗ | ✓ | ✗ | ✓ | ✗ | ⚠ Partial |
| Network / charging constraints | ✗ Manual | ✓ | ✗ | ✓ | ✗ | ✗ |
| Retry with backoff | ✗ Manual | ✓ Built-in | ✗ Manual | ⚠ Limited | ✗ | ✗ |
| Ongoing / streaming work | ✓ Foreground | ✗ | ⚠ While scope alive | ✗ | ✗ | ✗ |
| Server-triggered wake | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ |
| Persist across device reboot | ⚠ BOOT_COMPLETED | ✓ | ✗ | ✓ | ⚠ RTC_WAKEUP only | ✓ |
| No persistent notification required | ✗ Foreground needs one | ✓ | ✓ | ✓ | ✓ | ✓ |
| Large file download | ⚠ Complex | ⚠ Needs custom | ✗ | ✗ | ✗ | ✗ |
Android's early Service model (API 1-20) was maximally permissive: any app could run a background Service for any reason, indefinitely. The OS priority system elevated service processes above cached ones, which was the right call for capability — but devastating for battery. Apps abused background services for polling, analytics, push connections, and constant location tracking.
The result: a 2013-era Android phone could drain its battery in 6 hours with moderate app use, largely due to background service wakeups and CPU spin. This was the original design's biggest failure.
Starting with Doze Mode (API 23), JobScheduler (API 21), and culminating in WorkManager (2018) and the API 26 background restrictions, Android shifted to a declarative model: you declare what you need (network, charging, timing), and the OS decides when to run it — batching across apps to amortize radio and CPU wake-up costs.
Foreground Services still exist because some work genuinely requires always-on execution. But the ever-tightening restrictions (API 34's foregroundServiceType, API 31's exact alarm permission) signal that Android's direction is clear: raw Services are a last resort, not a first tool.
The design lesson: Don't give apps power they'll abuse. Give them APIs that express intent — and let the OS optimise the execution.