This page covers the platform fundamentals. For how Sightscreen uses Live Activities, see Live Activity lifecycle and iOS Live Activities.
Architecture
Live Activities involve four actors working together.The four layers
Your app (SwiftUI / ActivityKit)- Requests Live Activities via
Activity.request(attributes:content:pushType:.token) - Observes push tokens via async sequences
- Sends tokens to your backend
activity.pushTokenUpdates— async sequence for update tokensActivity<T>.pushToStartTokenUpdates— async sequence for start tokens (iOS 17.2+)Activity<T>.activityUpdates— observe all activities of a type- Lives in your main app target, NOT the widget extension
- Renders the Live Activity UI across Lock Screen, Dynamic Island, and StandBy
ActivityConfigurationdefines the widget- Lock Screen: full rectangular area above notifications
- Dynamic Island Compact: two small areas flanking the pill
- Dynamic Island Minimal: single tiny indicator (when multiple activities compete)
- Dynamic Island Expanded: long-press reveals larger view
context.isStale— render fallback UI when data is outdated- Runs in a sandbox: NO network access, NO location updates
- Stores device tokens, sends APNs requests to start/update/end Live Activities
- Must handle token rotation and APNs error responses
- Stores push-to-start tokens AND update tokens per device
- Must use token-based auth (JWT) — certificate auth NOT supported
- Must use HTTP/2 + TLS 1.2+
- Sends to
api.push.apple.com:443(prod) orapi.sandbox.push.apple.com:443(dev) - Topic format:
{bundleId}.push-type.liveactivity
- Routes push payloads from your server to the device
- Wakes the widget extension to re-render
- Priority 5 (low) = opportunistic delivery, no budget limit
- Priority 10 (high) = immediate delivery, budget-limited
- 4KB max payload size
NSSupportsLiveActivitiesFrequentUpdatesbypasses some throttling
Data flow
Token lifecycle
This is the part Apple’s docs barely cover. There are two distinct token types with completely different lifecycles, and a fundamental chicken-and-egg problem your backend must solve.Push-to-start token
| Property | Value |
|---|---|
| Purpose | Start a Live Activity remotely (even if app is terminated) |
| Availability | iOS 17.2+ |
| Async sequence | Activity<T>.pushToStartTokenUpdates |
| Lifetime | Persists across app launches. May rotate (rare). Re-observe on every app launch. |
- Can ONLY start activities — cannot update or end them
- One token per ActivityAttributes TYPE, not per activity instance
Update token
| Property | Value |
|---|---|
| Purpose | Update or end an existing Live Activity |
| Availability | iOS 16.1+ |
| Async sequence | activity.pushTokenUpdates (on the Activity instance) |
| Lifetime | Expires after 8 hours (aligned with LA max lifespan). Unique per activity instance. |
- Token MAY change mid-activity (Apple says so, rarely observed in practice)
- When token changes, app gets foreground runtime to handle it
- You MUST invalidate old token on server when new one arrives
The stale token problem
Your backend needs a valid push-to-start token to start a Live Activity. But the token might have rotated since the app last sent it. The app might be terminated, so it can’t proactively tell you.Layer 1 — Best case (~60-70% reliable)
Silent push (
content-available: 1) wakes app in background. App sends fresh push-to-start token. LA starts seamlessly. iOS is extremely stingy about honoring silent pushes — considers battery, user engagement, frequency.Layer 2 — Fallback (~95% reliable)
Backend detects APNs rejection (410/400). Sends visible notification. User taps. App opens. Fresh token sent. LA starts. 2-3 second delay but user barely notices since they just tapped.
Backend integration
Authentication (.p8 key / JWT)
- Generate a
.p8key in Apple Developer Portal (Keys → Add Key → enable APNs) - Note your Key ID (10 char), Team ID (from Membership), and Bundle ID
- JWT Header:
{"alg": "ES256", "kid": "YOUR_KEY_ID"}, Payload:{"iss": "YOUR_TEAM_ID", "iat": unix_timestamp} - Sign with
.p8private key using ES256 - JWT is valid for 1 hour — cache and refresh before expiry
APNs connection requirements
| Requirement | Value |
|---|---|
| Protocol | HTTP/2 (mandatory) |
| TLS | 1.2 or later |
| Production | api.push.apple.com:443 |
| Sandbox | api.sandbox.push.apple.com:443 |
| Push type | liveactivity |
| Topic format | {bundleId}.push-type.liveactivity |
| Max payload | 4KB |
| Auth header | bearer {JWT} |
APNs error handling
| Code | Meaning | Action |
|---|---|---|
| 200 | Success | No action needed |
| 400 | Bad request — malformed payload | Log and fix. Check attributes-type matches Swift struct name exactly. |
| 403 | Auth error — invalid JWT | Regenerate JWT. Verify .p8 key, Key ID, Team ID. |
| 404 | Invalid path or push token | Token is invalid. Remove from database. |
| 410 | Device token no longer active | Token expired or LA dismissed. Remove from DB. Trigger fallback notification. |
| 429 | Rate limited by APNs | Back off. Reduce high-priority push frequency. |
APNs payloads
Start (push-to-start)
Remotely starts a new Live Activity on the device, even if the app is terminated. Sent to the push-to-start token.Update
Updates the dynamic content of an existing Live Activity. Sent to the update token.timestampMUST increase with each update — same or lower timestamp is silently ignoredstale-datetells iOS when to show yourisStalefallback UI- Priority 5 = no budget limit, opportunistic delivery
- Priority 10 = immediate, but budget-limited
alertis optional — use for important moments onlycontent-statedecoded with default JSONDecoder — no custom strategies, camelCase keys only
End
Ends the Live Activity. Optionally sets final content and dismissal timing.- Without
dismissal-date: removed from Dynamic Island immediately, stays on Lock Screen up to 4 hours dismissal-datein the past = removed immediately from everywheredismissal-dateup to 4 hours in future = stays on Lock Screen until thendismissal-dategreater than 4 hours = capped at 4 hours by iOS- Do NOT send
attributesorattributes-typein end payloads (they’re immutable)
Testing with curl
Production gotchas
Hard-won lessons ordered by severity.Task deallocation kills token observation
If yourpushTokenUpdates observation Task gets garbage collected when the app backgrounds, you stop receiving token updates. Your Live Activities freeze showing stale data.
Fix: Store observation Tasks as instance properties on a singleton. Set up observation in didFinishLaunchingWithOptions, not when creating activities.
The double-fire confusion
pushToStartTokenUpdates fires TWICE after a server-side start: once with the real start token, then again with the SAME value after the LA is created. The second fire is NOT an update token.
Fix: Cache the token and deduplicate. Use pushToStartTokenUpdates ONLY for start tokens. Use activity.pushTokenUpdates (on the Activity instance from activityUpdates) for update tokens. These are two completely different async sequences.
Timestamp must always increase
If you send an update with the same or lower timestamp as a previous update, iOS silently ignores it. No error, no log, nothing. Always use monotonically increasing timestamps.No network in widget extension
Live Activities render in a sandboxed widget extension. You cannot fetch remote images with AsyncImage, URLImage, or any network call. Fix: Pre-download images in the main app. Store them in a shared App Group container. Load in the widget withUIImage(contentsOfFile:).
Budget throttling with priority 10
High-priority pushes are budget-limited. If you exceed the budget, updates are silently throttled. Cricket matches with frequent score changes can easily hit this. Fix:- Add
NSSupportsLiveActivitiesFrequentUpdates = YESto Info.plist - Use Priority 5 for routine updates (dot balls, singles)
- Reserve Priority 10 for wickets, boundaries, milestones
- Check
frequentPushesEnabled— users can disable this independently
Relevance score for multiple activities
If a user follows multiple matches simultaneously, iOS needs to know which one to show in the Dynamic Island. Fix: Setrelevance-score in your payload. Higher number = higher priority. Update dynamically — a close finish should score higher than a one-sided match.
Debugging tips
- Use the Console app on Mac to view device logs
- Filter by processes:
liveactivitiesd,apsd,chronod - If APNs returns 200 but the LA doesn’t update, check device logs for decode failures
- Use
JSONEncoderwith default encoding strategies to verify yourcontent-stateformat
Sightscreen playbook
How all of this maps to cricket live scores.Priority decision matrix
| Event | Priority | Alert? | Reason |
|---|---|---|---|
| Dot ball | 5 | No | Routine — don’t drain budget |
| Single/Double | 5 | No | Routine score update |
| Boundary (4) | 5 | No | Exciting but too frequent to buzz |
| Six | 5 | No | Same — boundaries are routine in T20 |
| Wicket | 10 | Yes + sound | Critical — fans need to know immediately |
| 50/100 milestone | 10 | Yes | Celebration moment |
| End of over | 5 | No | Summary update, not urgent |
| Innings break | 10 | Yes | Major state change |
| Match result | 10 | Yes | The climax |
| Hat-trick | 10 | Yes | Historic — once every few hundred matches |
| On hat-trick | 10 | Yes | Next ball is the hat-trick attempt |
| Maiden over | 10 | Yes | Rare in T20s — bowler dominance moment |
| Batsman on fire | 10 | Yes | SR 200+ sustained |
| Bowler on fire | 10 | Yes | 3+ wickets tight economy |
Match lifecycle phases
Pre-match (Orchestrator detects match going live)
Pre-match (Orchestrator detects match going live)
Orchestrator polls fixtures table every 30s. Detects matches transitioning to live status. For each match with followers: sends push-to-start to all follower devices, creates pending live-activity records, subscribes event bus.
During match (Event Bus pipeline)
During match (Event Bus pipeline)
ScorePoller fetches from SportMonks every 5s. MatchEventBus runs detection pipeline (Tier 0 + Tier 1 detectors, cooldown filter, staleness filter). LACoordinator sends APNs batch to all active tokens. Priority 5 for routine, Priority 10 for alert-worthy events.
Match end
Match end
LACoordinator sends
event: "end" with dismissal-date = now + 15 minutes. All live-activity records marked as ended. Match-follows cleaned up. Event bus unsubscribed.Rain delay / suspension
Rain delay / suspension
Event bus detects status transition to suspended. Sets
suspendedAt flag on live-activity records to prevent iOS stale check from ending LA. Event bus reduces polling to 60s. On resume: clears flag, sends score refresh at P5.Sources
Apple official
Apple official
- WWDC23: Update Live Activities with push notifications
- WWDC23: Meet ActivityKit
- WWDC24: Broadcast updates to your Live Activities
- WWDC23: Design dynamic Live Activities
- ActivityKit documentation
- Starting and updating Live Activities with push notifications
- Human Interface Guidelines: Live Activities
- Sending push notifications using command-line tools
- Establishing a token-based connection to APNs
- Sending notification requests to APNs
Community deep dives
Community deep dives
- Christian Selig: Server side Live Activities guide — Push-to-start handoff, iOS 17 vs 18 gap, double-fire, Node.js library landscape
- Daniel Onadipe: Task lifecycle management deep dive — The definitive post on Task deallocation killing token observation
- Braze SDK: Live Activities docs — Confirms 8-hour token expiry, documents
NSSupportsLiveActivitiesFrequentUpdates
Apple Developer Forums
Apple Developer Forums
- Thread 740791 — Token change never observed in production
- Thread 682939 — Push tokens don’t expire but can change on reinstall/restore/OS update
- Thread 671310 — Request fresh tokens on every launch