Craft · WidgetKit

A WidgetKit timeline that stays fresh inside Apple's budget

Every widget I've shipped started with the same wrong instinct: refresh it when the data changes, on a timer, like a tiny app. WidgetKit doesn't work that way, and once that clicked, my widgets stopped getting throttled into uselessness. A widget is not live. You hand the system a schedule of pre-rendered entries and walk away.

That sentence is the whole article, really. But it took me a couple of frustrated releases to internalise it, so let me unpack what it means in practice — the TimelineProvider trio, the reload policies, the budget you're quietly spending, and how to design a timeline that animates through the next few hours without your code ever waking up.

The provider is a render schedule, not a data source

A WidgetKit extension implements one protocol, TimelineProvider (or AppIntentTimelineProvider if it's configurable), and it has three methods. The mental model gets a lot clearer if you read them as three different audiences asking "what does this widget look like?"

  • placeholder(in:) — the system, asking for a skeleton it can show instantly, with no real data, while it figures out the rest. This is the redacted shape you see in the widget gallery before anything loads.
  • snapshot(in:) — the gallery and transient states, asking "what does one representative entry look like, right now?" It must return fast, so you give it a single entry with whatever data is cheap to reach for.
  • timeline(in:) — the real work. You return a Timeline: an array of entries, each stamped with the date it should appear on screen, plus a policy that tells WidgetKit when to come back and ask again.

The thing that trips people up is that last method. getTimeline is not "give me the current state." It's "give me a schedule of future states." Each TimelineEntry carries a date, and the system flips the rendered view to that entry when the clock reaches that date — your extension is long since suspended by then. You're not pushing updates; you're pre-baking them.

Why polling is simply not an option

Here's the constraint that forces the whole design. WidgetKit gives each widget a reload budget — a soft daily allowance of times it will wake your extension to fetch a new timeline. The exact number isn't published and it flexes with how often the user actually looks at the widget, but the working figure I plan around is on the order of 40 to 70 wake-ups a day. That's it. Spread across 24 hours, that's a refresh every 20–35 minutes at best.

So a timer that polls your API every minute is dead on arrival. You'd blow the entire day's budget before lunch, and WidgetKit's response to a spendthrift extension is to quietly stop honouring your reload requests. The widget goes stale, you can't reproduce it on your own device because you open the app constantly (which earns budget back), and you ship a bug you can't see. I've done exactly this. It's miserable.

The trap

The budget is invisible in development. You launch from Xcode, you open the host app every few minutes, and the system rewards that engagement with generous reloads. Your widget looks perfectly fresh. On a real user's phone — where the app sits untouched for hours — the same widget starves. Test by installing a build, then leaving the phone alone overnight.

The escape is to stop thinking of a reload as "fetch the data" and start thinking of it as "fetch enough data to cover the next several hours." One wake-up should produce many entries.

Pre-computing a window of entries

This is the move that makes WidgetKit pleasant. If your data is predictable for a while — a countdown, a meeting agenda, a medication schedule, the day's tide times, anything that unfolds on a known clock — you compute all of it in a single getTimeline call and return one entry per moment you want the display to change.

Say a widget counts down to an event and you want it to tick visibly. You don't reload every minute. You build, in one pass, sixty entries spaced a minute apart, each pre-rendering the countdown at that minute. The system stores the lot, and for the next hour it advances the widget through them on its own — your extension stays asleep the entire time, and you've spent exactly one reload from the budget.

import WidgetKit
import SwiftUI

struct CountdownEntry: TimelineEntry {
    let date: Date          // when WidgetKit should show this entry
    let remaining: TimeInterval
}

struct CountdownProvider: TimelineProvider {

    func placeholder(in context: Context) -> CountdownEntry {
        CountdownEntry(date: .now, remaining: 3600)
    }

    func snapshot(in context: Context,
                  completion: @escaping (CountdownEntry) -> Void) {
        // Cheap and synchronous — the gallery is waiting on this.
        completion(CountdownEntry(date: .now, remaining: remainingFromCache()))
    }

    func timeline(in context: Context,
                  completion: @escaping (Timeline<CountdownEntry>) -> Void) {
        let target = EventStore.nextEventDate()
        let now = Date()

        // One entry per minute for the next hour — pre-rendered, no further wakes.
        var entries: [CountdownEntry] = []
        for minute in 0..<60 {
            let tick = Calendar.current.date(byAdding: .minute,
                                             value: minute, to: now)!
            guard tick < target else { break }
            entries.append(CountdownEntry(date: tick,
                                          remaining: target.timeIntervalSince(tick)))
        }

        // Come back at the end of the window and rebuild the next hour.
        let refresh = entries.last?.date ?? now.addingTimeInterval(3600)
        completion(Timeline(entries: entries, policy: .after(refresh)))
    }
}

Notice what that for loop bought us: the widget animates through sixty distinct states on one wake-up. The cost to the budget is a single reload per hour — roughly 24 a day, comfortably under the ceiling, with room to spare for the times something real changes.

Choosing the reload policy

The second argument to Timeline is a TimelineReloadPolicy, and it answers exactly one question: once the system reaches the last entry, when should it wake you for a fresh timeline? There are three, and picking the wrong one is the difference between fresh and frozen.

  • .atEnd — reload as soon as the final entry's date passes. Use it when you genuinely can't predict the next window until the current one runs out. It's the most eager of the three, so reach for it sparingly.
  • .after(date) — don't even consider reloading until this date. This is the one I use most. It lets you say "I've covered the next hour; come back then," which is precisely the contract that keeps you inside budget. Pad it slightly past your last entry so you're not racing the clock.
  • .never — never reload on your own; the timeline is final until something external tells WidgetKit otherwise. This sounds useless but it's the right answer surprisingly often: a widget whose only meaningful changes come from user action in the app should sit perfectly still until the app pokes it.

One subtlety: a policy is a request, not a guarantee. WidgetKit treats .after(date) as "no earlier than," and it may still wake you later than you asked if the user isn't engaging, or — within budget — a little around the requested time. Design for "roughly then," never "exactly then."

Pushing changes from the app with WidgetCenter

So how does a .never widget ever update? Or a countdown where the user just added a new event that should appear immediately? The answer lives in the host app, not the extension: WidgetCenter.

When something real changes — the user logs a workout, marks a task done, changes the tracked stock — the app writes the new state to the shared container (an App Group UserDefaults or a shared store) and then explicitly asks WidgetKit to rebuild the affected timelines.

// In the host app, right after the underlying data changes:
func userDidComplete(_ task: Task) {
    SharedStore.save(task)                       // App Group container
    WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
}

Always prefer reloadTimelines(ofKind:) over the blunt reloadAllTimelines() — you only want to spend budget on the widget that actually changed. And resist calling it on every keystroke or scroll. A reload triggered by a concrete, user-visible state change is exactly what the system wants; a reload fired in a loop is just polling wearing a different hat, and it'll get you throttled the same way.

Relevance and the Smart Stack

On the lock screen and in the Smart Stack, iOS decides which widget to surface at any moment, and you get a vote. Each TimelineEntry can carry a TimelineEntryRelevance with a score and an optional duration. The score is unitless and only meaningful relative to your other entries — a higher number says "this moment matters more, rotate me to the top."

let entry = CountdownEntry(date: tick,
                           remaining: target.timeIntervalSince(tick))
// Climb the stack as the event gets close.
entry.relevance = TimelineEntryRelevance(
    score: remaining < 600 ? 90 : 20,           // last 10 minutes: surface me
    duration: 600
)

The payoff is that your widget appears on its own — the boarding-pass widget rises an hour before the flight, the workout summary surfaces in the evening — without the user digging for it. It's a small touch that, in the apps where it fits, does more for perceived quality than almost anything else in the extension.

Keep the snapshot honest and fast

A last practical note that costs people review time. snapshot(in:) is on the critical path for the widget gallery and for previews, and the system expects it back quickly. Do not make a network call there. Read from your shared cache, and if there's nothing cached, return believable placeholder data — context.isPreview tells you when you're being asked purely for display. Heavy or slow snapshots make the whole add-widget experience feel broken, and that's the first thing a user sees.

The reframe that finally made WidgetKit make sense to me was to stop fighting the budget and start designing within it. A widget isn't a window onto live data; it's a short film you render in advance and let the system play back. Compute a generous window of entries in one pass, pick .after so you only wake when the window runs dry, push from the app with reloadTimelines(ofKind:) when reality actually shifts, and lean on relevance to show up at the right moment. Do that and the widget stays fresh on a phone you haven't touched all day — which is the only place it has to work.

← All writing