Skip to main content
Everything you need to know about iOS Live Activities before working on the Sightscreen LA system. Synthesized from Apple WWDC sessions, developer forums, and hard-won production experience.
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 tokens
  • Activity<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
Widget extension (WidgetKit + SwiftUI)
  • Renders the Live Activity UI across Lock Screen, Dynamic Island, and StandBy
  • ActivityConfiguration defines 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
Your backend (Node.js / Go / etc)
  • 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) or api.sandbox.push.apple.com:443 (dev)
  • Topic format: {bundleId}.push-type.liveactivity
Apple Push Notification service (APNs)
  • 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
  • NSSupportsLiveActivitiesFrequentUpdates bypasses 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

iOS 17 gotcha: On iOS 17, the push-to-start token is nearly impossible to get reliably — delete app + reboot + fresh install was the only way. iOS 18+ works reliably. Target iOS 18+ for push-to-start features.
Double-fire: 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. Deduplicate by caching the last sent token.
  • Can ONLY start activities — cannot update or end them
  • One token per ActivityAttributes TYPE, not per activity instance

Update token

Never read synchronously: Don’t access activity.pushToken after creation — it’s nil. Must use the async sequence pushTokenUpdates.
  • 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.
1

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.
2

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.
3

Layer 3 — Passive (100% when user opens app)

User opens app naturally. didFinishLaunchingWithOptions fires. Fresh token sent. Backend starts LA. Depends on user behavior but great as supplementary strategy.

Backend integration

Authentication (.p8 key / JWT)

  1. Generate a .p8 key in Apple Developer Portal (Keys → Add Key → enable APNs)
  2. Note your Key ID (10 char), Team ID (from Membership), and Bundle ID
  3. JWT Header: {"alg": "ES256", "kid": "YOUR_KEY_ID"}, Payload: {"iss": "YOUR_TEAM_ID", "iat": unix_timestamp}
  4. Sign with .p8 private key using ES256
  5. JWT is valid for 1 hour — cache and refresh before expiry
Certificate-based auth (.p12 / .pem) does NOT work with Live Activities. Only token-based (.p8) auth is supported. This fails silently.

APNs connection requirements

APNs error handling


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.
attributes-type MUST exactly match your Swift struct name. A mismatch causes a silent failure — no error, no LA, nothing.

Update

Updates the dynamic content of an existing Live Activity. Sent to the update token.
  • timestamp MUST increase with each update — same or lower timestamp is silently ignored
  • stale-date tells iOS when to show your isStale fallback UI
  • Priority 5 = no budget limit, opportunistic delivery
  • Priority 10 = immediate, but budget-limited
  • alert is optional — use for important moments only
  • content-state decoded 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-date in the past = removed immediately from everywhere
  • dismissal-date up to 4 hours in future = stays on Lock Screen until then
  • dismissal-date greater than 4 hours = capped at 4 hours by iOS
  • Do NOT send attributes or attributes-type in end payloads (they’re immutable)

Testing with curl


Production gotchas

Hard-won lessons ordered by severity.

Task deallocation kills token observation

If your pushTokenUpdates 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.
A Task with no owner (not stored as a property) gets deallocated. The async sequence silently stops. No error, no crash — just frozen data on the lock screen.

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 with UIImage(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 = YES to 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: Set relevance-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 JSONEncoder with default encoding strategies to verify your content-state format

Sightscreen playbook

How all of this maps to cricket live scores.

Priority decision matrix

Match lifecycle phases

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.
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.
LACoordinator sends event: "end" with dismissal-date = now + 15 minutes. All live-activity records marked as ended. Match-follows cleaned up. Event bus unsubscribed.
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

  • 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