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:

ProviderDefault intervalReloads/day
ChatGPT/Codex30 s2,880
Claude60 s1,440
Kimi120 s720
OpenRouter300 s288
OpenAI600 s144
Anthropic, Grok, Copilot900 s each288
Total, all eight configured5,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:

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

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.