跪拜 Guibai
← All articles
Frontend

Android 16 Kills the Old Keep-Alive Combo: Foreground Service + Expedited Job Architecture

By 疯华整猫 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Any Android app that relied on a foreground service to keep background jobs alive will see those jobs throttled or killed in Android 16. The new approach keeps the most important work running within quotas, but it also forces developers to design for cancellations and to accept that 'keep-alive' is now a best-effort negotiation, not a guarantee.

Summary

Android 16 changes how background jobs are counted: a job running concurrently with a foreground service no longer gets a free pass. The quota system now depends on the app's App Standby bucket, its top-state history, and whether a foreground service is active, with the Active bucket getting roughly 30 minutes of job runtime per 24 hours and less-capable buckets dropping to 15, 10, or fewer minutes. The old trick of calling setImportantWhileForeground() is dead; even setExpedited(true) jobs are still subject to quotas. And starting a foreground service from the background triggers ForegroundServiceStartNotAllowedException, so the service must be launched only in exempt situations like boot completion or user interaction.

A workable architecture pairs a foreground service as a visible shell with an expedited periodic JobScheduler job as the actual worker. The job calls setExpedited(true) and setPersisted(true), and onStopJob reads params.stopReason to decide whether to reschedule. Android 16 introduces STOP_REASON_TIMEOUT_ABANDONED and JobScheduler#getPendingJobReasonsHistory(), letting developers log exactly why a job didn't run. The article walks through generating this code with Trae's Builder and Chat modes, then verifying on a real device with the adb compat flag OVERRIDE_QUOTA_ENFORCEMENT_TO_FGS_JOBS to disable quota enforcement for comparison.

The real message is that Android 16 forces a shift in mindset: a foreground service now guarantees only visibility, not CPU time. Jobs are best-effort and may be stopped at any time, so the architecture must accept cancellations gracefully, log the reasons, and reschedule where appropriate. The goal is predictability, not a permanent keep-alive.

Takeaways
— Android 16 computes job runtime quotas based on App Standby bucket, top-state history, and whether the job runs alongside a foreground service.
— The Active bucket allows roughly 30 minutes of job runtime per 24 hours, Working set 15 minutes, Frequent 10 minutes, and Rare and Restricted even less.
— setImportantWhileForeground() has no effect from Android 16, and isImportantWhileForeground() always returns false.
— Use setExpedited(true) to push a job into the expedited queue, but expedited jobs still obey quotas.
— Background-starting a foreground service throws ForegroundServiceStartNotAllowedException for targetSdk 31+; exemptions include boot completed, user interactions, exact alarms, and visible-state transitions.
— JobScheduler's onStopJob(params) receives params.stopReason, and Android 16 adds STOP_REASON_TIMEOUT_ABANDONED for jobs whose JobParameters weren't strongly referenced.
— JobScheduler#getPendingJobReasonsHistory() tells you why a job didn't run: bucket, quota, device state, or unmet constraints.
— The adb compat flag OVERRIDE_QUOTA_ENFORCEMENT_TO_FGS_JOBS disables quota enforcement for jobs running with foreground services, useful for testing but not a production workaround.
Conclusions

Android 16's real change is philosophical: a foreground service now guarantees user awareness, not CPU permission, so the old pattern of using it to bypass scheduler limits is invalid.

Even expedited jobs are quota-constrained, which means developers must write background work that can be cancelled at any time; any app that assumed periodic jobs would always run will break.

Using a compat flag to disable quota enforcement during development isolates timing issues, but it masks production behavior, so final validation has to happen with quotas active.

The stop-reason logging pattern shifts debugging from guesswork to evidence: you can now see exactly why the system killed a job, which is a practical improvement over brick-wall behavior.

Concepts & terms
App Standby bucket
A classification Android assigns to apps based on how recently and frequently they are used. Buckets (Active, Working set, Frequent, Rare, Restricted) determine how often background jobs and network access are allowed.
Expedited job
A JobScheduler job created with setExpedited(true). It runs in a separate expedited queue intended for time-sensitive work, but still counts against the same runtime quotas as normal jobs on Android 16.
STOP_REASON_QUOTA
A value passed to onStopJob(params) indicating the job was stopped because it exceeded its runtime quota on Android 16.
ForegroundServiceStartNotAllowedException
An exception thrown on Android 12+ (targetSdk 31+) when an app tries to start a foreground service from the background, unless one of the listed exemptions (boot, user interaction, exact alarm, etc.) applies.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗