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.

SnapshotQuestionStorage unit
Parking snapshotIs it okay to drive there now?Park + display mode
General snapshotIs it okay to visit this park today?Park + general focus
Legacy snapshotBackward compatibilitySingle 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.

SituationDisplay behavior
Fresh snapshot existsBuild entry from snapshot
Snapshot exists but is staleTry network, then fall back with stale presentation if it fails
No snapshot existsTry API, then show unavailable or placeholder if it fails
Nearby park selection cannot access locationShow unavailable with a location reason
API payload park mismatchTreat 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

Share

Image preview