◉ANTIGRAVITY LABJP
Articles/App Development
▣ App Development/2026-07-09Advanced

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.

BGTaskSchedulerWorkManagerbackground processingAntigravity377app architecture

✦ Premium Article

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 Doze, App Standby Bucket, OEM-specific optimizations
After reboot 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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

Related Articles

▣ App Dev2026-09-29
Purging Cloudflare Didn't Bring Back the New Wallpapers: the Stale JPEGs Were Held by XPageSpeed on the Origin
An operations record from a wallpaper app: images overwritten at the same URL kept arriving stale even after a Cloudflare purge. Covers the three-way local/origin/edge probe that names the guilty layer, when prefix purge beats per-URL purge, and how to switch off Xserver's XPageSpeed in .htaccess without a 500.
▣ App Dev2026-09-19
I Asked for the Whole Quote PDF Tool at Once, Then Split It Into Four Requests
How I moved my client quote paperwork from copy-and-edit to a small PDF tool built with Antigravity, why the first single-shot request had to be thrown away, and the four smaller requests that replaced it, with working code.
▣ App Dev2026-09-03
A dependency without musllinux wheels doesn't fail on Alpine. It just gets slower
On Alpine, a dependency without musllinux wheels can quietly fall back to pure Python. Protobuf went from 0.043ms to 36.2ms, and pip reported no error.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links