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

PropertyValue
PurposeStart a Live Activity remotely (even if app is terminated)
AvailabilityiOS 17.2+
Async sequenceActivity<T>.pushToStartTokenUpdates
LifetimePersists across app launches. May rotate (rare). Re-observe on every app launch.
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

PropertyValue
PurposeUpdate or end an existing Live Activity
AvailabilityiOS 16.1+
Async sequenceactivity.pushTokenUpdates (on the Activity instance)
LifetimeExpires after 8 hours (aligned with LA max lifespan). Unique per activity instance.
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

RequirementValue
ProtocolHTTP/2 (mandatory)
TLS1.2 or later
Productionapi.push.apple.com:443
Sandboxapi.sandbox.push.apple.com:443
Push typeliveactivity
Topic format{bundleId}.push-type.liveactivity
Max payload4KB
Auth headerbearer {JWT}

APNs error handling

CodeMeaningAction
200SuccessNo action needed
400Bad request — malformed payloadLog and fix. Check attributes-type matches Swift struct name exactly.
403Auth error — invalid JWTRegenerate JWT. Verify .p8 key, Key ID, Team ID.
404Invalid path or push tokenToken is invalid. Remove from database.
410Device token no longer activeToken expired or LA dismissed. Remove from DB. Trigger fallback notification.
429Rate limited by APNsBack 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.
{
  "aps": {
    "timestamp": 1685952000,
    "event": "start",
    "content-state": {
      "teamOneScore": 0,
      "teamTwoScore": 0
    },
    "attributes-type": "SportsActivityAttributes",
    "attributes": {
      "gameName": "IND vs AUS",
      "gameNumber": "IPL Match 12"
    },
    "alert": {
      "title": "IND vs AUS is LIVE",
      "body": "Follow the match on your Lock Screen",
      "sound": "default"
    }
  }
}
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.
{
  "aps": {
    "timestamp": 1685955600,
    "event": "update",
    "content-state": {
      "teamOneScore": 2,
      "teamTwoScore": 1
    },
    "stale-date": 1685959200,
    "alert": {
      "title": "WICKET!",
      "body": "Kohli dismissed for 47",
      "sound": "wicket.caf"
    }
  }
}
  • 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.
{
  "aps": {
    "timestamp": 1685962800,
    "event": "end",
    "dismissal-date": 1685966400,
    "content-state": {
      "teamOneScore": 4,
      "teamTwoScore": 3
    }
  }
}
  • 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

curl \
  --header "apns-topic: com.yourapp.bundle.push-type.liveactivity" \
  --header "apns-push-type: liveactivity" \
  --header "apns-priority: 10" \
  --header "authorization: bearer $AUTHENTICATION_TOKEN" \
  --data '{
    "aps": {
      "timestamp": '$(date +%s)',
      "event": "update",
      "content-state": {
        "teamOneScore": 2,
        "teamTwoScore": 1
      }
    }
  }' \
  --http2 https://api.sandbox.push.apple.com/3/device/$ACTIVITY_PUSH_TOKEN

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.
// Correct — Task retained on singleton
@MainActor class LiveActivityManager {
  static let shared = LiveActivityManager()
  private var tokenTasks: [String: Task<Void, Never>] = [:]

  func observeTokens(for activity: Activity<MyAttrs>) {
    tokenTasks[activity.id] = Task { [weak self] in
      for await tokenData in activity.pushTokenUpdates {
        guard let self else { return }
        let token = tokenData.map {
          String(format: "%02x", $0)
        }.joined()
        await self.sendToBackend(token, activityId: activity.id)
      }
    }
  }
}
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

EventPriorityAlert?Reason
Dot ball5NoRoutine — don’t drain budget
Single/Double5NoRoutine score update
Boundary (4)5NoExciting but too frequent to buzz
Six5NoSame — boundaries are routine in T20
Wicket10Yes + soundCritical — fans need to know immediately
50/100 milestone10YesCelebration moment
End of over5NoSummary update, not urgent
Innings break10YesMajor state change
Match result10YesThe climax
Hat-trick10YesHistoric — once every few hundred matches
On hat-trick10YesNext ball is the hat-trick attempt
Maiden over10YesRare in T20s — bowler dominance moment
Batsman on fire10YesSR 200+ sustained
Bowler on fire10Yes3+ wickets tight economy

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