The widget that froze because it refreshed too often
2 September 2026 · ipstyle · Deutsch
WidgetKit gives a widget a small daily allowance of timeline reloads. Spend it and the system stops honouring your reload requests. There is no error, no log line, no callback, no API to ask how much is left. The tile simply stops moving, and everything about the app looks fine.
The fastest way to spend that allowance is a struct that looks entirely innocent.
The setup
The app polls eight AI providers and writes a snapshot of what it found into a shared App Group container. The widget extension reads that snapshot and renders it. The widget fetches nothing itself — on the Mac the app is running anyway, and a second fetch path would only be a second place where a token lives.
Two operations, very different costs. Writing the snapshot goes into a local file and is effectively free; asking WidgetKit to reload is not. So the writer was built to always write and only sometimes reload:
@discardableResult
public func schreib(nach vorgaben: UserDefaults? = AppGruppe.vorgaben) -> Bool {
guard let vorgaben, let daten = try? Self.kodierer.encode(self) else { return false }
let vorher = Self.lies(aus: vorgaben)
vorgaben.set(daten, forKey: Self.schluessel)
guard vorher?.fenster != fenster || vorher?.quellen != quellen else { return false }
WidgetCenter.shared.reloadAllTimelines()
return true
}
The version that shipped in 6.4. That guard is the entire cost control.
It never fired.
The field that made every snapshot unequal to itself
One row of the tile is a Quelle — one provider card, reduced to
finished display values:
public struct Quelle: Codable, Sendable, Equatable {
public let name: String // "Claude", "ChatGPT/Codex"
public let anbieter: String
public let fenster: [Fenster]
public let wert: String // "5 h: 64 % · 7 d: 57 %"
public let kurz: String
public let prozent: Double?
public let warnung: Bool
/// When *these* numbers were fetched — per source, not global.
public let stand: Date
}
stand is display data: the tile writes “4 min ago” out of it, so it
has to be in the model and it has to survive JSON encoding. It is also a stored
property, which means Swift's synthesized == compares it. And every
producer fills it with a fetch timestamp:
stand: limits.fetchedAt // Claude
stand: snapshot.costs.fetchedAt // OpenAI
stand: xaiHealth.updated ?? Date() // Grok
So on every poll where a provider returned byte-identical numbers,
vorher?.quellen != quellen was still true. One Date had
moved. The guard let it through. Reload.
The arithmetic
Every successful fetch writes the snapshot, so every successful fetch was a reload. These are the shipped default intervals:
| Provider | Default interval | Reloads/day |
|---|---|---|
| ChatGPT/Codex | 30 s | 2,880 |
| Claude | 60 s | 1,440 |
| Kimi | 120 s | 720 |
| OpenRouter | 300 s | 288 |
| OpenAI | 600 s | 144 |
| Anthropic, Grok, Copilot | 900 s each | 288 |
| Total, all eight configured | 5,760 |
That number is arithmetic from the defaults, not a measurement. Fewer cards, a laptop that sleeps, a failing provider — all of it brings the figure down. Shortening the intervals, which the settings allow, brings it up. Either way it is three orders of magnitude away from where it needs to be.
What the budget actually is
Apple's WidgetKit documentation puts the typical allowance at 40 to 70 refreshes per day. That is the number the code comments quote, and it is the only figure I have.
What is not documented, and what I therefore cannot tell you:
- There is no counter to read and no notification when you are throttled.
- The docs do not spell out precisely how a reload requested by the containing
app through
WidgetCenteris accounted against system-scheduled timeline refreshes — whether it is the same pot, exactly. - I never watched a throttled device with an instrument, because there is nothing to watch. The frozen tile is the documented consequence of exceeding the allowance, and it is what the comparison predicts. It is not a measurement I made.
The order-of-magnitude argument survives all three caveats: thousands of requests a day against an allowance in the dozens is not a tuning problem.
How it was found
Not from a bug report, and not from the field. The Mac widgets were new in version 6.4, and 6.4 was pulled from review before it was ever released, so nobody outside this machine ran the broken version.
What found it was dull work: after cutting the release, reading everything the
release had newly introduced, file by file, and asking of every comparison what
exactly it compares. != between two structs is the kind of line the
eye slides over. It is worth stopping at, because Swift will happily synthesize a
comparison over fields you never meant to compare.
The part worth sitting with is the test. There already was one for this, and it was green:
func testUnveraenderteWerteStossenNichtAn() {
let original = beispiel()
XCTAssertTrue(original.schreib(nach: vorgaben))
let gleicheWerte = beispiel(erhoben: Date(timeIntervalSince1970: 1_788_000_000))
XCTAssertFalse(gleicheWerte.schreib(nach: vorgaben))
}
The helper beispiel(erhoben:) uses that single parameter for both
the snapshot time and every source's stand. Passing the same value
twice produced two byte-identical structs, and the test then asserted that
identical input compares equal. That is a fact about Equatable, not
about this code.
The case that mattered — same numbers, newer timestamp, which describes essentially every real poll — was the one case the helper could not express. A green test on the right function is not coverage of the right case.
The fix, and the one that was rejected
Three options.
Drop stand. No: the tile displays it, and an age
is the difference between a number you can trust and a number you cannot.
Write a custom == on Quelle that skips
stand. Tempting — one line, no new type. It also quietly
blinds a test you want to keep sharp. The snapshot travels through the App Group
as JSON, and the round-trip test catches a field lost in encoding by comparing
the decoded value against the original. If == ignores
stand, then a stand that fails to encode passes that
test, and nobody ever finds out.
An explicit projection of what the user can actually see. This is what shipped:
/// Everything that is on the tile — without `stand`.
struct Anzeige: Equatable {
let name: String
let anbieter: String
let fenster: [Fenster]
let wert: String
let kurz: String
let prozent: Double?
let warnung: Bool
}
var anzeige: Anzeige {
Anzeige(name: name, anbieter: anbieter, fenster: fenster, wert: wert,
kurz: kurz, prozent: prozent, warnung: warnung)
}
guard vorher?.fenster != fenster
|| vorher?.quellen.map(\.anzeige) != quellen.map(\.anzeige)
else { return false }
WidgetCenter.shared.reloadAllTimelines()
Equatable on Quelle stays complete. Two different
questions now get two different comparisons: is this the same value
(serialization) and does this look different on screen (reload).
The cost is real but small: adding a field to Quelle now forces a
decision about whether it belongs in Anzeige. That is the same
decision you already owed the reader of the code — is this display data or
bookkeeping — only now the compiler makes you write the answer down.
The tests that replaced the green one
func testNeuererZeitstempelAlleinStoesstNichtAn() {
XCTAssertTrue(beispiel().schreib(nach: vorgaben))
let spaeter = Date(timeIntervalSince1970: 1_788_003_600)
XCTAssertFalse(beispiel(erhoben: spaeter).schreib(nach: vorgaben),
"Only the fetch time is newer — the tile shows the same thing.")
XCTAssertEqual(WidgetZustand.lies(aus: vorgaben)?.erhoben, spaeter,
"It is still written, otherwise the age never advances.")
XCTAssertEqual(WidgetZustand.lies(aus: vorgaben)?.quellen.first?.stand, spaeter)
}
Two assertions in one test on purpose: the reload must not happen, and the write must. A guard that skips the write as well would trade a frozen tile for a tile that lies about its own age.
And the counter-test, because a comparison that lets nothing through is not progress either:
func testGeaenderterWertStoesstAn() {
XCTAssertTrue(beispiel().schreib(nach: vorgaben))
let spaeter = Date(timeIntervalSince1970: 1_788_003_600)
XCTAssertTrue(beispiel(erhoben: spaeter, prozent: 47).schreib(nach: vorgaben),
"42 % became 47 % — that belongs on the tile.")
}
What generalizes
- A struct that is both “what to show” and “when we got it” will be
unequal to itself on every poll. Fetch timestamps are the common
case; request ids, retry counters, ETags and trace ids have the same shape.
Synthesized
Equatableis a convenience, not a statement of intent — the moment it drives a decision, write the comparison you mean. - Look for limits that are enforced with silence. A rate limit that answers with an error teaches you it exists. WidgetKit's answers by doing nothing, and the symptom shows up in a place — a stale tile — that makes you suspect the fetch layer first.
- Write cheap, signal expensive. Keeping the two apart is the right shape. It only works if the condition on the expensive half is explicit.
- Shared code shares its defects. The same struct is used near-verbatim on iOS, so the iPhone widget had it too — and the watch complication, which reloaded on every hand-off across the WatchConnectivity bridge. One fix, three places to apply it, and the comment now says so in both trees.
The code above is from AI-Cockpit, a menu-bar app for macOS and an app for iPhone, iPad and Apple Watch that shows how much of your Claude, ChatGPT and API budgets is left. It is free on both App Stores.