Handling Background Execution Limits Correctly
A comprehensive guide to working within Android's background execution restrictions across API levels, covering Doze, App Standby, foreground service requirements, and WorkManager strategies.
Context
Android has progressively restricted background execution since API 23 (Doze), API 26 (background service limits), API 28 (App Standby buckets), API 31 (foreground service launch restrictions), and API 34 (further tightening). Each version adds constraints that break previously working code. Apps that do not adapt see their background work silently deferred, killed, or blocked entirely.
Problem
The background execution model is a moving target with layered restrictions that interact in non-obvious ways. A WorkManager task that runs perfectly on API 30 may be deferred indefinitely on API 34 if the app is in the "restricted" standby bucket. A foreground service that launches correctly on API 30 throws ForegroundServiceStartNotAllowedException on API 31+. The rules are version-dependent, OEM-dependent, and user-behavior-dependent.
Constraints
- Must support API 24+ (covering 97%+ of active devices)
- Background work must complete reliably despite Doze, App Standby, and OEM restrictions
- Foreground services must comply with type declarations required on API 34+
- Must not degrade user experience with excessive notifications
- Must handle graceful degradation when background work is blocked
Design
Understanding the Restriction Layers
┌──────────────────────────────────────────────┐
│ API 34+: Foreground service type enforcement │
├──────────────────────────────────────────────┤
│ API 31+: Cannot start FGS from background │
├──────────────────────────────────────────────┤
│ API 28+: App Standby Buckets │
├──────────────────────────────────────────────┤
│ API 26+: Background service limits │
├──────────────────────────────────────────────┤
│ API 23+: Doze mode │
└──────────────────────────────────────────────┘
| Restriction | Affected APIs | Impact |
|---|---|---|
| Doze | 23+ | Network, wake locks, alarms deferred in maintenance windows |
| Background service limits | 26+ | Cannot start background services, must use foreground or WorkManager |
| App Standby Buckets | 28+ | Jobs, alarms, FCM throttled based on usage frequency |
| FGS launch restriction | 31+ | Cannot start foreground service from background |
| FGS type requirement | 34+ | Must declare and justify foreground service type |
App Standby Buckets
The system classifies your app into buckets based on user engagement. This directly affects how often your background work runs.
| Bucket | Job Frequency | Alarm Frequency | Criteria |
|---|---|---|---|
| Active | No restriction | No restriction | App in foreground or recently used |
| Working Set | 2 hours | 6 hours | Used regularly but not active |
| Frequent | 8 hours | No regular alarms | Used often in last 7 days |
| Rare | 24 hours | No regular alarms | Rarely opened |
| Restricted | 1 per day | 1 per day | User explicitly restricted |
// Check the app's standby bucket
fun checkStandbyBucket(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val usageStatsManager =
context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
val bucket = usageStatsManager.appStandbyBucket
val bucketName = when (bucket) {
UsageStatsManager.STANDBY_BUCKET_ACTIVE -> "ACTIVE"
UsageStatsManager.STANDBY_BUCKET_WORKING_SET -> "WORKING_SET"
UsageStatsManager.STANDBY_BUCKET_FREQUENT -> "FREQUENT"
UsageStatsManager.STANDBY_BUCKET_RARE -> "RARE"
UsageStatsManager.STANDBY_BUCKET_RESTRICTED -> "RESTRICTED"
else -> "UNKNOWN ($bucket)"
}
Log.d("Standby", "Current bucket: $bucketName")
}
}WorkManager: The Correct Default
WorkManager is the only background scheduling API that works reliably across all restriction layers.
class DataSyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
val syncResult = SyncEngine.sync()
if (syncResult.hasMore) {
// Schedule immediate follow-up for remaining data
Result.success(workDataOf("continuation" to true))
} else {
Result.success()
}
} catch (e: IOException) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
companion object {
fun schedulePeriodicSync(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val syncRequest = PeriodicWorkRequestBuilder<DataSyncWorker>(
1, TimeUnit.HOURS,
15, TimeUnit.MINUTES // flex period
)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
.addTag("periodic_sync")
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"data_sync",
ExistingPeriodicWorkPolicy.UPDATE,
syncRequest
)
}
fun scheduleOneTimeSync(context: Context, urgent: Boolean = false) {
val request = OneTimeWorkRequestBuilder<DataSyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.apply {
if (urgent) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
}
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"one_time_sync",
ExistingWorkPolicy.REPLACE,
request
)
}
}
}Foreground Services on API 31+
Starting a foreground service from the background throws ForegroundServiceStartNotAllowedException on API 31+. The approved alternatives:
// Option 1: Start from a user-visible context (Activity)
class UploadActivity : AppCompatActivity() {
fun startUpload() {
val intent = Intent(this, UploadService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
}
// Option 2: Use WorkManager with setForeground for long-running work
class UploadWorker(
context: Context, params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
setForeground(createForegroundInfo())
return performUpload()
}
private fun createForegroundInfo(): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, "upload")
.setContentTitle("Uploading")
.setSmallIcon(R.drawable.ic_upload)
.setOngoing(true)
.build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(
NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
ForegroundInfo(NOTIFICATION_ID, notification)
}
}
}Foreground Service Types on API 34+
API 34 requires declaring foreground service types in the manifest and at runtime.
<!-- AndroidManifest.xml -->
<service
android:name=".UploadService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<service
android:name=".LocationTrackingService"
android:foregroundServiceType="location"
android:exported="false" />| Service Type | Permission Required | Use Case |
|---|---|---|
dataSync | FOREGROUND_SERVICE_DATA_SYNC | Sync operations |
location | ACCESS_FINE_LOCATION + FOREGROUND_SERVICE_LOCATION | GPS tracking |
mediaPlayback | FOREGROUND_SERVICE_MEDIA_PLAYBACK | Music/podcast playback |
camera | CAMERA + FOREGROUND_SERVICE_CAMERA | Video recording |
microphone | RECORD_AUDIO + FOREGROUND_SERVICE_MICROPHONE | Audio recording |
Handling Doze Mode
Doze restricts network access and defers alarms. High-priority FCM messages are the only reliable way to wake a device in Doze.
class HighPriorityFcmService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
// High-priority FCM messages wake the device from Doze
// Use this for time-sensitive operations only
if (message.priority == RemoteMessage.PRIORITY_HIGH) {
val work = OneTimeWorkRequestBuilder<UrgentSyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(this).enqueue(work)
}
}
}Defensive Background Work Pattern
class ResilientWorker(
context: Context, params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val bucket = getStandbyBucket()
// Adjust work scope based on standby bucket
val workScope = when (bucket) {
StandbyBucket.ACTIVE, StandbyBucket.WORKING_SET -> WorkScope.FULL
StandbyBucket.FREQUENT -> WorkScope.ESSENTIAL
else -> WorkScope.MINIMAL
}
return try {
when (workScope) {
WorkScope.FULL -> {
syncAllData()
prefetchContent()
uploadAnalytics()
}
WorkScope.ESSENTIAL -> {
syncAllData()
uploadAnalytics()
}
WorkScope.MINIMAL -> {
syncCriticalDataOnly()
}
}
Result.success()
} catch (e: Exception) {
if (runAttemptCount < maxRetries(workScope)) Result.retry()
else Result.failure()
}
}
private fun maxRetries(scope: WorkScope) = when (scope) {
WorkScope.FULL -> 3
WorkScope.ESSENTIAL -> 2
WorkScope.MINIMAL -> 1
}
}
enum class WorkScope { FULL, ESSENTIAL, MINIMAL }Trade-offs
| Strategy | Reliability | Complexity | Battery Impact |
|---|---|---|---|
| WorkManager periodic | High | Low | Medium |
| Expedited work | High | Medium | Higher |
| Foreground service | Highest | High | Highest |
| High-priority FCM | Medium (quota limited) | Low | Low |
| AlarmManager exact | Low (restricted) | Low | High |
Failure Modes
- ForegroundServiceStartNotAllowedException: attempting to start a foreground service from background on API 31+. Use WorkManager with
setForegroundinstead. - Expedited work quota exhausted: the system limits expedited work per app. Falls back to regular work if
OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUESTis set. - WorkManager silently deferred: in the Restricted bucket, work runs at most once per day. Users who rarely open the app will have severely delayed sync.
- OEM killing: some OEMs kill WorkManager entirely. Detect this with periodic liveness checks via FCM.
Scaling Considerations
- Centralize all background work scheduling through a single registry to prevent schedule proliferation
- Monitor execution success rates per API level and OEM to identify platform-specific failures
- Implement server-driven scheduling where the backend controls sync frequency based on data staleness
See also: Event Tracking System Design for Android Applications.
Observability
- Track WorkManager execution success, retry, and failure rates per worker type
- Monitor time-to-execution (scheduled time vs. actual start time) to detect excessive deferral
- Log standby bucket changes to correlate with sync reliability
- Alert on foreground service start failures per release
Key Takeaways
- WorkManager is the only reliable background execution API. Use it as the default for all background work.
- Foreground services require type declarations on API 34+ and cannot start from background on API 31+.
- App Standby Buckets throttle all background execution based on user engagement. Design for the worst bucket.
- High-priority FCM is the only way to reliably wake a device in Doze. Use it sparingly and for genuinely urgent work.
- Adjust work scope based on the standby bucket. Do less when the system gives you less opportunity.
- Test on real devices across OEMs. Stock AOSP behavior is the best case, not the typical case.
Further Reading
- Designing Background Job Systems for Mobile Apps: Architecture for reliable background job execution on Android, covering WorkManager, job prioritization, constraint handling, and failure...
- Managing Large Dependency Graphs in Android: Strategies for structuring, optimizing, and debugging dependency injection graphs in large Android apps using Dagger/Hilt, covering scopi...
- Debugging Heisenbugs in Android Apps: Strategies for diagnosing and fixing bugs that disappear when observed, covering race conditions, timing-dependent failures, and non-dete...
Final Thoughts
Background execution on Android is an adversarial environment. The system actively works to prevent your code from running. The apps that succeed are those that embrace these constraints rather than fight them, doing the right work at the right time with the minimum necessary resources. Design for the restricted case first, then enhance for the active case.
Recommended
Designing an Offline-First Sync Engine for Mobile Apps
A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, queue management, and real-world trade-offs.
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.