The first time I built a WidgetKit widget, I expected it to work like a small view that re-renders when data changes. That's not how it works. Once I understood the actual model, everything clicked — and I stopped wasting time wondering why my widget wasn't updating.
The actual mental model
A widget is a timeline of pre-rendered snapshots. Your TimelineProvider runs in a separate process, generates an array of TimelineEntry values, and the system renders each entry at the specified time. The widget doesn't "poll" — it displays a pre-rendered image that the system swaps on a schedule.
This means your widget's view code runs at timeline generation time, not at display time. The system takes a snapshot of the rendered view and shows that image. Your SwiftUI code is not running when the user looks at their home screen.
TimelineProvider in practice
struct Provider: TimelineProvider {
func getTimeline(
in context: Context,
completion: @escaping (Timeline<SimpleEntry>) -> Void
) {
// Generate entries for the next few hours
let now = Date()
let entries = (0..<6).map { hour in
SimpleEntry(date: now.addingTimeInterval(Double(hour) * 3600))
}
// Tell the system when to ask for a new timeline
let nextUpdate = now.addingTimeInterval(6 * 3600)
let timeline = Timeline(entries: entries, policy: .after(nextUpdate))
completion(timeline)
}
}
The TimelineReloadPolicy controls when the system regenerates your timeline:
.atEnd— regenerate after the last entry's date passes.after(date)— regenerate at a specific future time.never— don't regenerate until the app explicitly requests it
For a budget widget showing monthly spend, .never is the right choice — you regenerate from the app when the user logs a new expense. For a time-based widget like a countdown, .after(date) lets you schedule the next refresh for when the data will change.
App Groups are non-negotiable
Your widget extension runs in a separate process from your app. It cannot access your app's UserDefaults, Core Data store, or any file in your main app container. Everything the widget needs must go through an App Group.
// In your app, after any data change:
let defaults = UserDefaults(suiteName: "group.com.yourapp.shared")
defaults?.set(totalSpend, forKey: "currentMonthSpend")
WidgetCenter.shared.reloadAllTimelines()
// In your widget's TimelineProvider:
let defaults = UserDefaults(suiteName: "group.com.yourapp.shared")
let spend = defaults?.double(forKey: "currentMonthSpend") ?? 0
@AppStorage works fine within the widget for data stored in the shared suite. What won't work is @AppStorage referencing the default suite — that's your app's private container, not visible to the extension.
The simulator lies
Widget timeline reloads don't fire on schedule in the simulator. The widget will appear to never update. Test on a physical device.
Also: when you're iterating on widget UI, use the Xcode widget preview target — it renders quickly without going through the full timeline machinery. But for testing actual update behavior, there's no substitute for a device.
Update budget
WidgetKit gives each widget a daily budget of timeline reloads — roughly 40–70 per day depending on how often the user views the widget. The system learns usage patterns and prioritizes refresh budget for widgets the user actually looks at.
If you call WidgetCenter.shared.reloadAllTimelines() on every small data change, you'll burn through the budget quickly. Batch your updates. A good pattern for Pocky: debounce the reload call with a 2-second window so rapid expense entries only trigger one reload.
The key insight is that the system is in control of when timelines actually regenerate — your reload request is a hint, not a command. If you're out of budget, the request is queued.