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 processingAntigravity316app architecture2

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 $10 for lifetime access
View Membership →

Related Articles

App Dev2026-07-06
Building a Live Wallpaper That Doesn't Drain the Battery — Designing the WallpaperService Render Loop Around a Power Budget
Live wallpapers quietly burn battery because they keep drawing when nothing is visible. Here is the WallpaperService.Engine design I used to cut power draw by roughly 60% in a real wallpaper app, built around visibility, idle, and thermal gates.
App Dev2026-07-05
Keep the Self-Debugging Agent Away From Live Ads: Three Layers Against AdMob Invalid Traffic
When Antigravity 2.0's real-browser self-debug renders a live AdMob unit, every pass counts as an impression, and Google may read it as invalid traffic. Here is a three-layer setup, with measurements, that keeps the agent from ever touching a production ad.
App Dev2026-07-04
Paid, but the Ads Won't Go Away — Closing a StoreKit 2 Transaction.updates Launch Race with Antigravity
How I traced a StoreKit 2 launch race that dropped transactions arriving right after startup, pinned the listener to the app's lifetime instead of a view's, reconciled with currentEntitlements, and let Antigravity turn it into a StoreKitTest regression suite.
📚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
See all →