The app works perfectly while you're looking at it. Data keeps syncing, timers fire, network requests finish, and notifications show up right on time.
Then you lock the phone.
A few minutes later, the sync hasn't happened. A background task appears stuck. Maybe the expected notification never arrives. You turn the screen back on, and suddenly everything wakes up and starts working again.
I've learned not to treat this as one specific Android bug. Locking the screen can expose several different problems that look almost identical from the outside. It could be Doze, background execution limits, a stopped service, an unreliable in-process timer, an OEM battery setting, or simply an incorrect assumption about how long an app process gets to stay active.
The phone usually isn't just "killing the app." What's happening is more specific, and figuring out which restriction you're hitting is the useful part.
Open doesn't mean allowed to run forever
When an app's activity is visible, Android treats it as something the user is actively using. The process has a high priority, network access is generally available, and code started by the UI often keeps running without obvious trouble.
That can create a misleading test environment.
Suppose I start a coroutine, executor task, timer, or regular background service from an activity. While the app is open, it may work every time. But none of those things automatically becomes durable background work just because it was launched successfully.
Once the screen is locked, the activity usually moves through onPause() and onStop(). The process may continue running, but the app is no longer in the foreground from Android's point of view.
That distinction matters:
- An app process can still exist without having permission to do unrestricted background work.
- An activity being stopped does not guarantee that the process will be terminated.
- A process remaining alive does not guarantee that its timers, network access, jobs, or services can run whenever they want.
- Work stored only in memory disappears if Android later removes the process.
This is why onStop() should never be treated as a signal that the app has a few more hours to finish something. Android lifecycle callbacks describe component state. They aren't a background execution contract.
What actually changes when the screen turns off
Locking the screen doesn't immediately flip one giant "stop all apps" switch.
Several things can happen, sometimes right away and sometimes after the device has been idle for a while.
First, the activity is no longer visible. If the app has no foreground component, its process importance drops. Android now has more freedom to reclaim that process if the system needs memory.
Second, the CPU may suspend when nothing is holding an appropriate wake lock. A Java or Kotlin timer doesn't keep the hardware awake by itself. If the CPU sleeps, the timer isn't getting regular execution time.
Third, background execution rules start to matter. Depending on the Android version and app state, Android may limit background services, defer jobs, batch alarms, or restrict network access.
After the device remains unused, Doze can apply. App Standby and standby buckets may also affect apps that the user hasn't interacted with recently.
These mechanisms overlap, but they aren't the same thing.
Background execution limits are often the first problem
Android 8.0 introduced major limits on what apps can do with background services. When an app moves to the background, it usually gets a short window in which existing background services can continue. After that window, Android stops those services as if the app had called stopSelf().
That means an old pattern like this is not a reliable way to run indefinitely:
startService(Intent(this, SyncService::class.java))
It might work while the activity is visible. It might continue briefly after the screen is locked. Then it stops.
On newer Android versions, trying to start a normal background service while the app is already in the background can also throw an IllegalStateException.
This still doesn't mean Android has necessarily terminated the entire process. The system may only be restricting or stopping the background service. Other parts of the process might remain around for some time.
That's one reason this bug can feel inconsistent. The process can appear alive in one log while the work you expected is no longer allowed to continue.
Doze is delayed, but it changes a lot
Doze is Android's idle power-saving state. It doesn't usually begin the instant the screen turns off. The device needs to meet idle conditions, and the timing varies by Android version and system behavior.
Once the device enters Doze, Android starts deferring a number of things:
- Regular network access is suspended.
- Standard alarms are deferred.
- Jobs and WorkManager work may be postponed.
- Wake locks are ignored.
- Background CPU activity is heavily limited.
The device periodically enters maintenance windows where deferred work gets a chance to run. If the phone stays idle, those windows can become less frequent.
This explains a common pattern: the app works for a little while after the phone is locked, then stops making progress, then catches up later.
Doze isn't supposed to block everything forever. It's designed to batch background work so the phone doesn't keep waking up for every app that wants to poll a server or run a timer.
An app being exempt from battery optimization changes some Doze behavior, but asking users for that exemption shouldn't be the default fix. Google Play policy and Android guidance reserve broad exemptions for cases where the app's core function truly can't work under normal power management.
Most apps should adapt their scheduling instead.
App Standby is related, but not triggered by one screen lock
App Standby deals more with apps the user hasn't actively used for a while. Android can defer their network access and background jobs because the system considers them inactive.
Newer Android versions also place apps into standby buckets such as active, working set, frequent, rare, and restricted. The bucket affects how often an app can run jobs, receive alarms, and use background resources.
The exact bucket behavior has changed across Android releases, so I don't build logic around a fixed promise such as "rare apps get exactly this many minutes." The useful point is that Android gives recently used apps more freedom than apps that haven't been opened in a long time.
One screen lock normally isn't enough to make App Standby the immediate cause. But it can explain why the issue only happens after the app hasn't been used for hours or days.
Battery optimization and user restrictions add another layer
Users can usually choose a battery mode for an app. The labels vary by Android version and phone manufacturer, but common choices include unrestricted, optimized, and restricted.
"Optimized" is generally the normal state. It allows Android's standard battery management to apply.
"Restricted" can be much harsher. Background jobs, alarms, services, and other behavior may be limited enough that the app doesn't behave as expected. Android also warns that restricted apps may not work correctly in the background.
There's also the system's general background activity restriction. This is separate from Android's rules about starting an Activity from the background, though the names are easy to confuse.
Background activity launch restrictions control when an app can suddenly place UI on the screen. Starting with Android 10, apps generally can't launch activities from the background unless they meet a documented exception. A notification is usually the correct way to ask the user to open something.
None of these settings is perfectly consistent across manufacturers. Some vendors add their own auto-start controls, sleeping app lists, power modes, or process management policies. An app can behave correctly on one phone and get delayed aggressively on another, even when both report the same Android version.
I still design around Android's documented APIs first. Manufacturer-specific instructions are a fallback for confirmed device-specific behavior, not the foundation of the app.
A foreground service is not a hidden keep-alive trick
A foreground service is meant for work the user can actively notice. Navigation, workout tracking, media playback, and an ongoing file transfer are typical examples.
It must show an ongoing notification and declare an appropriate foreground service type. Recent Android releases also require corresponding permissions for certain service types.
A basic start looks like this:
val intent = Intent(context, TrackingService::class.java)
ContextCompat.startForegroundService(context, intent)
The service then needs to promote itself to the foreground promptly:
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int
): Int {
startForeground(
TRACKING_NOTIFICATION_ID,
createTrackingNotification()
)
return START_NOT_STICKY
}
This gives the service a higher-priority execution state. It doesn't make the app immune to every system rule, and it doesn't automatically keep the CPU awake. If the work genuinely requires the CPU while the screen is off, wake-lock handling may still be relevant. That needs to be narrowly scoped because an incorrectly held wake lock can wreck battery life.
There are also restrictions on starting foreground services.
Since Android 12, apps generally can't start a foreground service while already in the background unless one of the documented exceptions applies. Newer Android versions enforce service types, related permissions, and additional time limits for some types. For example, Android 15 introduced time limits for dataSync and mediaProcessing foreground services when an app targets that version.
So the answer isn't "convert every service into a foreground service." If the user can't reasonably tell why the app is running right now, a foreground service is probably the wrong model.
WorkManager is usually right for deferred, durable work
For background work that must eventually happen, WorkManager is usually where I start.
Examples include:
- Uploading locally saved data
- Syncing when a network connection is available
- Retrying a failed API request
- Processing data after a push message
- Performing periodic maintenance
A one-time request can declare what it needs:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"account-sync",
ExistingWorkPolicy.KEEP,
syncRequest
)
WorkManager stores the request and coordinates with Android's scheduling APIs. If the process disappears, the request isn't just lost with an in-memory coroutine.
The tradeoff is timing. WorkManager guarantees that eligible work will be scheduled, not that it will start at an exact second. Doze, constraints, quotas, standby buckets, and system load can all delay it.
Periodic work is also intentionally inexact, with a minimum repeat interval of 15 minutes. It isn't appropriate for a timer that must fire every minute.
Expedited work can request faster execution, but it has quotas and should be used for genuinely time-sensitive tasks. It isn't an unlimited express lane.
Long-running WorkManager tasks can run with foreground service support, but that still brings foreground service rules into the picture. WorkManager doesn't erase platform restrictions.
Push is better than polling for server-driven events
If the app needs to react when something changes on a server, keeping a process alive and polling every few minutes is usually the wrong design.
Firebase Cloud Messaging gives the server a way to notify the device that something happened.
There are two broad message patterns:
- Notification messages can be displayed by the FCM SDK when the app is in the background.
- Data messages are delivered to app code through
FirebaseMessagingService.
That difference matters. If a notification message works while the screen is off but a data message doesn't complete its processing, the issue may be what the app is doing inside onMessageReceived().
That callback is intended for short work. If the app needs to download files, update a large database, or perform something retryable, it should hand the task to WorkManager.
override fun onMessageReceived(message: RemoteMessage) {
val request = OneTimeWorkRequestBuilder<MessageSyncWorker>()
.setInputData(
workDataOf("messageId" to message.messageId)
)
.build()
WorkManager.getInstance(applicationContext)
.enqueue(request)
}
FCM priority also matters.
Normal-priority messages can be delayed during Doze. High-priority messages are meant for urgent, user-visible events and can temporarily wake the device. If high priority is repeatedly used for messages that don't produce user-visible results, FCM may deprioritize later messages.
FCM delivery also isn't a precision clock. Messages can be delayed, collapsed, or expire based on their configuration and device connectivity.
If FCM says the message arrived but no notification appears, I check a different set of problems:
- Does the app have notification permission on Android 13 or later?
- Is the notification channel disabled?
- Is the channel importance too low?
- Did the app actually create the notification?
- Was the message a notification payload or a data-only payload?
- Was the app force-stopped?
A force-stopped package is a special case. Android keeps it in a stopped state until the user launches it again or otherwise interacts with it. That can block scheduled work and message delivery. Swiping an app out of the recent apps screen is not normally the same thing as force-stopping it, although manufacturer behavior can complicate the result.
Why in-process timers fail so easily
A lot of screen-lock bugs come from code that assumes the process will keep running.
Examples include:
Handler.postDelayed()TimerScheduledExecutorService- A coroutine with
delay() - A loop that sleeps between network requests
- An RxJava interval
- A JavaScript timer inside a WebView or cross-platform runtime
These tools are fine while the process is alive and getting CPU time. They aren't persistent schedulers.
If Android removes the process, the timer is gone. If the CPU suspends, it doesn't keep ticking normally. If Doze blocks network access, the callback might run later but still be unable to complete its request.
For a short UI-related delay, an in-process timer is fine. For work that must survive the app leaving the screen, I use a platform-backed mechanism.
If exact user-facing timing is genuinely required, an alarm may be appropriate. Exact alarms have their own permission and policy restrictions on modern Android, so they shouldn't be used as a general replacement for background scheduling.
How I diagnose the actual restriction
I try not to guess from the symptom alone. "It stops after the screen locks" isn't enough to identify the mechanism.
First, I log the boundaries of the work:
Log.d(TAG, "Sync requested")
Log.d(TAG, "Worker started")
Log.d(TAG, "Network request started")
Log.d(TAG, "Network request completed")
Log.d(TAG, "Worker finished")
I also log:
- Activity lifecycle transitions
- Service creation and destruction
-
onStartCommand()calls - WorkManager attempt counts
- FCM receipt times and message IDs
- Network failures and timeout causes
- Notification creation
- Whether the process was started fresh
That tells me whether the work wasn't scheduled, was scheduled but delayed, started and failed, or completed without producing the expected UI.
Then I inspect system state with ADB.
To see Doze and device idle state:
adb shell dumpsys deviceidle
To force the device into idle mode for a controlled test:
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle
To leave forced idle mode and restore battery reporting:
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery reset
The battery unplug command makes Android treat the device as unplugged, which matters because a USB-connected test phone may otherwise behave like a charging device.
To simulate an inactive app:
adb shell am set-inactive com.example.app true
adb shell am get-inactive com.example.app
On versions that support standby bucket commands, I can also test a restrictive bucket:
adb shell am set-standby-bucket com.example.app rare
adb shell am get-standby-bucket com.example.app
To inspect scheduled jobs:
adb shell dumpsys jobscheduler
To inspect running services:
adb shell dumpsys activity services com.example.app
For alarms:
adb shell dumpsys alarm
And for the full story, I keep Logcat running while the screen is off:
adb logcat
Filtering for the package, ActivityManager, JobScheduler, WorkManager, and Firebase Messaging usually makes the output manageable.
ADB commands and output can vary between Android versions. I treat them as test tools, not behavior that production code should depend on.
Testing with the phone actually locked
A quick screen-off test isn't enough. I test several distinct states because they exercise different paths:
- App visible with the screen on
- App backgrounded with the screen on
- Screen locked for a short time
- Device forced into Doze
- App placed in a restrictive standby bucket
- Device disconnected from power
- Network disconnected and restored
- Process removed without force-stopping the package
- App explicitly force-stopped
- Device rebooted before scheduled work runs
I also test more than one Android version. Android 8's background service limits, Android 10's activity launch restrictions, Android 12's foreground service start restrictions, Android 13's notification permission, and newer foreground service requirements can all change what fails.
At least one physical device is useful too. Emulators are great for forced-state testing, but they don't reproduce every manufacturer battery policy.
For delayed work, I don't stare at the UI and assume nothing happened. I collect timestamps from the app and server, then compare when the work was requested, when Android started it, and when the request reached the backend.
That separates a scheduling delay from a notification bug or failed API call.
Picking the mechanism that matches the job
The choice gets easier when I stop asking, "How do I keep my app running?" and ask, "What kind of work is this?"
For durable work that can run later, I use WorkManager.
For continuous, user-visible work that needs to happen now, I use a foreground service with the correct service type and notification.
For server-driven events, I use FCM, then hand longer processing to WorkManager when needed.
For an exact, user-facing event such as an alarm clock, I evaluate the exact alarm APIs and their current permission rules.
For ordinary UI work, I keep it tied to the activity or screen lifecycle and accept that it stops when the UI goes away.
What I don't do is rely on a loop, timer, coroutine, or plain background service to keep an Android app alive forever. It may survive a basic test with the screen on, but that test proves almost nothing about Android background execution.
The practical fix is usually not to fight every battery restriction. It's to make the work durable, schedulable, and honest about how urgent it really is.
Top comments (0)