Background Refresh May Never Run — Measuring Execution Opportunity and Redesigning Around Freshness
Scheduling a BGTaskScheduler or WorkManager job is a request, not a guarantee. Here is how I instrumented execution opportunity, defined freshness as an SLO with p50 and p90 targets, and rebuilt the update path into three layers that hold up when background work never runs.
A message arrived late one night from someone using my wallpaper app: "It's been showing the same image for three days."
On my own phone, the picture changed every morning without fail. I could not reproduce it. Only after pulling the logs did the situation come into focus. The background refresh task had been scheduled. It had simply never run. Not once.
Scheduling is a request. Whether the work executes is the operating system's decision. I knew that line by heart. On a real device in someone else's pocket, it carried an entirely different weight.
The assumption that quietly broke
My original design was naive. Schedule a BGAppRefreshTask overnight, prepare tomorrow's wallpaper before the user wakes up. On Android, do the same with a PeriodicWorkRequest on a 24-hour cadence. The widget just reads whatever image was prepared.
It works flawlessly in the simulator. It works on my iPhone, because I open the app daily and sleep with it on the charger.
My users are not me. Some keep Low Power Mode on permanently. Some open the app once a week. Some have added it to Android's battery optimization list. On those devices the task sits in the queue, its turn never comes, and the widget keeps displaying an image from several days ago.
Not a bug. Exactly the documented behavior. The design had simply misread reality.
Execution opportunity is a lottery, not an entitlement
iOS and Android use different vocabulary, but the shape is the same. The app declares what it would like to do. The OS inspects device state and grants a moment to do it. On some days no moment is granted.
Aspect
iOS (BGTaskScheduler)
Android (WorkManager)
Minimum interval
earliestBeginDate is a floor, never a ceiling
PeriodicWorkRequest floors at 15 minutes; execution depends on Doze windows
Execution window
App Refresh typically hits expirationHandler around 30 seconds
Workers get 10 minutes before isStopped is raised
Primary throttles
Low Power Mode, app launch frequency, charging and network state
Requests survive (re-register after an app update)
Persisted, but a force stop halts everything until re-registration
Visibility of failure
A run that never happened leaves no trace anywhere
WorkInfo records state, but never the reason for deferral
That last row is the heart of the problem. Executions leave records. Non-executions leave nothing. So you never notice.
Android's App Standby Bucket demotes an app through active, working_set, frequent, rare, and restricted based on how it is used on that device. Once a weekly-use app lands in rare, its periodic window narrows to roughly once a day, if that. Mine was living there.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Minimal Swift and Kotlin instrumentation for logging execution opportunity, and what the resulting run rates actually looked like
✦Defining freshness as an SLO with p50 and p90 staleness targets, so background refresh becomes a contract rather than a hope
✦A three-layer update design that survives a job that never runs, plus where to draw the line when delegating measurement to an agent
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Instrument the opportunity before touching the fix
Before proposing a fix, I added measurement. Without a number for how often the job fails to run, there is no way to tell which remedy actually helped.
On iOS I record both the scheduling timestamp and the execution timestamp. A run that never happened cannot be logged, so instead I read the elapsed time since the last successful run at launch.
import BackgroundTasksimport Foundationenum RefreshLog { private static let lastRunKey = "bg.lastRunAt" private static let lastScheduleKey = "bg.lastScheduledAt" private static let historyKey = "bg.runHistory" static func markScheduled() { UserDefaults.standard.set(Date(), forKey: lastScheduleKey) } static func markCompleted(success: Bool) { let now = Date() UserDefaults.standard.set(now, forKey: lastRunKey) // Keep only the last 30 entries; that is plenty for analysis var history = UserDefaults.standard.array(forKey: historyKey) as? [[String: Any]] ?? [] history.append([ "at": now.timeIntervalSince1970, "scheduledAt": (UserDefaults.standard.object(forKey: lastScheduleKey) as? Date)? .timeIntervalSince1970 ?? 0, "success": success, "lowPower": ProcessInfo.processInfo.isLowPowerModeEnabled, ]) UserDefaults.standard.set(Array(history.suffix(30)), forKey: historyKey) } /// Call at launch. Hours since the last successful refresh *is* the freshness metric. static func stalenessHours() -> Double? { guard let last = UserDefaults.standard.object(forKey: lastRunKey) as? Date else { return nil } return Date().timeIntervalSince(last) / 3600 }}
Always route scheduling through markScheduled(). Skip it and you lose the ability to distinguish "I thought I scheduled it but the submit threw" from "it was scheduled and never ran." Those two have completely different causes and completely different fixes.
func scheduleRefresh() { let request = BGAppRefreshTaskRequest(identifier: "net.example.wallpaper.refresh") request.earliestBeginDate = Date(timeIntervalSinceNow: 6 * 3600) do { try BGTaskScheduler.shared.submit(request) RefreshLog.markScheduled() } catch { // submit failures are silent; swallowing them makes root-causing impossible Analytics.log("bg_submit_failed", ["reason": "\(error)"]) }}func handle(task: BGAppRefreshTask) { scheduleRefresh() // Schedule the next run FIRST, or expiration will drop the chain let work = Task { let ok = await WallpaperUpdater.shared.prepareNext() RefreshLog.markCompleted(success: ok) task.setTaskCompleted(success: ok) } task.expirationHandler = { work.cancel() RefreshLog.markCompleted(success: false) }}
Re-scheduling at the top of handle is the part that matters. Put it at the bottom and any run that hits expirationHandler first breaks the chain, after which the task never fires again. That was the first hole I fell into.
On Android, observing WorkInfo looks sufficient until you realize it never explains a deferral. So I have the Worker leave its own trail.
class WallpaperRefreshWorker( context: Context, params: WorkerParameters,) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val prefs = applicationContext.getSharedPreferences("bg", Context.MODE_PRIVATE) val startedAt = System.currentTimeMillis() val usageStats = applicationContext.getSystemService(UsageStatsManager::class.java) val bucket = usageStats?.appStandbyBucket ?: -1 // detect demotion to rare/restricted return try { WallpaperUpdater.prepareNext() prefs.edit() .putLong("lastRunAt", startedAt) .putInt("lastBucket", bucket) .apply() Result.success() } catch (e: IOException) { // Network failures deserve a retry. Nothing else belongs in this branch. if (runAttemptCount < 3) Result.retry() else Result.failure() } }}
Recording appStandbyBucket alongside the run lets you later correlate missing days with the bucket the device was in. It is the cheapest line of code in the whole investigation.
I ran this instrumentation for about two weeks across four apps. The numbers were harsher than I had assumed. Devices that received at least one execution opportunity within 24 hours came to roughly 70 percent on iOS, and to well under half on Android once the device had fallen into the rare bucket. On the rest, staleness stretched to two days, then three.
I had believed I was writing a job that occasionally failed. I was in fact writing a job that never reached a large share of my users.
Redefine freshness as an SLO
At that point I changed the language of the design. Not "refresh every morning," but "how much staleness do we tolerate, at which percentile?"
The targets I settled on: p50 under 24 hours, p90 under 72 hours. Past 7 days, this is no longer a delayed update — it is a broken feature.
That reframing paid off three times over.
It made remedies comparable. "Adding a silent push moves p90 from 72 hours to 30" is a sentence you can act on.
It defined what to give up on. Forcing an expensive regeneration at every launch on every device, purely to protect p90, costs battery and perceived speed. Percentiles let you draw the line.
Most importantly, it demoted background refresh from a feature to a best-effort auxiliary path. Once stated that way, the design itself insists that a primary path must exist elsewhere.
Rebuild into three layers that assume the job never runs
So I split the update path into three layers. The higher layers are reliable; the lower ones are merely pleasant.
Layer one is deterministic generation. When the widget builds its timeline, it decides the next several entries outright. Derive the selection index from the date and a per-device seed, and you need neither network nor background execution. Even if the refresh job never fires, tomorrow morning's image still changes.
func entryIndex(for date: Date, seed: UInt64, count: Int) -> Int { let day = Int(date.timeIntervalSince1970 / 86400) // Per-device seed offsets the sequence so users don't all see the same image var hasher = SplitMix64(state: seed &+ UInt64(day)) return Int(hasher.next() % UInt64(count))}
That single change eliminated the frozen-widget symptom. Background refresh shrank to one job — downloading new images to grow the candidate pool — and lost the responsibility of choosing today's image. Removing responsibility is what bought the reliability.
Layer two is opportunistic refresh. The moment the app comes to the foreground, read stalenessHours() and run a lightweight update if it exceeds the threshold. If the user opens the app at all, that is the most dependable execution opportunity you will ever get.
Layer three is background refresh and silent push. I treat this as strictly best effort. When it runs, freshness improves. When it does not, layer one is holding the floor.
Collecting logs is one thing; reviewing four apps' run histories every week is another. So I handed the aggregation and anomaly-flagging to an Antigravity agent.
What it receives is a daily JSON export of run history and a definition of the metrics. It computes p50 and p90 staleness, diffs against the prior week, breaks results down by bucket, and returns a short written assessment.
The boundary in AGENTS.md is almost comically plain.
## background-refresh-report- Input is `metrics/bg_runs/*.json` only. Do not read app source.- Output is exactly one file: `reports/bg_freshness_YYYY-MM-DD.md`- Always include p50/p90 staleness, run rate, and a per-bucket breakdown- If p90 regresses by more than 12 hours week-over-week, prefix the heading with ⚠- **Never modify thresholds, schedules, or code.**
That last line is the one that matters. The agent reports numbers and flags regressions. Whether to relax a threshold, thicken layer two, or invest in layer three is my call. Delegate that and the thresholds quietly loosen every time freshness degrades. My first prompt said "adjust settings if needed," and within two weeks the SLO had nearly been hollowed out.
Three months after the rebuild, p90 staleness sits comfortably under the 72-hour target, hovering around 40 hours. Nearly all of that gain came from layer one — from no longer letting background refresh decide today's image. The success rate of background execution itself has barely moved.
Rather than working to make an unreliable mechanism reliable, move the responsibility off it. Obvious once written down. I spent close to six months doing the opposite: adding retries, shifting scheduling moments, shortening earliestBeginDate and watching. None of it moved the numbers.
Next time I meet a design like this, I will reverse the order. First, find the paths where failure leaves no record. Then count the responsibilities riding on those paths. If there are too many, move them to a layer that always runs. Raising the execution rate comes last, if at all.
If you have a periodic job in an app today, there is one thing worth doing before anything else. Persist the timestamp of the last successful run, and read the elapsed hours at launch. That number alone will show you what your design has quietly been assuming.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.