Building Production-Grade Live Activities and Dynamic Island in iOS: ActivityKit, APNs Push-to-Start, and Resilient State Invalidation

Published: September 6, 2026

Building Production-Grade Live Activities and Dynamic Island in iOS: ActivityKit, APNs Push-to-Start, and Resilient State Invalidation

Live Activities and the Dynamic Island allow iOS applications to deliver persistent, glanceable, and real-time updates directly to the Lock Screen, StandBy mode, and Dynamic Island hardware cutouts. Building these experiences for production requires coordinating client-side WidgetKit presentation layouts with remote Apple Push Notification service (APNs) channels, resilient lifecycle management, and strict state invalidation policies. This guide walks through implementing a complete, production-ready Live Activity pipeline from scratch using modern Swift and remote APNs triggers.

flowchart TD A[Backend Service] -->|APNs Remote Push: Push-to-Start| B[Apple Push Notification service] B -->|apns-push-type: liveactivity| C[iOS Device / ActivityKit Engine] C -->|Instantiate Activity| D[Dynamic Island & Lock Screen Widget] C -->|Generate Unique Push Token| E[App Client Token Listener] E -->|Upload Push Token| A A -->|APNs Remote Push: Push-to-Update| B B -->|Update ContentState| D A -->|APNs Remote Push: End / Stale Date| B B -->|Dismissal Policy Applied| D

Introduction: Solving Real-Time Glanceable Updates

Live Activities eliminate the friction of repeatedly opening an application to check changing operational states, such as rideshare arrivals, order deliveries, or live event scores. By moving from high-frequency background fetch routines to event-driven push channels, engineering teams can minimize client battery consumption and reduce round-trip latency. Operating successfully at scale requires treating the Dynamic Island not merely as a decorative widget, but as an active remote state consumer that must degrade gracefully when connectivity falters.

Before ActivityKit, mobile applications relied heavily on standard local and remote notifications to communicate incremental progress updates. This legacy approach created notification center clutter and forced users to manually dismiss intermediate state alerts. Live Activities consolidate an entire event lifecycle into a single interactive view on the Lock Screen and Dynamic Island.

By integrating remote APNs capabilities, backends can control the presentation layer directly without waiting for a user to open the host application. Implementing this architecture requires a solid understanding of the Apple ActivityKit Documentation and modern Swift Concurrency.


Step 1: Project Setup and Capability Configuration

Configuring Live Activities requires adding a separate Widget Extension target to your Xcode project and declaring explicit ActivityKit permission keys in your property list files. Both your main iOS application target and your Widget Extension target must share access to the data models representing the activity state. Failing to configure these targets correctly will prevent the system from registering push tokens or presenting views in the Dynamic Island.

1. Configure Target Capabilities

In Xcode:

  1. Navigate to File > New > Target....
  2. Select Widget Extension under the iOS tab and click Next.
  3. Name your extension (e.g., DeliveryTrackingWidgetExtension).
  4. Ensure the Include Configuration Intent checkbox is unchecked unless your widget requires custom end-user parameter configuration.
  5. Click Finish, and activate the scheme when prompted.

2. Update Info.plist Declarations

Both your Main App and Widget Extension targets must declare support for Live Activities. Open your project settings or target Info.plist files and add the following keys:

<!-- Required in both Main App and Widget Extension Info.plist -->
<key>NSSupportsLiveActivities</key>
<true/>

<!-- Optional: Required if your activity updates more than once every few seconds -->
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
<true/>

[!IMPORTANT] Setting NSSupportsLiveActivitiesFrequentUpdates to true allows your backend to send more frequent APNs updates without being throttled by the operating system budget. However, excessive updates can still be throttled if the device enters Low Power Mode.


Step 2: Defining ActivityAttributes and Designing the Dynamic Island

Live Activity schemas are defined using an ActivityAttributes protocol implementation that separates static configuration data from dynamic, frequently changing state data. The visual presentation is built with SwiftUI and WidgetKit, targeting four distinct Dynamic Island configurations alongside the standard Lock Screen banner layout. Structuring your SwiftUI views defensively ensures text strings and status badges do not truncate on smaller devices like the iPhone 15 Pro.

1. Define Static and Dynamic State

Create a shared Swift file (e.g., OrderTrackingAttributes.swift) and ensure its target membership includes both the main app and the widget extension:

import ActivityKit
import Foundation

public struct OrderTrackingAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        public enum Status: String, Codable, Hashable {
            case preparing = "Preparing"
            case outForDelivery = "Out for Delivery"
            case arriving = "Arriving"
            case delivered = "Delivered"
        }
        
        public var status: Status
        public var estimatedArrival: Date
        public var driverName: String
        public var progress: Double // 0.0 to 1.0
        
        public init(status: Status, estimatedArrival: Date, driverName: String, progress: Double) {
            self.status = status
            self.estimatedArrival = estimatedArrival
            self.driverName = driverName
            self.progress = progress
        }
    }

    // Static immutable properties defined when the activity starts
    public var orderNumber: String
    public var storeName: String

    public init(orderNumber: String, storeName: String) {
        self.orderNumber = orderNumber
        self.storeName = storeName
    }
}

2. Construct the WidgetKit Dynamic Island View

Inside your Widget Extension, implement an ActivityConfiguration that defines the Lock Screen view and all Dynamic Island presentation regions: expanded, compactLeading, compactTrailing, and minimal.

Refer to the Apple WidgetKit Documentation for additional guidance on presentation contexts and container sizes.

import ActivityKit
import SwiftUI
import WidgetKit

@main
struct DeliveryTrackingWidgetBundle: WidgetBundle {
    var body: some Widget {
        DeliveryTrackingLiveActivity()
    }
}

struct DeliveryTrackingLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: OrderTrackingAttributes.self) { context in
            // Lock Screen and StandBy Presentation
            VStack(alignment: .leading, spacing: 8) {
                HStack {
                    Text(context.attributes.storeName)
                        .font(.headline)
                    Spacer()
                    Text(context.state.status.rawValue)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
                
                ProgressView(value: context.state.progress)
                    .tint(.blue)
                
                HStack {
                    Text("Driver: \(context.state.driverName)")
                        .font(.caption)
                    Spacer()
                    Text(context.state.estimatedArrival, style: .timer)
                        .font(.caption)
                        .monospacedDigit()
                }
            }
            .padding()
            .activityBackgroundTint(Color.black.opacity(0.8))
            .activitySystemActionForegroundColor(Color.white)
            
        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded Presentation (Long press)
                DynamicIslandExpandedRegion(.leading) {
                    VStack(alignment: .leading) {
                        Text(context.attributes.storeName)
                            .font(.caption2)
                            .foregroundStyle(.secondary)
                        Text(context.state.status.rawValue)
                            .font(.headline)
                    }
                    .padding(.leading, 4)
                }
                
                DynamicIslandExpandedRegion(.trailing) {
                    VStack(alignment: .trailing) {
                        Text("ETA")
                            .font(.caption2)
                            .foregroundStyle(.secondary)
                        Text(context.state.estimatedArrival, style: .time)
                            .font(.headline)
                            .monospacedDigit()
                    }
                    .padding(.trailing, 4)
                }
                
                DynamicIslandExpandedRegion(.bottom) {
                    VStack(spacing: 4) {
                        ProgressView(value: context.state.progress)
                            .tint(.blue)
                        HStack {
                            Text("Driver: \(context.state.driverName)")
                                .font(.caption2)
                            Spacer()
                            Text(context.state.estimatedArrival, style: .relative)
                                .font(.caption2)
                        }
                    }
                    .padding(.horizontal, 4)
                }
            } compactLeading: {
                // Compact Leading (Left pill)
                Image(systemName: "box.truck.fill")
                    .foregroundColor(.blue)
            } compactTrailing: {
                // Compact Trailing (Right pill)
                Text(context.state.estimatedArrival, style: .timer)
                    .monospacedDigit()
                    .frame(width: 45)
                    .contentTransition(.numericText())
                    .animation(.snappy, value: context.state.progress)
            } minimal: {
                // Minimal Presentation (Multiple active activities)
                Image(systemName: "box.truck.fill")
                    .foregroundColor(.blue)
            }
        }
    }
}

Step 3: Managing Lifecycles and Implementing APNs Push-to-Start

Activity lifecycles can be controlled locally from client code or triggered remotely through APNs Push-to-Start without requiring the app to be open. When an activity runs, it provides an asynchronous sequence of push tokens that must be synced to your backend to deliver subsequent updates. Starting with iOS 17.2, applications can register a static pushToStartToken so the backend can remotely spawn an activity even if the app process has been terminated.

1. Client-Side Activity Manager

Implement an ObservableObject or Swift concurrency actor in your main app target to request activities, observe push tokens, and stream token changes to your backend service:

import ActivityKit
import Foundation

@MainActor
public final class LiveActivityManager: ObservableObject {
    public static let shared = LiveActivityManager()
    private var currentActivity: Activity<OrderTrackingAttributes>?

    private init() {}

    /// Register for remote Push-to-Start updates (iOS 17.2+)
    public func observePushToStartTokens() {
        if #available(iOS 17.2, *) {
            Task {
                for await tokenData in Activity<OrderTrackingAttributes>.pushToStartTokenUpdates {
                    let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
                    await self.syncPushToStartTokenWithBackend(token: token)
                }
            }
        }
    }

    /// Request a local Live Activity and listen for its update push token
    public func startOrderTracking(orderId: String, store: String) throws {
        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
            print("Live Activities are disabled by the user.")
            return
        }

        let attributes = OrderTrackingAttributes(orderNumber: orderId, storeName: store)
        let initialState = OrderTrackingAttributes.ContentState(
            status: .preparing,
            estimatedArrival: Date().addingTimeInterval(1800),
            driverName: "Alex",
            progress: 0.1
        )

        let activityContent = ActivityContent(
            state: initialState,
            staleDate: Date().addingTimeInterval(3600)
        )

        // Request activity with remote push updates enabled
        let activity = try Activity.request(
            attributes: attributes,
            content: activityContent,
            pushType: .token
        )
        
        self.currentActivity = activity

        // Stream push tokens for updating this specific active session
        Task {
            for await pushToken in activity.pushTokenUpdates {
                let tokenString = pushToken.map { String(format: "%02.2hhx", $0) }.joined()
                await self.syncUpdateTokenWithBackend(orderId: orderId, token: tokenString)
            }
        }
    }

    private func syncPushToStartTokenWithBackend(token: String) async {
        // Send push-to-start token to backend server
        print("Push-to-Start Token: \(token)")
    }

    private func syncUpdateTokenWithBackend(orderId: String, token: String) async {
        // Send per-activity push-to-update token to backend server
        print("Activity Update Token for Order \(orderId): \(token)")
    }
}

2. APNs Request Headers and Payload Specifications

Sending Live Activity updates via APNs requires specific headers and JSON payload structures. Refer to the Apple Push Notification service Documentation for TLS setup and authentication tokens.

Required HTTP/2 Headers

Header Value Notes
apns-topic <your.bundle.id>.push-type.liveactivity Must include .push-type.liveactivity suffix
apns-push-type liveactivity Designates the payload as an ActivityKit message
apns-priority 10 or 5 Use 10 for immediate delivery, 5 for power-aware delivery

APNs Push-to-Start Payload (Event: start)

To launch a Live Activity remotely (iOS 17.2+), send this payload to the device's Push-to-Start token:

{
  "aps": {
    "timestamp": 1772787600,
    "event": "start",
    "content-state": {
      "status": "Preparing",
      "estimatedArrival": 1772789400,
      "driverName": "Alex",
      "progress": 0.15
    },
    "attributes-type": "OrderTrackingAttributes",
    "attributes": {
      "orderNumber": "ORD-92841",
      "storeName": "Artisan Roasters"
    },
    "alert": {
      "title": "Order Placed",
      "body": "Artisan Roasters has received your order."
    }
  }
}

APNs Push-to-Update Payload (Event: update)

To update an existing Live Activity, send this payload to the specific activity push token:

{
  "aps": {
    "timestamp": 1772788200,
    "event": "update",
    "content-state": {
      "status": "Out for Delivery",
      "estimatedArrival": 1772789100,
      "driverName": "Alex",
      "progress": 0.65
    }
  }
}

Step 4: Resilient State Invalidation and Stale Data Defense

Live Activities risk becoming orphaned on the user's Lock Screen if network conditions prevent the final completion push from reaching the device. Mobile engineers must implement defensive invalidation policies using staleDate properties, server-side Time-to-Live (TTL) timestamps, and explicit dismissal policies. Handling lifecycle cleanups locally on app launch prevents out-of-date widgets from lingering indefinitely.

flowchart LR A[Activity Starts] --> B[Active Streaming] B -->|Normal Flow| C[APNs 'end' Event] C -->|dismissal-date: immediate| D[Dismissed Immediately] C -->|dismissal-date: ISO/Timestamp| E[Dismissed at Target Date] B -->|Network Loss / Server Drop| F[staleDate Reached] F --> G[System Marks UI as Stale] G --> H[Local Startup Cleanup Loop] H --> D

1. APNs Push-to-End Payload

When an activity completes, your backend should transmit an end event containing a final state and a dismissal-date:

{
  "aps": {
    "timestamp": 1772789400,
    "event": "end",
    "dismissal-date": 1772791200,
    "content-state": {
      "status": "Delivered",
      "estimatedArrival": 1772789400,
      "driverName": "Alex",
      "progress": 1.0
    }
  }
}

[!TIP] Use dismissal-date: 0 to dismiss the Live Activity from the Lock Screen and Dynamic Island immediately. Supplying a future timestamp (e.g., +15 minutes) lets the user view the final confirmation without cluttering the screen for hours.

2. Defensive Client-Side Cleanup on App Launch

If the user was in airplane mode or disconnected when the activity ended, clean up any active sessions upon subsequent app launches:

import ActivityKit
import Foundation

extension LiveActivityManager {
    /// Inspects and purges orphaned or stale activities when the app opens
    public func reconcileOrphanedActivities() async {
        let activeActivities = Activity<OrderTrackingAttributes>.activities
        
        for activity in activeActivities {
            // Check if the activity has exceeded its stale date
            if let staleDate = activity.content.staleDate, staleDate < Date() {
                let finalContent = ActivityContent(
                    state: activity.content.state,
                    staleDate: nil
                )
                // Dismiss with immediate policy
                await activity.end(finalContent, dismissalPolicy: .immediate)
                print("Purged stale activity: \(activity.id)")
            }
        }
    }
}

Common Pitfalls and Production Checklist

Shipping Live Activities to millions of users introduces failure modes rarely encountered in standard push notification setups. Pay close attention to system payload sizes, token rotation lifecycles, and view hierarchy constraints.

Critical Area Common Pitfall Production Best Practice
Payload Budget Sending payloads greater than 4KB over APNs. Keep ContentState minimal. Omit verbose strings and pass primitives or enum keys.
Token Invalidation Caching push tokens indefinitely on the backend. Push tokens rotate frequently; update backend storage on every emission from pushTokenUpdates.
Layout Clipping Large text or fixed frame sizes breaking the Dynamic Island. Use .monospacedDigit() and dynamic font scaling with fluid SwiftUI layout containers.
Frequent Update Caps Sending updates every second causes APNs throttling. Batch non-critical updates. Use client-side Text(date, style: .timer) for running clocks instead of pushing every second.

Frequently Asked Questions

How do I handle APNs payload size limits for Live Activities?

The maximum payload size for an ActivityKit APNs update is 4KB (4096 bytes). To avoid delivery failures, transmit only state diffs and primitive types (such as raw enum strings, timestamps, or progress floats) rather than heavy object graphs. Static information, such as business logos or order item lists, should either be pre-loaded into the app bundle, bundled in the initial static ActivityAttributes, or rendered using system symbols.

Can Live Activities be started when the app is completely terminated?

Yes. Starting with iOS 17.2, Apple introduced Push-to-Start for ActivityKit. By observing Activity<YourAttributes>.pushToStartTokenUpdates, your client app receives a global push token to register with your backend. Your backend can then transmit an APNs payload with "event": "start" to create and display a Live Activity directly on the user's device even if the app process is terminated.

What happens if the device is offline when a Live Activity is marked as completed?

If a device is offline when the backend sends the APNs "event": "end" message, the Live Activity relies on the staleDate timestamp specified in earlier payloads or during initial local creation. Once the current time surpasses the staleDate, iOS automatically dims or marks the activity view as stale. When connectivity resumes or the user reopens the app, client reconciliation logic can immediately call activity.end(dismissalPolicy: .immediate).


About the Author

Furkan Çetinkaya is a Mobile-focused Software Developer specializing in React Native, native bridge integrations (Kotlin & Swift), and supporting backend services. Experienced in maintaining high-impact mobile applications and developer SDKs.