Flutter Home Widgets on iOS: Background Refresh Didn't Complete Once in Four and a Half Days
The one hour I declared in Dart never reached iOS. A locked phone couldn't read the token, so background refresh got no data. After fixing both, it went four and a half days on my phone without completing once. In the end I had the widget compute net worth itself.
Flutter Home Widget Traps · Part 2 of 2 | Part 1: Flutter Home Widgets: All Tests Green, the Agent Said It Was Fine, and the Number on the Home Screen Was Still Wrong

Introduction: first make it run, then make it update#
Part 1 was about whether the number on the home screen is right. This post is the other half: does it update, and on iOS, who decides when.
If you haven’t read Part 1, a few things to know first:
- The widget runs in a separate process with no Dart VM and can’t read the app’s database. The only link is a piece of shared storage (an App Group on iOS): the app writes strings that are already computed, and the native side only lays them out
- Background refresh: the app’s own background code,
BGAppRefreshTaskon iOS and WorkManager on Android - Reload: asking WidgetKit for a new timeline, which makes the widget read its data again
- Redraw: the system drawing an entry already scheduled on the timeline, with nobody needing to wake up
So the number on the widget only changes when “the app writes new data, and the system is willing to redraw.” Every trap in this post is about the second half of that sentence.
When I found these traps, the project had more than 1,300 tests, all green, and four review agents had looked at it. Most of the traps were dug out of device logs on a real iPhone, where tests and reviews can’t reach.
What this post helps you avoid
- A widget that has never launched on iPhone, while everything looks fine on the simulator
- Burning through iOS’s update budget so the widget stops updating entirely, while the timeline is scheduled fifteen hours ahead
- The one-hour schedule you declared in Dart never reaching iOS at all
- A locked phone that can’t read the keychain, so background refresh never works when it’s supposed to, and a setting that can’t be read falls back to a default that gets quietly written into the history
- A user taps sign out, the old token stays in the keychain, and the next cold start signs them right back in
- Background refresh, once fixed, going four and a half days on my phone without completing once, with no system log that says which rule blocked it
- One holding with no price, and a widget that refuses to compute anything, leaving the whole net worth frozen

1. The bugs that kept the widget from running at all#
Part 1’s two traps shared a premise: the widget was running, it just showed the wrong thing. Before background updates, here’s what the real device showed me: the feature wasn’t running at all.
From the day it was added, it never worked on iOS.
The widget extension imports only Foundation, SwiftUI and WidgetKit. But its build configuration inherited Pods’ OTHER_LDFLAGS from the project’s base configuration, so the appex (the executable inside the app bundle that the system launches on its own) linked 57 plugin frameworks. Those live in Runner.app/Frameworks, which wasn’t on the extension’s rpath. dyld killed it before launch every time:
Library not loaded: @rpath/<Plugin>.framework/<Plugin>
Referenced from: Runner.app/PlugIns/<Widget>.appex/<Widget>
WidgetKit then reported descriptors for 0 extensions, and the widget never appeared in the system widget picker. The simulator build succeeded, the appex was embedded, the App Group resolved. All correct.
The app itself died a few seconds after every cold start. AppDelegate never called workmanager’s native registration, so the request from Dart hit a BGTaskScheduler with no registered handler and threw an uncaught NSInternalInconsistencyException.
And two that don’t even leave a crash report.
Writing to an App Group you’re not entitled to doesn’t throw. One letter off in the identifier, or the capability checked on the extension but not the app, and UserDefaults quietly writes into a container that doesn’t exist; the widget reads nothing. The second is easy to do: when you create the extension’s App ID in the Apple Developer portal you naturally check App Groups, but the existing app’s App ID has to be updated separately.
Changing a capability invalidates existing provisioning profiles, but Apple doesn’t update their entitlements. Downloading again gets you the old one. You have to regenerate it: open the profile in the developer portal, click Edit and save, or delete it and let Xcode’s automatic signing rebuild it. Miss this and it builds, ships and passes review, and only the widget on the user’s home screen is empty.

None of these produce an error, so you can’t accept the work by looking for red text. You have to confirm each one on a real device; every step in section 8 says how.
2. Trap 3: betting freshness on reloads instead of the timeline#
iOS has two gates on widget updates, and neither is yours: iOS decides whether background refresh runs, and WidgetKit decides whether your reload counts.
The key difference is foreground versus background. A reload sent while the app is in the foreground isn’t counted against a budget; Apple’s documentation says so, and on device it updated almost immediately. One requested from the background comes out of a daily budget, and the system log says so plainly:
externalRequest(reason: WidgetCenterServer)-immediate-budgeted-1
Past the budget, it isn’t delayed; it’s cut off. chronod (the system process that schedules widget updates) starts writing Ignoring low-priority task, and the widget stops updating entirely. One version of mine sent a reload on every background refresh and hit exactly this on device.
How big is the budget? Apple gives a reference figure in Keeping a widget up to date:
- A frequently viewed widget gets roughly 40 to 70 a day, about one every 15 to 60 minutes
- The budget is per widget instance, not per app
- The 24-hour window adapts to the user’s habits and isn’t guaranteed to reset at midnight

The natural reaction is a guard: don’t reload if the screen won’t change. Two things to watch: compare against what’s actually stored in shared storage, because every background wake is a new isolate and nothing in memory survives it; and save reloads, never writes, because the widget decides whether it’s stale from the timestamp in the payload.
But however right the guard is, it only saves something that shouldn’t be the main mechanism anyway.
The thing to schedule is the timeline#
The timeline at the time had two entries, one now and one at the next local midnight, with policy .after(midnight). Looked at 9 a.m., that tells WidgetKit: come back in fifteen hours. Compiling the scheduling function on its own and feeding it fixed times gave worst redraw gap: 900 min.
The only thing in between that could change the screen was a reload request that cost budget every time. All the freshness rested on requests that cost budget, while the timeline, the one thing I could schedule myself, was scheduled fifteen hours out.
So now:
static let freshnessInterval: TimeInterval = 60 * 60 // ask for new data every hour
static let redrawInterval: TimeInterval = 15 * 60 // redraw every 15 minutes inside the window
“Every hour” works because getTimeline runs in the widget’s own process and rereads the App Group. Whatever the last background refresh wrote gets picked up the next time a timeline is requested, whether or not its reload got through. Twenty-four a day leaves plenty of room in a 40-to-70 budget. The window is cut off at local midnight, because a daily figure can’t outlive the day it describes.

Rule: The timeline is the only mechanism you schedule yourself; reload requests aren’t. Build freshness on the timeline and save the budget for when something really happens. The resource you think you’re saving may not be the bottleneck at all.
3. Trap 4: the hour you declared never reached iOS#
Dart says it clearly:
const refreshInterval = Duration(hours: 1);
Android’s WorkManager schedules by that value. iOS never sees it.
workmanager_apple (0.9.10) maps only initialDelay to BGAppRefreshTaskRequest.earliestBeginDate and ignores frequency. The registration didn’t pass initialDelay, which defaults to zero. Device log, submitted at 22:21:40 local time:
submitTaskRequest: <BGAppRefreshTaskRequest: <task identifier>,
earliestBeginDate: 2026-08-28 14:21:40 +0000>
earliestBeginDate is the moment of submission; two measured runs were eight minutes apart. And every app launch registers again, resetting the rhythm to zero.

The fix is to give one hour explicitly on both sides: pass initialDelay when registering from Dart, and earliestBeginInSeconds: 3600 to WorkmanagerPlugin.registerPeriodicTask in AppDelegate, because the handler uses the native value when it resubmits. The side effect: every cold start submits a fresh “not for an hour,” so the more often someone opens the app, the further background refresh gets pushed back. Fortunately those users get fresh numbers from the foreground publish anyway.
A few more things specific to iOS:
- If the user swipes the app away, this feature stops. iOS won’t relaunch a force-quit app for a
BGAppRefreshTask - The chain resubmits itself. The next request is submitted inside this run’s handler; if this run never happens, you wait until the user opens the app
When time is up, iOS won’t wait for your finally. A BGAppRefreshTask gets at most thirty seconds. My background refresh fetched prices first (a twenty-second budget) and republished the widget in a finally. But workmanager_apple’s expiration handler only logs a line; it neither cancels the work nor calls setTaskCompleted, so iOS simply kills the process. When the process is killed, finally doesn’t run: it guarantees exceptions, not death.
The symptom on device: the snapshot was written, but the payload on the home screen was seven and a half hours old. Three green tests sat next to this code, all with a refresh that returned immediately. The fake did for the real code what the real code couldn’t do on a device.
The fix is to put what’s cheap and must happen first: every wake publishes once, then refreshes, then publishes again.
Rule: The period a cross-platform scheduling package declares may be nothing more than a Dart constant on iOS. Trust what’s in the request actually submitted, which you can only read from the device log (and recheck after upgrading). Put what must happen first, not in
finallyhoping it gets to clean up.
4. Trap 5: a locked phone won’t give you the token, and won’t tell you#
This was the most expensive one, because it meant background refresh never did the one thing it exists for: updating the numbers while the phone sits in a pocket.
flutter_secure_storage (9.2.4) defaults IOSOptions to KeychainAccessibility.unlocked. While the phone is locked, every read is refused, and that’s exactly when background refresh runs.
The refusal is invisible. When the plugin’s first read gets nothing it retries with a synchronizable query, that retry returns errSecItemNotFound for an item withheld because the phone is locked, and that’s mapped to success-with-nil. Dart gets a plain null: no exception, no error code, no log. The token was in the keychain the whole time.

So background refresh called the API without an Authorization header and never once completed while locked; and the widget read that null as “signed out,” showing a signed-out screen to someone who never signed out.
Fix 1: change accessibility. Use first_unlock_this_device: readable after the first unlock since boot, and never carried to another device through a backup.
But accessibility is fixed when the item is written. Items already in the keychain keep the old setting until they’re rewritten, so after you ship, nothing changes for existing users; items written only at sign-in stay stuck the longest. So add a migration: on launch, rewrite items still under the old setting with the new one, and quietly give up if the phone is locked, without blocking launch.
Fix 2: null doesn’t mean signed out. There are many other reasons a token read comes back empty. Now a wake that can’t see the token is recorded as skipped, and the screen keeps the last good payload. A real sign-out still reaches the widget immediately, because the foreground publishes from the real auth state.
Second symptom: falling back to a default. Settings stored in the keychain can’t be read either. When the base currency couldn’t be read, the code fell back to the default, so a user with another currency got a wrong snapshot computed while locked and written into the history. That’s worse than one wrong display: the snapshot becomes the baseline for later daily figures, and it doesn’t fix itself.
Third symptom: after sign-out, the old token is still there. In flutter_secure_storage 9.2.4, reads ignore accessibility, but deletes and readAll queries include it. Deleting an item written under the old setting with the new one matches nothing, reports success, and leaves the item in place. The user signs out, and the next cold start signs them back in. The fix is to delete under both settings, with a test pinning “written under the old setting, deleted under the new.”
Rule:
nullmeans “I don’t know”, not “nothing there” and not “the default”. For any read that crosses a lock state, a process boundary or a permission boundary, keep “couldn’t read” apart from “definitely nothing”: the first skips this round, only the second may change the screen. A value that goes into the history must never be guessed. After changing a storage attribute, delete under the old attribute too.
5. Trap 6: background refresh didn’t complete once in four and a half days#
With all that fixed, at 7:38 a.m. on September 24, background refresh ran end to end on a real device: fetched prices, wrote a snapshot, republished the widget.
At 8:57 p.m. on September 28, the last completion was still 9/24 07:38. Four days and thirteen hours without completing once, while I opened the app as usual.
I pulled 24 hours of system logs off the phone (sudo log collect --device-udid <udid> --last 24h) and filtered for what dasd, the iOS process that decides when background tasks run, wrote about this task:
- 2,751 scoring records, every one concluding “don’t run”; the task never started once
Application Policywas 0.00 in every recordLow Power Mode Policywas 0 in 44%, andEnergy Budget Policywas 0 in 98%
What the log can’t prove is which rule kept the task from running. Every record had several rules at 0, and Apple doesn’t publish how they combine. All BGAppRefreshTaskRequest lets you set is the earliest start time; nothing asks it to run more often.
So I stopped guessing and looked at the outcome: on my phone, background refresh can go days without completing, and nothing on the app side changes that. The only thing left that I could schedule was the timeline from section 2, which runs in the widget’s own process without the app waking up. But when the widget woke, it had no new number to draw.
So I had it compute one itself.
On every foreground publish, the app also writes valuation inputs (each wallet’s balance, holdings’ quantities and symbols, no token). When a timeline is requested and the number is over 15 minutes old, the widget reads the app’s token in place through a keychain group, calls the same two APIs the app uses for prices and exchange rates, computes net worth with the app’s rules, and formats it the way Dart’s NumberFormat does.

This breaks Part 1’s rule that the native side stays deliberately dumb. I accepted it because nothing else could move the widget forward for an app nobody opens. The cost is paid in tests: the Swift valuation and the Dart refresh pipeline run against the same fixture.
The result: on the same phone that went four and a half days without a background refresh, the widget valued itself 14 times on 9/30 and succeeded 12 times.
Two programs guessing wrong together#
The most expensive mistake here was in my picture of Dio. The app lets Dio build the exchange-rate API’s query string; the widget had to build its own. I wrote list parameters as key[]=a&key[]=b, because “that’s probably how Dio encodes lists.” It’s axios’s default, and PHP and Rails backends expect it too. The Swift test’s expected value and the local mock server were written from the same picture.
All green. First call to the real backend: 422. Dio’s default repeats the key: key=a&key=b. Three places shared one wrong assumption, so no local check had any chance to fail. When one author, human or agent, writes the implementation, the test and the mock in one go, all three share the same blind spot.
![I assumed Dio encodes a list as key[]=a&key[]=b; the Swift side, the probe's expected values and the mock all followed that and were green; Dio actually repeats the key, and the real backend returned 422](https://images.weserv.nl/?url=res.cloudinary.com/dgesxdqgx/image/upload/v1793464475/articles/flutter-home-widget-traps/images/dio-query-en.png&w=1400&we&output=webp)
Now Dio writes the expected value: a Dart test has RequestOptions(...).uri.query build the query string, and compares it with the format on the Swift side.
Rule: When two implementations have to agree on a format, the expected value should come from the real source, not from memory on both sides. A mock you wrote only proves your assumptions agree with each other.
Where this approach stops#
- The widget can’t refresh the token itself. A refresh token is single-use; if the widget used it, the app’s would be invalidated. So the app refreshes on open whenever the token is close to expiring; people who open the app regularly won’t get stuck, but those who stay away long enough still will
- Users who haven’t opened the new version get nothing. Valuation inputs are written by the new app in the foreground
- History needs filling separately. The widget only displays and never touches the app’s database, so days without opening the app leave no history. The widget keeps each day’s last value, and the app fills in only days with no snapshot at all
Rule: Don’t bet “update even when the app isn’t opened” on
BGAppRefreshTask. When it runs is up to the system. For a reliable rhythm, have the widget fetch data on its own timeline, or push from a server (iOS 26’sWidgetPushHandler, which also has a budget).
6. When the device tells you nothing#
These traps took three rounds of diagnosis, each reasoning from source alone, because the release build’s background isolate logs nothing. “iOS never woke it” and “it woke and returned early” look identical on the home screen. None of the three rounds could answer the one question that decided the next step: did this task ever fire?
In the fourth round I built the output first: every wake leaves a marker in the App Group, next to the payload, not inside it, because the paths worth telling apart are exactly the ones that don’t publish a payload. A dev build’s widget draws the marker, so a phone can answer the question by itself.
Render every state on the simulator. The simulator can’t prove scheduling or locking, but it can prove what the screen looks like. I used ImageRenderer to render the widget’s real SwiftUI view in every state, twenty images, and two things jumped out: with no payload ever arriving it drew a blank box, and the diagnostic marker only appeared on the “data OK” screen, missing exactly when it was needed. Test diagnostic output in the worst state.
Put a date on the time. Background refresh can skip days, and a marker with only hours and minutes makes bg 07:38 look like this morning when it was four days ago.

Rule: If a failure leaves no output on the device, build the output first; don’t start by guessing the cause. What took me three rounds of reasoning, one screenshot settled in the fourth.
7. Honesty can’t depend on freshness; it has to be written into the data#
Only after sections 2 through 5 were the layers in the right order:

I started with it exactly upside down: the timeline only reached midnight, and all freshness rested on the bottom two layers.
And however you order them, someday a user will look at that little box when the last update was three days ago. What saves you then isn’t a refresh mechanism; it’s the time fields the payload carries: when it was updated, and which day its figures describe.
This is a classification problem, not a mechanism problem. Refresh layers handle “as fresh as possible,” which is performance; the time fields handle “never lie even when stale,” which is correctness. I treated them as one thing, so every problem got another layer, while the answer was in the payload all along.
When only part of the data is missing#
A widget that combines several sources will one day find one missing, say a holding with no price this time.
Computing nothing sounds rigorous, but it turns “one piece is missing” into “I know nothing”: the whole net worth freezes on an old number, and the user sees only “Data may be outdated” with no reason. Better to carry the last value forward for the missing piece, compute the rest, and note “some prices not updated” underneath.
The exception is an input whose error spreads. A stale price keeps the error in one holding; a wrong exchange rate puts every holding in that currency off. So a missing exchange rate still means computing nothing.

Say exactly which part is missing.
8. Do it in this order, and how to check each step#
Here are both posts as an implementation order, with how to confirm each step really works.
- Get Xcode right before writing any UI. Set the extension’s
OTHER_LDFLAGSon its own, not$(inherited); check App Groups on both the app and the extension; regenerate the profile after any capability change; haveAppDelegatecallWorkmanagerPlugin.registerPeriodicTask, with the identifier underBGTaskSchedulerPermittedIdentifiersin Info.plist. Give each flavor its own App Group. Check:otool -Lshows the appex links 0@rpathframeworks; the widget appears in the widget picker; archive once with release signing and install it on a phone (section 1) - Write a pure Dart function that produces the payload. All formatting, translation and state decisions happen here, written to shared storage as one JSON bundle of strings (Part 1, section 2)
- The native side only lays out, plus two kinds of checks. Read the clock at draw time: hide the daily change after midnight, mark data as possibly outdated after a day. Read the width: switch to a short form only if the number doesn’t fit (Part 1, sections 3 and 5)
- Make the timeline the main mechanism. New data every hour, an entry every 15 minutes, cut off at local midnight (section 2)
- Publish while the app is in the foreground. Foreground reloads aren’t counted; always write the data, and if you save anything, save reloads (section 2)
- Treat background refresh as a bonus. On iOS give
initialDelayexplicitly on both sides; put what must happen before network requests. Check: leave the phone locked for a day without opening the app and count how many times background refresh actually ran (sections 3 and 5) - Set the keychain to
first_unlock_this_deviceexplicitly. Treat everynullread in the background as “I don’t know”; delete under the old setting too. Check: background refresh has really completed while locked; signing out and cold-starting doesn’t sign you back in (section 4) - When data is incomplete, say so. The payload carries an update time and which day its figures describe; whatever part is missing, the screen says so (Part 1, sections 3 and 4; section 7)
- Build diagnostic output first. A dev build’s widget shows which branch each wake took, with a date, visible even with no payload. Check: render every state with
ImageRenderer, including the one with no data yet (section 6)
Reading device logs: in Console.app, filter on chronod, dasd and BGTaskScheduler; for a whole day of dasd scoring, pull the log with sudo log collect --device-udid and filter with log show.
Two small things at release: the extension ships inside the app bundle and needs no separate App Store Connect record; and don’t mention Android in the iOS “What’s New” notes. We wrote “works on both iPhone and Android” and were rejected once under Guideline 2.3.10.
Conclusion#
As in Part 1, none of these traps break the build. An App Group checked on one side doesn’t throw, a locked keychain returns only a null, a fallback default still writes into the database, and a profile missing an entitlement still ships.
Measure before you save. I worked to save the reload budget, while the real problem was a timeline scheduled fifteen hours ahead.
The most reliable schedule may not be in your app. I bet on reloads, then on background refresh, and lost both times to the same thing: iOS decides when you wake up, and doesn’t tell you why. In the end the thing I could schedule myself was the widget’s own timeline.
Say exactly which part is missing. Freezing everything is a kind of dishonesty too.
Tests and reviews can prove the code does what you meant. They can’t prove whether iOS will wake it, or whether a locked phone will hand it the token. Only a phone can: leave it a day without opening the app, then read the logs.
References#
- Apple — Keeping a widget up to date: budget figures, exemptions and the timeline reload policy
- Apple — WidgetKit: the timeline provider and entry model
- Apple — Configuring App Groups: both the app and the extension need it
- Apple — Sharing access to keychain items among a collection of apps: the keychain group the widget uses to read the app’s token
- Apple — Updating widgets with WidgetKit push notifications: server pushes that make WidgetKit fetch a new timeline