All field notes

Live Activities in production: the rate limits nobody warns you about

ActivityKit looks simple until you try to keep a Lock-Screen ETA ticking. The frequent-updates entitlement, the roughly-per-minute push ceiling, the 12-hour window, token rotation — what actually constrains a live-updating widget, and how to build the backend around it.

Live Activities demo beautifully. You call Activity.request(...), a card appears on the Lock Screen and in the Dynamic Island, and it feels like you've been handed a real-time channel to the user's glance. Then you try to keep a countdown accurate for an hour, and you meet the constraints Apple mentions quietly and everyone discovers loudly. Here's the list, and how we build a backend that respects it.

Two ways to update, and only one scales

There are two update paths, and picking wrong is the first mistake.

  • Local updates (activity.update(...) from the app) only run when your app has execution time. On the Lock Screen, with the app suspended, that's ~never. Fine for a value you already know at start; useless for a live ETA.
  • Remote push (an ActivityKit push to APNs, apns-push-type: liveactivity) updates the card while the app is fully suspended. This is the one you want for anything that changes on its own — a ferry that's still moving whether or not the app is running.

If your data changes in the world rather than in the app, you're doing remote push. Everything below is about that path.

The ceiling: budgeted, not free

This is the constraint people hit first. You do not get unlimited pushes. The system meters them.

  • By default the budget is stingy. To push more often you must declare NSSupportsLiveActivitiesFrequentUpdates in Info.plist.
  • Even with the frequent-updates entitlement, you're not unthrottled — you're budgeted. Push too fast and the system starts dropping your updates, and the user's card silently stops moving. Plan for roughly once-per-minute as the sustainable cadence, not once-per-second.
  • The user can turn frequent updates off. Your backend has to look fine when it's off, too.

Minute-granularity turns out to be the right product answer anyway. A ferry ETA that re-renders every second is jitter, not information. But you should arrive there by design, not by discovering your pushes are being eaten.

The payload: 4 KB, and it must be self-contained

An ActivityKit push carries a content-state that has to match your ActivityAttributes.ContentState, inside a 4 KB APNs limit.

{
  "aps": {
    "timestamp": 1752249660,
    "event": "update",
    "content-state": { "etaSeconds": 480, "status": "on_time" },
    "stale-date": 1752249900,
    "relevance-score": 100
  }
}

Two fields do quiet, important work:

  • stale-date tells the system when to visually mark the content as stale on its own — so if your next push is late, the card shows "this is old" instead of confidently showing a wrong ETA. Set it every push, a bit past your expected next update.
  • relevance-score decides which activity wins the Dynamic Island when several are live.

The window: it ends, one way or another

Activities do not live forever, and every ending is a state your UI has to render deliberately.

  • An activity can be active up to 8 hours, and remain on screen up to 12 hours total before the system removes it.
  • You should end it yourself the moment it stops being relevant — the boat departed, the run finished — with a final frame (dismissalPolicy / a terminal content-state) rather than letting it rot into a stale card.
  • "Ended" is a real design state, not an afterthought. A countdown that hits zero and then just sits there reads as a bug.

Token rotation: the operational tax

Each activity has its own push token, delivered asynchronously and able to rotate mid-life. Your app has to stream tokens to the backend as they arrive:

for await token in activity.pushTokenUpdates {
    let hex = token.map { String(format: "%02x", $0) }.joined()
    await api.register(route: route, token: hex)   // fire on every emission
}

Register on every emission, not just the first. Miss a rotation and your pushes start hitting a dead token — the classic "it worked for twenty minutes and then froze" bug. On the backend, tokens are ephemeral: they belong to one activity, expire with it, and should be discarded when it ends. There's no stable user identifier here to hang onto, which is good for privacy and means your token store is a cache, not a database.

What the backend actually looks like

Put the constraints together and the server design falls out:

  • A token registry keyed by activity, holding (route, token) pairs, treated as disposable.
  • A per-route change feed driving pushes — you already have fresh state if you poll upstream once and fan out; push a route's watchers when its value meaningfully changes.
  • A cadence limiter at ~1/min per activity, coalescing rapid upstream changes into a single push so you stay inside budget.
  • Every push sets stale-date; the terminal event ends the activity cleanly.

What to tell users

The last constraint is honesty. When someone reports "the Live Activity updates slowly," the truthful answer is usually "iOS budgets how often any app may push, and we're inside that budget." It isn't a dodge — it's the actual mechanism. Build for minute-granularity, mark staleness so a late push never shows a confident wrong number, and end the activity the instant it stops mattering. Do that and the feature feels reliable, which on the Lock Screen is the only thing that counts.