Android Internals — Long-Running Background Work

Service

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.

3
Service Types
Started, Bound, Foreground — each with different lifecycle, priority, and process guarantees.
≠ Thread
Common Misconception
A Service does NOT run on a background thread. It runs on the main thread unless you explicitly create one.
OOM
Survival Priority
Foreground service process is nearly unkillable. Background service can be killed any time by the system.
🚀
Started Service
Fire-and-forget. Runs until stopSelf() or stopService(). No caller binding required.
🔗
Bound Service
Client-server model. Lives while at least one client is bound. Exposes IBinder interface.
📌
Foreground Service
Requires a persistent notification. Near-immune to OOM kills. Restricted since API 34.
⚙️
Alternatives
WorkManager, JobScheduler, Coroutines, DownloadManager — often better than raw Service.

Why Background Work Needs a Component

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 Player
Real World

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.

→ ForegroundService + MediaSession + MEDIA_PLAYBACK_CONTROL
File Upload
Real World

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.

→ ForegroundService + ProgressNotification + WorkManager for retry
Analytics Sync
Real World

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.

→ WorkManager (NOT Service) with NETWORK + CHARGING constraints

Three Kinds.
Very Different Contracts.

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
onStartCommand

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:

START_STICKY — system restarts it with null Intent
START_NOT_STICKY — not restarted after kill
START_REDELIVER_INTENT — restart + resend last Intent
Bound
onBind

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.

→ Client calls IBinder methods synchronously within the same process, or via AIDL across process boundaries
Foreground
Notification Required

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.

→ Music, navigation, location tracking, call recording — anything the user must explicitly see is happening
Kotlin — Full Service skeleton with all callback hooks
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
    }
}

State Machine:
What the OS Tracks

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 STATE MACHINE — onStartCommand path
Not Created
Initial State

Service class exists in the manifest but no instance in memory. No resources allocated. The Binder stub is not registered with ServiceManager.

onCreate()
Constructor → Initialization

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.

onStartCommand()
Running — may be called N times

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.

onBind()
Client Connected

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.

onDestroy()
Final Cleanup

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.

⚠️

The #1 Service Bug: Blocking the Main Thread

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().

Process Priority Impact

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

START_STICKY vs. START_NOT_STICKY

The return value of onStartCommand() tells the OS what to do if the service process is killed:

Kotlin — restart behaviors
// 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
}

Bound Services:
The Client-Server Pattern

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.

Level 1: Local Binder

Same-process only. Client gets a direct reference to the Service object. Zero IPC overhead. Used for Activity-to-Service communication within one app.

Level 2: Messenger

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.

Level 3: AIDL

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.

Kotlin — ServiceConnection & Local Binding
// 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
}
AIDL — Cross-process interface definition
// 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)
    }
}

Services & Threads:
You Must Bring Your Own

🚨

Service ≠ Background Thread

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.

HandlerThread
Classic

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.

val ht = HandlerThread("worker")
ht.start()
val handler = Handler(ht.looper)
handler.post { doWork() }
Coroutines
Modern

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.

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onDestroy() { scope.cancel() }
Executor Pool
Concurrent

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.

val executor = Executors.newCachedThreadPool()
executor.execute { doWork() }
// shutdown in onDestroy()

IntentService is Deprecated — Use CoroutineWorker

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.

Six Ways to Do
Background Work

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.

Work
Manager
Jetpack · API 14+
A+

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).

Constraints Retry/Backoff Chaining Periodic Persist across reboot
Default choice for deferrable work
Job
Scheduler
API 21 · System Service
B+

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.

Battery-aware batching Constraint scheduling System-managed
Use WorkManager unless you have specific needs
Alarm
Manager
API 1 · Exact Timing
B

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.

Exact timing Works in Doze Permission required (API 31+)
Only for user-visible exact-time alarms
Coroutines
& Flow
Kotlin · viewModelScope
A

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.

Lifecycle-aware Structured concurrency No process guarantee
Default for UI-tied background work
Download
Manager
API 9 · System Service
B

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.

System-process owned Auto-resume Progress notification
Specifically for large file HTTP downloads
FCM +
Data Msg
Firebase · Push
B+

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.

Server-initiated Wakes from Doze GMS dependency
Replace polling with server-push + WorkManager

What to Use When:
The Full Matrix

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
Quick Decision Flowchart
Does the work need to run while the user is actively waiting (music, call, nav)?
YES
Foreground Service (with Notification)
NO →
Can the work be deferred by minutes / hours?
YES
WorkManager (with Constraints)
NO →
Does it need to run at an exact clock time?
YES
AlarmManager (setExactAndAllowWhileIdle)
NO →
Is work tied to UI / ViewModel lifecycle?
YES
viewModelScope + Coroutines
NO →
Started Service + Coroutine Scope + stopSelf()

Why Android Built
Services This Way

The Original Design: Power Over Battery

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.

The Modern Evolution: Declare Intent, Not Implementation

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.