Let’s talk Live activities!
Three months of taking Live Activities from a basic in-app implementation to a full hybrid, push-driven system — and everything we learned along the way.
A Live Activity is a glanceable, live-updating card rendered by the system — on the Lock Screen, in the Dynamic Island, and (on newer OSes) the Apple Watch Smart Stack and CarPlay. Your app defines the layout in a widget extension with SwiftUI; the OS owns rendering and updating. And the property that shapes everything else in this post: your process does not need to be alive for the card to live.
Two ways to drive one:
- In-app (local): your running app calls
Activity.request(...), thenactivity.update(...)/activity.end(...). Simple — but only works while your app can run. - Push-driven: your server updates (iOS 16.2+) or even creates (iOS 17.2+, “push-to-start”) the activity via APNs, with the app in any state — including never launched since reboot.
And Android? There is still no true equivalent. Android 16’s Live Updates (Notification.ProgressStyle + promoted ongoing notifications, surfaced in Samsung’s Now Bar and status-bar chips) is the closest thing, but it’s driven by your process or a foreground service — there is no APNs-style “server creates a card on a dead device” mechanism. For a cross-platform app, that asymmetry is architectural, not cosmetic: on iOS the server drives the surface; on Android, the app does. Plan two designs, not one port.
The problem that makes it interesting
Take a parking session as the running example — a countdown the user cares about while the phone sits in their pocket. It has one property that makes Live Activities genuinely challenging: the session can be born in two different worlds. Sometimes the user starts it inside the app. Sometimes it starts entirely outside it — created by an external system, with the phone nowhere in the loop. Either way, the user expects the same thing: a live countdown card on the Lock Screen.
That dual origin is what turns Live Activities from a UI feature into a distributed-systems problem: the app and the backend are both legitimate creators of the same card, and they have to agree — on who creates it, who updates it, and who ends it.
What we expected to be a two-week feature became a three-month education. The happy path is genuinely easy — you’ll have a card on the Lock Screen in an afternoon. The rest took experimentation, telemetry, and in a few cases asking Apple engineers directly on the Developer Forums.
This post is the map I wish I’d had when I started.
If you only skim, take these seven:
- Live Activities run on two different push tokens with opposite lifecycles. Confusing them will cost you weeks.
- If the user force-quits your app, your server will never receive the per-activity token — confirmed verbatim by Apple DTS. Design for it; you can’t code around it.
- Push-to-start has a hidden double-approval gate: a user must let your app run activities twice before remote starts work.
- A push-to-start launch wakes your app headless — no UI, no Face ID. If your token upload requires interactive auth, it will silently never happen.
- The OS enforces a hard ~8h active / 12h total cap that pre-empts your own
staleDate. Long-lived activities need a server-side re-push strategy. - ActivityKit’s token streams are forward-only. Always prime from the
Activity.activitiessnapshot before iteratingactivityUpdates, or you’ll miss every activity created while you weren’t looking. - When the push path fails — and a slice of it always will — you need a fallback ladder that ends with “prompt the user to open the app.”
The two-token mental model
This is the single most important thing to internalize. Everything that confused us — and most of the confused threads on the Apple forums — traces back to conflating these:
The asymmetry is the trap: the start token is durable and device-scoped — you can register it once and hold it server-side like an FCM token. The update token does not exist until the activity does, and when the server creates the activity on a device where your app is dead, the only way that token reaches your server is if iOS wakes your app in the background to hand it over — and whether that wake happens is the whole game (next sections).
Step 3 is yours. Apple gives you the token; getting it to your server — through auth, process death, and background execution limits — is where production systems succeed or fail. When we first instrumented ours fleet-wide, ~90% of our server-sent end pushes were failing because no per-activity token had made it back. Almost none of that was “a bug” in the classic sense; it was a stack of OS policies we hadn’t designed for.
One correction to a widespread misreading: the "input-push-token": 1 flag you can add to a start payload (iOS 18+) does not deliver the update token to your server. It asks the OS to mint the token immediately — your app must still wake, capture it, and POST it. An Apple Frameworks engineer put it plainly: it’s “an indicator that you are using push tokens to interact with the activity, not a command to emit a new token.”
Anatomy of a hybrid system
You need both start paths. In-app creation gives instant feedback when the user acts inside your app; push-to-start covers sessions born outside it. But running both creates a new problem: the same logical session can end up with two Live Activities — one your app requested, one the OS created from a push. iOS will happily show both.
Our resolution rests on one invariant worth stealing:
Exactly one token-bearer per session. In-app activities are requested with
pushType: nil— deliberately tokenless placeholders. When a push-to-start activity arrives for a session we’re already showing, we adopt the OS-created one (it carries the per-activity token) and end our own placeholder.
Why keep the push-created twin and kill your own? Because the server already holds — or will hold — that instance’s token. If you keep your local one instead, the server’s token points at a corpse, and every subsequent update/end push silently hits a dead activity (APNs still returns success — you get no error signal). We learned that one from telemetry, not docs: at one point 45% of our delivered push-to-start activities were being reconciled away by our own dedup logic, each taking its registered token to the grave.
Get Adithya’s stories in your inbox
Join Medium for free to get updates from this writer.
The other adoption rule: reconcile on every foreground, not once per launch. A push-to-start LA can appear at any moment while you’re backgrounded; a one-shot “adopt existing activities at startup” guard leaves later arrivals untracked and un-endable.
The server side, abstracted
Whatever your stack, the dispatch pipeline converges on the same shape:
Three payload rules that bit us:
startrequires analertblock. Omit it andliveactivitiesdrefuses the activity — silently. No API error reaches your server.- The
attributes-typevalue is your Swift struct’s exact name. It’s a wire contract. RenamingParkingActivityAttributesin a refactor is a breaking API change for your backend. - Store the exact payload you sent per dispatch, plus a per-dispatch status enum (
sent/no_token/device_disabled/failed…). “Why didn’t this card update?” is unanswerable without both.
Where KMP fits
We’re a Kotlin Multiplatform app, and the layering question — what’s Swift, what’s shared? — has a clean answer:
Swift owns “talk to Apple.” KMP owns “talk to our backend.”
ActivityKit has no Kotlin binding and never will need one; widget rendering, Activity.request, and token observation are pure Swift. But everything after a token is captured — auth gating, dedup, persistence, retry, the actual POSTs — is platform-agnostic plumbing, and it lives once, in commonMain, where it’s unit-testable.
The bridge being a replayed SharedFlow is load-bearing: iOS publishes tokens the moment Apple emits them — including before login, including during a headless background launch — and the buffer holds them until an authenticated collector can drain them. The Swift side never needs to know what “logged in” means.
App state × what actually works
The truth table we assembled from forum threads, DTS answers, and our own telemetry:
That last row is the one nobody writes down, so we asked. Apple DTS, verbatim, on our thread:
“When the user force-quit like by swiping from the App Switcher… The system will start the Live Activity, but it will not wake your app. Because the app is not woken up, your code never runs, and your server will never receive the per-activity update token.”
This is policy, not a bug — it’s the same rule Quinn “The Eskimo!” has stated for years about background fetch and silent push after force-quit (“the system will not give the app background fetch time until after the user manually launches the app again”). There is no entitlement, no payload flag, no trick. The activity gets created and renders beautifully; your server just can’t ever end it. If your product has users who habitually swipe apps away — ours does — a slice of your activities is structurally unreachable, and your design must absorb that (see the fallback ladder below). Separately, Apple has acknowledged an actual bug in this area (FB21158660) affecting push-to-start token delivery after force-close across iOS 18–26 — so even the reachable population is softer than you’d hope.
For the killed-but-not-force-quit row, DTS guidance is precise and worth following to the letter: arm Activity.activities + Activity.activityUpdates + pushTokenUpdates observers immediately in didFinishLaunching (not lazily, not after login), and wrap the token upload in beginBackgroundTask or a background URLSession — you have seconds before re-suspension.
Five more things we learned the hard way
1. The push-to-start double-approval gate. An Apple engineer, mid-2026: “Users must approve activities for your app twice before activities are allowed to start using the push-to-start token.” A device that has never run one of your in-app activities may silently drop your first remote starts. Practical consequence: if you can, start one activity locally (a real one, from a real user action) before you rely on push-to-start for that device.
2. Headless launches have no user. When push-to-start cold-launches your app in the background, there is no UI, no splash — and critically, no biometrics. We traced one “token never arrived” case to exactly this: token captured at second 1, but our upload required an authenticated session, the access token lived behind Face ID, and a headless process can’t present Face ID. The POST sat buffered for 62 minutes until the user happened to open the app — long after the update and end pushes had already failed. If your token-registration endpoint demands interactive auth, redesign it: silent (non-biometric) credentials scoped to this one call, or an endpoint keyed on an identity the gateway can resolve. Bonus trap: our biometric auto-prompt was a one-shot flag, and the headless launch consumed it — so the next real foreground didn’t even prompt.
3. The 12-hour guillotine ignores your staleDate. ActivityKit hard-caps an activity around 8 hours active, ~12 hours total. We had a 12h00m32s parking session whose card vanished 32 seconds before the timer hit zero — the OS dismissed at start+12h, pre-empting the staleDate we’d set at the session end. And you can’t just restart a push-created activity client-side: a new Activity.request mints a new instance with no per-activity token registered server-side. Long-running sessions need the server to re-push a fresh start before the cap.
4. Token streams don’t replay. Activity<T>.activityUpdates is forward-only: it yields activities created after you started iterating. Any activity created while you weren’t observing — the push-to-start cold-launch race, in particular — will never appear in it. Every observation site needs the two-step: snapshot first, then stream.
for activity in Activity<Attrs>.activities { // 1. prime: catch what already exists
observeTokens(of: activity)
}
for await activity in Activity<Attrs>.activityUpdates { // 2. stream: catch what comes next
observeTokens(of: activity)
}We ran two observers side by side for a day — one primed, one stream-only — and watched the stream-only one miss an activity the primed one caught. These streams also never complete; spawn them as retained, long-lived Tasks, and re-arm them on every foreground (the OS can quietly stop delivering to a stale continuation without ever ending the stream — a zombie observer looks exactly like a healthy one in your code).
5. Decode like the payload is hostile. The widget renders whatever APNs delivers, decoded by your Codable conformance — and a strict decoder turns any server-side field omission into a silently rejected push or a dismissed activity. Write a permissive init(from:) with defaults for everything. And watch serialization drift between your two authors: our in-app path encoded a Date as a Swift Double; the backend sent ISO-8601. The decode fell back to a “reconstruct from elapsed seconds” branch, quietly baking push latency into the timer — every push-created card ran 14–20 seconds behind the app. Anchor timers to an absolute epoch timestamp, same format from both authors, and never derive “now minus elapsed” on the receiving side. Related: the attributes block is immutable for the activity’s lifetime — anything user-editable (we learned with vehicle nicknames) must live in content-state or stay out of the payload entirely.
staleDate and dismissal-date are your safety net — use both, deliberately
Because a slice of activities is always unreachable (force-quit, token races, disabled LAs), the OS-side timers are not polish — they’re your last line of defense against the worst LA failure mode: a confidently wrong card. A frozen “45 minutes remaining” on a session that ended an hour ago is worse than no card.
Our rules:
- Never create an activity without a
staleDate— from either author. In-app: pass it toActivity.request. Server:stale-dateon thestartpush. For fixed-length sessions, staleDate = the known end. For open-ended ones, a generous ceiling (we use 24h — it’ll never fire before the 12h cap, but it documents intent). Past the date, the system marks the card stale (isStale), and your widget should render an explicitly degraded state — “Session status unknown — open the app” — not the last live numbers. stale-datedims; it does not dismiss. Removal needsevent: "end"+dismissal-date, the app callingend(...), the user swiping, or the system cap.- On
end, always setdismissal-date. Default lingering is up to four hours; a “Parking ended” card three hours later reads as broken. We show the ended state briefly (server: now+60s on the push path; in-app:.after(now + 15min)) and then let it go..immediateis for teardown cases only — duplicates, user switch — where lingering would be wrong, not just stale. - Match the fallback view’s footprint to the live view. The stale state is still your Lock Screen presence; don’t let it collapse into an apologetic strip.
The fallback ladder
Design the failure path as explicitly as the happy path. Ours, in order:
Rung 2 is worth spelling out: on every foreground we reconcile all activities — including push-created ones the app has never seen — against source-of-truth session state, adopt the untracked, and end the orphans. Rung 4 works because your server knows when it’s blind: it sent a push, got no-token back, and the session is still live — so it falls back to the one channel that always works and recruits the user. The moment the app foregrounds, observers re-arm, the token uploads, and sync repairs the card.
Rung 4 feels like a defeat until you accept rung 0 reality: on today’s iOS, a force-quit app cannot get you that token. The only agent who can restore the pipeline is the user.
Measure it or you’re flying blind
The system spans your server, APNs, an OS daemon, and an app that’s usually dead — no debugger sees it end-to-end. Two instruments made every discovery in this post possible:
- Server: a status enum per dispatch (
sent/no_token/device_disabled/skipped/failed), tagged with event type and attributes type, queryable over time. One caveat we learned to respect:no_tokenon astartandno_tokenon anendare completely different diseases (no push-to-start token registered vs. per-activity token never captured) — bucket them separately or your dashboard will send you chasing the wrong fix. And don’t paint them all red: a large share of start-sideno_tokenis simply users who disabled Live Activities in Settings — working as intended, not a failure. - Client: a small, bounded event vocabulary —
started(with origin: in-app vs adopted),per_activity_captured,duplicate_reconciled,orphan_ended,enablement_changed, and cruciallypremature_dismissed(activity dismissed while its session is still live, with seconds-into-session). That last one converts a vague “users say the card vanishes” into a single query.
The first honest dashboard was humbling — that ~90% end-push failure number — but every point of it decomposed into a nameable cause with a nameable fix: snapshot-priming, adopt-don’t-duplicate, auth-free token uploads, staleDate everywhere, and a labeled bucket for “user turned LAs off.”
Closing thoughts
Live Activities are the rare iOS feature where the rendering is trivial and the distributed system around it is the work. The mental shift that made it click for us: stop thinking of the Live Activity as part of your app. It’s a tiny remote client owned by the OS, on the other side of two flaky, half-documented channels — APNs downstream, token-upload upstream — and your app is merely one of two writers to it. Once you design it like that (idempotent syncs, single token-bearer, explicit staleness, a fallback rung for every channel failure), it becomes boring and reliable. Which is exactly what a Lock Screen should be.
And if you’re waiting on Apple’s side of the ledger, my wishlist is short: a server-visible token delivery mechanism (so a dead app isn’t in the critical path), a documented force-quit story instead of a forum answer, and a per-activity lifetime that can outlive a workday.
Found a gap or fought a different Live Activities dragon? I’d genuinely like to hear it.







