Snapshot Storage for WidgetKit
I let widgets outside the app safely read the last display value and refresh state
A Hangangjari widget is not a smaller copy of an app screen. It runs in a different place, refreshes on a different cadence, and users trust its numbers much faster.
So the widget needed its own stored snapshot.
Before adding widgets, it was easy to think a good app cache would be enough. But a WidgetKit extension does not see the main app’s memory, and timeline refreshes do not happen when the user opens the app. Loading that feels natural in the app can look too late or too ambiguous in a widget. A widget should be treated as a surface running outside the app, not as a miniature app screen.
Widgets need separately stored display values
A WidgetKit extension does not share the main app’s memory. It does not simply hold the data the main app stored in SwiftData, and the network cannot be assumed to succeed immediately.
The Hangangjari widget had to:
- Show recent values without opening the app.
- Keep the last confirmed value when the network fails.
- Answer different questions for parking widgets and general park widgets.
- Deep link to the relevant park and screen when tapped.
- Avoid presenting stale information as current.
Depending on values held by the main app makes this fragile because of how extensions execute. So I placed an App Group snapshot between the app and the widget.
flowchart LR App["Main app"] --> Repo["Repository"] Repo --> SwiftData["SwiftData cache"] App --> Builder["Snapshot builder"] Builder --> AppGroup["App Group snapshot"] Widget["WidgetKit extension"] --> Loader["Widget entry loader"] Loader --> AppGroup Loader --> API["/v2 home-summary<br/>state fallback"] Loader --> Entry["Widget display entry"]
A snapshot keeps only values the widget shows
A snapshot is not copied DB rows. It is a small bundle of only the values the widget needs to display.
A parking snapshot contains values such as park ID, short park name, parking lot list, remaining spaces, capacity, availability level, confirmation time, stale cutoff, and directions URL. A general park snapshot contains values that help answer “Is this park worth visiting today?”, such as crowding, weather, fine dust, key signals, and forecast movement.
Even for the same park, a parking widget and a general widget ask different questions. So snapshot keys are separated by purpose.
| Snapshot | Question | Storage unit |
|---|---|---|
| Parking snapshot | Is it okay to drive there now? | Park + display mode |
| General snapshot | Is it okay to visit this park today? | Park + general focus |
| Legacy snapshot | Backward compatibility | Single parking snapshot |
This separation prevents one widget configuration from overwriting a snapshot used for another purpose.
What matters in a widget snapshot is not every field in a particular Swift struct. It is what question the snapshot answers. A parking widget answers “Can I bring the car?”, while a general park widget answers “Is this park okay today?”
Widgets read stored values first and call the API only when needed
The widget entry loader follows a simple order.
sequenceDiagram
autonumber
participant Widget as Widget provider
participant Loader as Widget entry loader
participant Snapshot as App Group snapshot
participant API as Hangangjari API
participant Builder as Snapshot builder
Widget->>Loader: Request timeline value
Loader->>Snapshot: Read stored value for this purpose
alt Snapshot is fresh and refresh is not forced
Snapshot-->>Loader: Return usable stored value
Loader-->>Widget: Return widget entry from stored value
else Stale or missing
Loader->>API: Request first-screen or status data
API-->>Loader: Return display read model
Loader->>Builder: Build widget display values
Builder-->>Snapshot: Store display values in shared area
Loader-->>Widget: Return widget entry from new response
end
The first step was separating what “fresh” means. A snapshot being recently stored and the inner data’s observed_at being recent are different things. Hangangjari separates them.
- Snapshot freshness: should the widget entry be rebuilt?
- Data freshness: is the displayed data recent enough to call current?
Even if a widget opens quickly from a cache hit, the UI must show stale state if the inner parking value is stale.
SwiftData and App Group have different jobs
SwiftData is the app’s internal read cache. It lets app screens reuse parks, lots, statuses, forecasts, and home summaries.
The App Group snapshot is a small display-value bundle shared with widgets. Because the extension must be able to read it reliably, it stores only the values needed on screen as a Codable payload.
Without this separation, widgets become too tightly coupled to app-internal data changes. But if the snapshot becomes too rich, it turns into a small duplicate DB beside the app cache.
In Hangangjari, I limited snapshots to “final display ingredients for building widget entries.”
Widget failure separates stale values from empty values
How a widget shows failure is directly tied to whether a user can trust its numbers.
| Situation | Display behavior |
|---|---|
| Fresh snapshot exists | Build entry from snapshot |
| Snapshot exists but is stale | Try network, then fall back with stale presentation if it fails |
| No snapshot exists | Try API, then show unavailable or placeholder if it fails |
| Nearby park selection cannot access location | Show unavailable with a location reason |
| API payload park mismatch | Treat as empty data |
The key is not hiding failure. A widget is a small screen, so it is especially easy to create false confidence.
Because small screens simplify presentation, hiding stale state is more dangerous. A user may act on a single widget number. So the widget internally separates “no information,” “last confirmed information,” and “just refreshed information.”
The smaller the screen, the clearer freshness had to be
A WidgetKit extension is not a main-app view. It is a separate surface running outside the app, so an App Group snapshot has to be treated as an explicit shared format between the app and the widget.
I separated snapshot freshness from data freshness, and I stored different value bundles for parking widgets and general widgets even when they came from the same source data. Fallback also had to separate “last confirmed value” from “no information.” A snapshot should keep only the values needed for widget display and deep links.
The biggest shift while building widgets was not “small screens need less explanation.” Because users trust small screens faster, the snapshot and freshness rules had to be clearer.
Share
No comments yet. You can leave the first one.
Pending review