close

DEV Community

Amol Srivastava
Amol Srivastava

Posted on

Building Home Screen Widgets Without a Backend: A WidgetKit Field Guide

WidgetKit is one of the more deceptively simple frameworks Apple ships. The sample code makes it look like you just declare a TimelineProvider, return some entries, and you're done. The reality, once you're building a widget that has to be useful and not just a demo, is a lot more interesting — mostly because a widget process is not your app process, and everything about how you architect data has to respect that.

The widget extension is a stranger to your app

A widget extension runs out-of-process from your main app. It doesn't share memory, it doesn't share your ObservableObjects, and it has a much stricter memory ceiling than your app does — a few tens of megabytes before the system kills it. This means the first design decision on any widget project isn't "what does it look like," it's "how does data get from the app to the extension without a server in between."

For anything local-first, the answer is almost always an App Group. You configure a shared container, both targets get access to it, and you write to it from the app and read from it in the extension:

let store = UserDefaults(suiteName: "group.com.example.myapp")
store?.set(encodedSnapshot, forKey: "widgetSnapshot")
Enter fullscreen mode Exit fullscreen mode

The snapshot should be small and pre-computed. Don't hand the widget your entire model graph and expect it to compute a summary — do the summarizing in the app, where you have memory and time to spare, and hand the widget exactly the bytes it needs to render.

Timelines are a scheduling problem, not a data problem

The part that trips people up is TimelineProvider.getTimeline. It's tempting to think of it as "fetch the current data," but it's actually "describe the next N states of the world and when each one becomes current." If your widget shows a countdown, you don't refresh every minute — you generate entries for every relevant minute up front and let the system swap them in on schedule, all without waking your process again.

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
    let now = Date()
    let entries = (0..<60).map { minuteOffset in
        Entry(date: Calendar.current.date(byAdding: .minute, value: minuteOffset, to: now)!)
    }
    completion(Timeline(entries: entries, policy: .after(entries.last!.date)))
}
Enter fullscreen mode Exit fullscreen mode

This reframing matters because widget refresh budget is a scarce, system-managed resource. iOS decides how often your extension actually gets to run based on how often the user looks at the widget, battery state, and a bunch of heuristics you don't get to see. Front-loading state changes into a single timeline computation works with that constraint instead of fighting it.

Deep links are the only "interaction" you really get

Widgets aren't fully interactive in the way a normal view is — even with the newer interactive widget APIs, most of what you're building is still "tap here to open the app to this exact place." Design the widget's tap targets around widgetURL or Link destinations that map to specific in-app states, and make sure your app's URL handling can reconstruct that state without a network round trip:

.widgetURL(URL(string: "myapp://item/\(entry.itemID)"))
Enter fullscreen mode Exit fullscreen mode

The apps that get widgets right treat them as a second, constrained rendering surface for state that already exists locally — not a new feature that needs its own backend, its own sync layer, or its own source of truth. Once the shared container and the timeline are right, the actual SwiftUI view code is the easy 20%.

Top comments (0)