DEV Community

Cover image for Who Gets to Run When the Screen Goes Dark? Android Services Under Modern Limits
Android Expert
Android Expert

Posted on Originally published at Medium

Who Gets to Run When the Screen Goes Dark? Android Services Under Modern Limits

Who Gets to Run When the Screen Goes Dark? Android Services Under Modern Limits

What each service type is, why plain background services are mostly gone, and how to choose between a foreground service, WorkManager and a bound service.

Every few years someone on the team opens a crash report that begins with ForegroundServiceStartNotAllowedException, sighs, and asks the same question: "It worked on my Pixel last year, what changed?"

What changed is the contract. Android used to assume that if your app wanted to keep working, it could. It now assumes the opposite: background work has to justify itself, to the system, to the battery, and ultimately to the person holding the phone. Services are still the building block underneath most long-running work, but which kind you pick, and how you start it, decides whether the code survives a review, a Play Console declaration and a Tuesday-morning Doze window.

Throughout this post I’ll use a made-up trail-running app called Kestrel. It has three jobs that look similar and are not:

  1. Record a GPS track while the runner is out, with the screen off, for two hours.
  2. Show live pace on the active-run screen.
  3. Upload finished runs to the cloud when there is a decent network.

Each job lands on a different tool. By the end you should see why.

1. Service types at a glance

A Service is an application component with no UI of its own. What matters is how it came to exist, because that determines how long it lives and what the system will let it do.

service-types

Three shapes cover almost everything:

  • Started (background) service. Your code calls startService(). It runs until something calls stopSelf() or stopService(), or until the system decides your idle app has overstayed. This is the shape that used to be everywhere and is now the most restricted.
  • Foreground service. A started service that has promised the user it is doing something they care about, and proves it with an ongoing notification. It gets a much longer leash, in exchange for rules about type, permission and start timing.
  • Bound service. A service that exists to serve clients. A client calls bindService(), receives an IBinder, and talks to the service through it. When the last client lets go, the service can be destroyed.

These shapes combine. A service can be started and bound, and Kestrel’s recorder will be exactly that. The thing to hold on to is that “started” and “bound” are two independent reasons for a service to stay alive, and the system keeps it around while either reason is still true.

2. Background execution limits: what changed and why

The short answer to “why” is the battery. The longer answer is that an app doing small amounts of invisible work looks harmless in isolation. Multiply it by every installed app, each waking the CPU, the radio or the GPS on its own schedule, and the phone never gets to sleep. Users could see the drain but not the cause, and they blamed the device.

So the platform took the decision away from individual apps, one release at a time.

restriction-timeline

Three ideas run through that timeline.

Background means “no free rides”. From Android 8.0 (API 26), an app that is not visible can’t freely create background services, and an app that stays in the background for a few minutes is considered idle, at which point its background services are stopped. The supported way to start long-lived work from that state is a foreground service, with a short deadline to promote it.

Foreground is a privilege with paperwork. Android 9 made FOREGROUND_SERVICE a required permission. Android 10 added android:foregroundServiceType, Android 11 tightened camera and microphone, and Android 14 made the type mandatory and tied each type to its own permission. Each step moves the burden from "the system guesses what your service does" to "you declare it, and the system checks."

Even foreground has limits. Android 12 stopped apps from starting foreground services out of the background, with a short list of exemptions. Android 15 added time budgets for some types. Android 16 applies runtime quotas to jobs launched from a foreground service. The direction of travel is consistent: more verification, fewer assumptions.

None of this means you can’t do background work. It means you have to pick the mechanism the platform actually wants for that kind of work, and the rest of this post is about picking correctly.

3. Foreground services: notification, types and permissions

The notification is the price of admission

A foreground service must show a notification for as long as it runs. That isn’t decoration, it’s the user’s visible handle on your work. On Android 13 and later, notifications need the POST_NOTIFICATIONS runtime permission, most foreground-service notifications can be swiped away, and the system's foreground-service task manager lets users stop apps that are running such services. Design for a user who can see you and can say no.

Two practical consequences:

  • If the notification permission is denied, the service can still run, but the user may have a harder time noticing it. Treat that as a UX problem to solve (a clear in-app explanation before you ask), not a bug to ignore.
  • Promote quickly. After startForegroundService(), you have only a few seconds to call startForeground(). The documentation describes a window comparable to the ANR interval, older guidance says five seconds, and field measurements suggest closer to ten. Don't count on more than five. Miss it and an app in the foreground crashes with a RemoteServiceException, while an app in the background gets an ANR report and the service is stopped.

Declare a type, and hold what that type demands

If your app targets Android 14 (API 34) or higher, every foreground service must declare at least one type, and must hold the matching FOREGROUND_SERVICE_* permission. These permissions are normal permissions, granted at install time and not revocable by the user, but the underlying runtime permissions (location, camera, microphone and so on) are a separate gate and must be granted before the service starts.

fgs-types

The cards sort the types by what trips teams up most:

  • Runtime-permission types (location, camera, microphone, health, connected device) fail with a SecurityException if the user hasn't granted the underlying permission yet.
  • Time-limited types (dataSync and mediaProcessing on Android 15+, shortService always) end on the system's clock. The service must stop quickly when onTimeout() fires.
  • Consent or review types (mediaProjection, specialUse, phoneCall) involve an explicit user consent step or a Play Console declaration.

Kestrel’s recorder is a location service. The manifest looks like this:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<service
    android:name=".record.RunRecorderService"
    android:exported="false"
    android:foregroundServiceType="location" />
Enter fullscreen mode Exit fullscreen mode

And the Kotlin side promotes itself using ServiceCompat, which handles the API-level differences for you:

class RunRecorderService : Service() {

    private fun promoteToForeground(): Boolean = try {
        ServiceCompat.startForeground(
            this,
            NOTIFICATION_ID,
            buildNotification(),
            ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
        )
        true
    } catch (e: IllegalStateException) {
        // ForegroundServiceStartNotAllowedException on API 31+, and the
        // older "app is in the background" error on API 26-30.
        Log.w(TAG, "Not allowed to become a foreground service", e)
        false
    } catch (e: SecurityException) {
        // Type prerequisites missing, e.g. location not granted yet.
        Log.w(TAG, "Foreground type prerequisites not met", e)
        false
    }
}
Enter fullscreen mode Exit fullscreen mode

Catching IllegalStateException rather than the API 31 class by name keeps the code safe on older devices and covers the pre-Android 12 variant of the same failure. A service that cannot become foreground should stop itself, not limp along as a background service that will be killed anyway.

(The snippets in this post show RunRecorderService in pieces. journal, locationSource, paceCalculator and uploader are Kestrel's own collaborators, and the skeleton in section 4 shows the shared coroutine scope and its cleanup.)

4. Bound services: onBind, IBinder and when they stop

Kestrel’s active-run screen needs live pace from the recorder. That is not a job for a notification or a broadcast; it is a conversation between two components in the same process. That is the bound service’s home turf.

A bound service implements onBind() and returns an IBinder. You have three ways to build that binder, and the choice is about where the client lives:

  • Extend Binder. The client is in the same process and gets a direct reference to the service. Simplest and fastest. This is Kestrel's choice.
  • Use a Messenger. Cross-process, message-based, serialised on one thread. Good when you want simple IPC without writing AIDL.
  • Use AIDL. Cross-process with a typed interface and concurrent calls. Heavier, and worth it only when you really need it.

bound-lifecycle

The binding lifecycle

When the first client binds with BIND_AUTO_CREATE, the system creates the service (onCreate()), calls onBind() once, and caches the returned binder for that Intent. Later clients receive the cached binder without another onBind() call. When the last client unbinds, onUnbind() runs and, unless something else is keeping the service alive, so does onDestroy().

The recorder exposes its state through a local binder:

class RunRecorderService : Service() {

    inner class LocalBinder : Binder() {
        val service: RunRecorderService get() = this@RunRecorderService
    }

    private val binder = LocalBinder()
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    private var trackingJob: Job? = null

    private val _pace = MutableStateFlow(PaceSnapshot.Empty)
    val pace: StateFlow<PaceSnapshot> = _pace.asStateFlow()

    override fun onBind(intent: Intent): IBinder = binder

    override fun onDestroy() {
        scope.cancel() // stops location collection with the service
        super.onDestroy()
    }
}
Enter fullscreen mode Exit fullscreen mode

On the client side, wrapping the connection in a Flow ties its lifetime to a collector, so binding and unbinding follow the screen with no manual bookkeeping:

fun Context.recorderConnection(): Flow<RunRecorderService?> = callbackFlow {
    val connection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName, binder: IBinder) {
            // Safe here: same process, non-exported service. Across processes
            // you would receive a proxy, not a LocalBinder.
            trySend((binder as RunRecorderService.LocalBinder).service)
        }
        override fun onServiceDisconnected(name: ComponentName) { trySend(null) }
        override fun onBindingDied(name: ComponentName) { trySend(null) }
        override fun onNullBinding(name: ComponentName) { close() }
    }
    val intent = Intent(this@recorderConnection, RunRecorderService::class.java)
    try {
        if (!bindService(intent, connection, Context.BIND_AUTO_CREATE)) {
            close(IllegalStateException("bindService() was refused"))
        }
    } catch (e: SecurityException) {
        close(e) // bindService() throws this when the caller may not bind
    }
    // The connection is registered with the framework before the system is asked
    // to bind, and the docs say to call unbindService() regardless of the return
    // value. So unbind on every exit path, refusal included.
    awaitClose { unbindService(connection) }
}
Enter fullscreen mode Exit fullscreen mode

Collected from an Activity or Fragment:

@OptIn(ExperimentalCoroutinesApi::class)
private fun observePace() {
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            applicationContext.recorderConnection()
                .flatMapLatest { it?.pace ?: flowOf(PaceSnapshot.Empty) }
                .collect(::renderPace)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This binds when the screen starts and unbinds when it stops, which is cheap but not free. If several screens need the recorder, hoist the connection into one shared object (a singleton exposing a StateFlow built with shareIn) so they share a single binding.

When binding does not keep you alive

Three misconceptions cause real bugs:

  • Binding does not grant background privileges. A bound service in a background app is still an app in the background. It can’t promote itself to foreground just because a client bound to it.
  • A bound service dies with its clients. If nothing started it, it is destroyed when the last client unbinds. If the recorder were only bound, the run would end the moment the user locked the phone and the screen unbound. Kestrel therefore also starts the recorder, which is why section 3 matters.
  • A client can lose the service without unbinding. If the service’s process dies, the client gets onServiceDisconnected() or onBindingDied(). After onBindingDied() the connection is dead and must be re-established deliberately. Never keep using the old binder.

One more trap: you can’t call bindService() from a manifest-registered BroadcastReceiver. Bind from a component with a longer life.

5. onStartCommand return values, with a scenario for each

When the system kills a service’s process to reclaim memory, the return value from onStartCommand() is the contract for what happens next. It is not a performance hint, it is a recovery policy.

start-command-returns

START_STICKY: resume from saved state

The system recreates the service when it can and calls onStartCommand() again, usually with a null Intent. Use it for work that has no "input", only a state you can rebuild.

The recorder journals every fix to local storage as it goes, so a revival can reopen the unfinished session:

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    // Promote first: the clock started when startForegroundService() was called.
    if (!promoteToForeground()) {
        stopSelf(startId)
        return START_NOT_STICKY
    }

    // A null intent means the system revived us after process death.
    val session = journal.openUnfinishedSession()
        ?: if (intent?.action == ACTION_START) journal.begin() else null

    if (session == null) {
        stopSelf(startId)
        return START_NOT_STICKY
    }

    trackingJob?.cancel()
    trackingJob = scope.launch {
        locationSource.updates().collect { fix ->
            journal.append(session, fix)
            _pace.value = paceCalculator.next(fix)
        }
    }
    return START_STICKY
}
Enter fullscreen mode Exit fullscreen mode

Note the shape: every branch either keeps a legitimately running recorder or stops itself and returns START_NOT_STICKY. A sticky service that can't actually do anything useful on revival is how you get zombie notifications.

START_NOT_STICKY: if it dies, let it stay dead

The system does not recreate the service. Use it when a restart with no context would be wrong or surprising. A user-triggered “export this run as GPX” is a good example: if the process is killed half-way, silently resuming ten minutes later is worse than the user tapping Export again. The cost is silent loss, so if the work must finish regardless, it doesn’t belong in a service at all (that’s WorkManager’s job, see section 6).

START_REDELIVER_INTENT: replay the unfinished job

The system recreates the service and redelivers the last Intent until you acknowledge it with stopSelf(startId). Use it when each Intent is the job. Kestrel's upload service gets one file URI per start:

class UploadService : Service() {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    private val queue = Channel<Pair<Uri?, Int>>(Channel.UNLIMITED)

    override fun onCreate() {
        super.onCreate()
        // One consumer: uploads run in the order they were started.
        scope.launch {
            for ((uri, startId) in queue) {
                try {
                    if (uri != null) uploader.upload(uri)
                } finally {
                    // Acknowledges this start. The service only stops if this is
                    // also the most recent start request.
                    stopSelf(startId)
                }
            }
        }
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        queue.trySend(intent?.data to startId)
        return START_REDELIVER_INTENT
    }

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onDestroy() {
        scope.cancel()
        super.onDestroy()
    }
}
Enter fullscreen mode Exit fullscreen mode

Three cautions. The upload must be idempotent, because redelivery can repeat work that was nearly done. An intent that always crashes the service turns redelivery into a restart loop, so validate input early and acknowledge bad input immediately (the loop above does, via finally). And process starts one at a time: stopSelf(startId) stops the service only when startId is the latest start, so if uploads #1 and #2 ran in parallel and #3 finished first, stopping on #3 would cut the others off.

A caveat that applies to sticky foreground services

Modern Android can revive a sticky service while your app is in the background, and real crash reports show what follows. On recent releases (Android 15 to 17 in the reports I found), a system-restarted media service received a null Intent in onStartCommand() and startForeground() threw ForegroundServiceStartNotAllowedException. The documented exemption list for background starts does not mention system restarts, so don't assume one.

Keep two failures apart. A refused promotion throws ForegroundServiceStartNotAllowedException (or IllegalStateException on older releases). Separately, a location, camera or microphone service that lacks while-in-use access throws SecurityException. The recorder's promoteToForeground() catches both and stops itself, which is the behaviour you want from a service that cannot do its job.

6. Decision flow: foreground service, WorkManager or bound service

Most “which should I use” debates dissolve if you ask three questions in order.

decision-flow

User-visible and ongoing → foreground service. Navigation, playback, recording, a live call. The work is happening now, the user would be unhappy if it stopped, and a notification honestly describes it. Kestrel’s GPS recorder is here.

Deferrable but guaranteed → WorkManager. Syncing, uploading, cleanup, periodic refresh. The system may choose the moment (when the network is back, when the device is idle), and the job survives app restarts and reboots. Kestrel’s cloud upload is here, and it replaces the START_REDELIVER_INTENT service from the previous section for anything that can wait:

class RunSyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {

    override suspend fun doWork(): Result =
        if (syncRepository.uploadPendingRuns()) Result.success() else Result.retry()

    // Required so expedited work can run as a foreground service on older releases.
    override suspend fun getForegroundInfo(): ForegroundInfo =
        ForegroundInfo(
            SYNC_NOTIFICATION_ID,
            buildSyncNotification(),
            ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
        )
}

fun enqueueSync(context: Context) {
    val request = OneTimeWorkRequestBuilder<RunSyncWorker>()
        .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
        .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
        .build()
    WorkManager.getInstance(context)
        .enqueueUniqueWork("run-sync", ExistingWorkPolicy.KEEP, request)
}
Enter fullscreen mode Exit fullscreen mode

Expedited work is for short, user-triggered jobs. The type passed to ForegroundInfo must match what the manifest declares for WorkManager's foreground service, along with the FOREGROUND_SERVICE_DATA_SYNC permission, and on Android 15+ it carries the same dataSync time limit, so keep these jobs short. The OutOfQuotaPolicy fallback means "run it anyway, just later" when the expedited quota is used up, which is almost always what you want. For long uploads the user explicitly started, Android 14's user-initiated data transfer jobs are the other platform-sanctioned route.

In-process interaction → bound service. Live state shared between a service and your UI. If the interaction doesn’t need to outlive the screen, a bound service (or even a plain object in your DI graph) is enough, and it asks nothing of the user.

Combine when each part has a different reason to exist. Kestrel’s recorder is foreground because the work is user-visible, started because it must outlive the screen, and bound because the UI wants live data. That’s three properties of one service, not three services.

And if the answer to all three questions is no, you may not need a service. A coroutine in a ViewModel or an application-scoped CoroutineScope is lighter, cheaper and easier to test.

7. The common crash: starting a foreground service from the background

If you only read one section, make it this one. ForegroundServiceStartNotAllowedException is the most common service crash on modern Android, and it almost never reproduces on a developer's device, because developers are looking at the screen when they test.

background-start-crash

The platform checks the app’s state at the instant of the call. If the app is not visible and none of the documented exemptions applies, the call throws. The exception can surface from startForegroundService() and from startForeground() itself.

Where it hides in real apps

  • A push handler that receives a normal-priority message and starts the recorder.
  • A Worker or JobService that decides it needs a foreground service half-way through its job.
  • A broadcast receiver reacting to a broadcast that isn’t exempt, such as a connectivity change. (Boot, package-replaced, time-zone, time and locale changes are on the exemption list.)
  • An inexact alarm or timer callback that fires while the app is in the background.
  • A START_STICKY service revived by the system while the app has no visible UI.

The second gate

Types that need while-in-use access, meaning location, camera and microphone, are stricter still. Holding a general start exemption is not enough: unless a narrower while-in-use exemption also applies, a service of those types can’t be started from the background and use those capabilities, and the start fails with a SecurityException. The fix is never a retry. It's a different trigger.

What to do about it

First, change who starts it. Start from a tap on visible UI, a notification action or a widget. Those are allowed. High-priority push messages, exact alarms that complete a user-requested action, geofence and activity-recognition transitions and expedited WorkManager jobs are also valid starting points. Check RemoteMessage.priority, because the system can downgrade a high-priority message.

Second, fail softly when you can’t. Wrap the start, and fall back to something the platform allows:

fun Context.tryStartForeground(intent: Intent): Boolean = try {
    ContextCompat.startForegroundService(this, intent)
    true
} catch (e: IllegalStateException) {
    // ForegroundServiceStartNotAllowedException on API 31+;
    // "not allowed to start service" while in the background on API 26-30.
    false
}

// In a push handler:
override fun onMessageReceived(message: RemoteMessage) {
    val intent = Intent(this, RunRecorderService::class.java).setAction(ACTION_RESUME)
    val canTryStart = message.priority == RemoteMessage.PRIORITY_HIGH
    if (!canTryStart || !tryStartForeground(intent)) {
        // We cannot record from here. Ask the user: a tap on this
        // notification is an allowed trigger.
        showResumeRecordingNotification()
    }
}
Enter fullscreen mode Exit fullscreen mode

The fallback in that last branch is the whole point. When the platform says “not now”, the honest answer is to ask the person, not to find a loophole.

Be clear about what that catch covers. It intercepts the synchronous refusal at the call site. Failures that happen later, inside the service, are out of its reach: a SecurityException from missing type prerequisites, or missing the promotion deadline (a RemoteServiceException for a foreground app, an ANR report for a background one). That is why the service guards its own promotion, as in section 3, instead of trusting the caller's try.

Other crashes from the same family

These show up in Play Vitals next to the one above and are worth a line each:

  • Context.startForegroundService() did not then call Service.startForeground(). An early return, a slow onCreate(), or a heavy dependency graph pushed the promotion past the deadline. It crashes a foreground app and shows up as an ANR for a background one. Promote first, then do the rest.
  • MissingForegroundServiceTypeException. Targeting Android 14 without a type in the manifest or in the startForeground() call.
  • SecurityException on start. The FOREGROUND_SERVICE_* permission is declared, but the underlying runtime permission isn't granted yet.
  • Time limit already exhausted for foreground service type dataSync. On Android 15+, the time budget for the type was used up and the next start was refused. A sticky restart during an exhausted budget makes this crash repeatable.
  • ForegroundServiceDidNotStopInTimeException. onTimeout() fired and the service didn't call stopSelf() quickly enough. Make teardown fast and let slow cleanup continue elsewhere.
  • ServiceConnectionLeaked. A client bound and never unbound. The callbackFlow pattern above exists to prevent this.
  • Starting dataSync from boot. Android 15 blocks several foreground-service types from being started out of a boot-completed broadcast, dataSync among them. Route that work through WorkManager.

A short checklist before you ship

  • Is the work user-visible and ongoing? If not, you probably want WorkManager, not a service.
  • Does every foreground service declare a type, the matching permission, and have its runtime prerequisites granted before start?
  • Is startForeground() the first meaningful thing the service does?
  • Does every onStartCommand() path either do real work or call stopSelf(startId) and return START_NOT_STICKY?
  • Do time-limited types implement onTimeout() and stop promptly?
  • Is every bindService() paired with an unbindService() that cannot be skipped?
  • Is every start of a foreground service reachable only from visible UI or a documented exemption, with a user-facing fallback for the rest?

Android’s service rules read like a list of prohibitions, but they encode one principle: the system will give you time and resources in proportion to how clearly you can show the user wants the work done. Pick the mechanism that makes that case honestly, and most of the crashes in this post never get written.

Sources and further reading

📱 Go Beyond Using AI — Learn How to Actually Build It Into Your Android Apps

If you’re building Android apps in 2026, “adding AI” can’t just mean bolting on a chatbot API — it means understanding on-device inference, model trade-offs, and production realities well enough to ship a feature users actually trust. I highly recommend “Mastering Android AI: App to Intelligence” It’s a practical, code-first journey through the entire Android AI stack — from ML Kit and LiteRT to Gemma, Gemini, and MediaPipe — covering everything from your first “hello inference” screen to thermal throttling, model drift monitoring, and full applied projects like an AI-powered camera app and a health companion, with hands-on Kotlin builds at every step.


Originally published on Medium.

Top comments (0)