Handling Background Execution Limits Correctly

Dhruval Dhameliya·September 18, 2025·8 min read

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                           │
└──────────────────────────────────────────────┘
RestrictionAffected APIsImpact
Doze23+Network, wake locks, alarms deferred in maintenance windows
Background service limits26+Cannot start background services, must use foreground or WorkManager
App Standby Buckets28+Jobs, alarms, FCM throttled based on usage frequency
FGS launch restriction31+Cannot start foreground service from background
FGS type requirement34+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.

BucketJob FrequencyAlarm FrequencyCriteria
ActiveNo restrictionNo restrictionApp in foreground or recently used
Working Set2 hours6 hoursUsed regularly but not active
Frequent8 hoursNo regular alarmsUsed often in last 7 days
Rare24 hoursNo regular alarmsRarely opened
Restricted1 per day1 per dayUser 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 TypePermission RequiredUse Case
dataSyncFOREGROUND_SERVICE_DATA_SYNCSync operations
locationACCESS_FINE_LOCATION + FOREGROUND_SERVICE_LOCATIONGPS tracking
mediaPlaybackFOREGROUND_SERVICE_MEDIA_PLAYBACKMusic/podcast playback
cameraCAMERA + FOREGROUND_SERVICE_CAMERAVideo recording
microphoneRECORD_AUDIO + FOREGROUND_SERVICE_MICROPHONEAudio 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

StrategyReliabilityComplexityBattery Impact
WorkManager periodicHighLowMedium
Expedited workHighMediumHigher
Foreground serviceHighestHighHighest
High-priority FCMMedium (quota limited)LowLow
AlarmManager exactLow (restricted)LowHigh

Failure Modes

  • ForegroundServiceStartNotAllowedException: attempting to start a foreground service from background on API 31+. Use WorkManager with setForeground instead.
  • Expedited work quota exhausted: the system limits expedited work per app. Falls back to regular work if OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST is 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

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