The export finishes on the server, but the Lock Screen still says Preparing, 67%. Your app looks broken even though the file is ready. That is the hard part of Live Activities: drawing a progress view is easy; making every update honest is a systems problem.

Suppose a user starts a large video export. Some work happens on device, and the final upload and rendering happen on your server. They leave the app and want a useful answer at a glance: is it still working, has it failed, or can they download the result? We’ll use that job to build a Live Activity with a clear owner for each piece of state and a recovery path when delivery fails.

ActivityKit displays information. It does not keep an export process alive. If the job must continue in the background, choose a mechanism for the work itself. For a user-started local transformation on supported systems, BGContinuedProcessingTask and its cancellation model may fit. A background URLSession fits a system-managed file transfer. The Live Activity observes the result of that work; it is not the work scheduler.

1. Choose the Contract Before the Layout

A Live Activity has two data layers. ActivityAttributes contains values that do not change after the activity starts. Its nested ContentState contains the values that updates replace. The same types must be available to the app and its widget extension, usually through shared source or a small Swift package.

For our export, the job identifier and a generic project label are fixed. The phase, percentage, and timestamp change. Avoid embedding a private document title or customer name: Live Activities can appear on a locked device and on other nearby Apple devices.

import ActivityKit
import Foundation

struct ExportAttributes: ActivityAttributes {
    let jobID: String
    let projectLabel: String

    struct ContentState: Codable, Hashable {
        enum Phase: String, Codable {
            case preparing, uploading, rendering, ready, failed
        }

        let phase: Phase
        let completedUnits: Int
        let totalUnits: Int
        let updatedAt: Date

        var fractionComplete: Double {
            guard totalUnits > 0 else { return 0 }
            return min(1, max(0, Double(completedUnits) / Double(totalUnits)))
        }
    }
}

fractionComplete is derived rather than sent by APNs. Keep the encoded data small; the combined static ActivityAttributes and dynamic ContentState must fit within ActivityKit’s 4 KB size limit. A status summary and a few numbers are enough. Store file URLs, logs, thumbnails, and a full job record elsewhere. Model progress as verified completed work, not elapsed time dressed up as a percentage. If a server step has unknown duration, show a phase such as “Rendering” without pretending it is 95% done.

The export may move from local processing to remote rendering. Decide whether the progress bar describes the entire job or just one phase. A bar that jumps backward when phases change damages trust. One option is to allocate stable weights to phases and document how they are calculated. If those weights are guesses, a phase label is more honest.

2. Build One Readable View, Then Adapt It

Enable Live Activities in the app target’s Info.plist with NSSupportsLiveActivities set to YES, add a widget extension, and include the shared attributes type in both targets. The extension declares an ActivityConfiguration; ActivityKit supplies the current attributes and state to its SwiftUI views.

import ActivityKit
import SwiftUI
import WidgetKit

struct ExportLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: ExportAttributes.self) { context in
            VStack(alignment: .leading, spacing: 8) {
                Text("Exporting \(context.attributes.projectLabel)")
                    .font(.headline)
                Text(context.state.phase.rawValue.capitalized)
                ProgressView(value: context.state.fractionComplete)
                Text("Updated \(context.state.updatedAt, style: .relative)")
                    .font(.caption)
            }
            .padding()
            .activityBackgroundTint(.indigo)
            .activitySystemActionForegroundColor(.white)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: "square.and.arrow.up")
                }
                DynamicIslandExpandedRegion(.center) {
                    Text(context.state.phase.rawValue.capitalized)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    ProgressView(value: context.state.fractionComplete)
                }
            } compactLeading: {
                Image(systemName: "square.and.arrow.up")
            } compactTrailing: {
                Text(context.state.phase == .ready ? "Ready" :
                     "\(Int(context.state.fractionComplete * 100))%")
            } minimal: {
                Image(systemName: "square.and.arrow.up")
            }
        }
    }
}

Register ExportLiveActivity in your extension’s WidgetBundle. The compact and minimal Dynamic Island views get very little width, so they carry only an icon or short status. The expanded view can explain the current phase. Do not squeeze a filename, spinner, percentage, and action into one compact region.

The Lock Screen presentation is also used in StandBy, where it can appear much larger. Test the actual background and type scaling there. Apple’s WWDC26 Live Activities essentials session also describes iOS 27’s landscape Dynamic Island layout and the small activity family used on Apple Watch and CarPlay. Treat those as additional layout tests, guarded by the SDK and deployment target you ship with. A Live Activity’s availability across surfaces does not mean one dense view will be legible on all of them.

For playback controls on those same system surfaces, publish one authoritative Now Playing media session instead of recreating transport controls in ActivityKit.

3. Request and Update From the App

Start an activity after the user starts a real export, and check authorization first. The request can fail; the export should still run and the app should still show its status.

import ActivityKit
import Foundation

func startExportActivity(
    jobID: String,
    label: String
) throws -> Activity<ExportAttributes>? {
    guard ActivityAuthorizationInfo().areActivitiesEnabled else { return nil }

    let attributes = ExportAttributes(jobID: jobID, projectLabel: label)
    let state = ExportAttributes.ContentState(
        phase: .preparing,
        completedUnits: 0,
        totalUnits: 100,
        updatedAt: .now
    )
    let content = ActivityContent(
        state: state,
        staleDate: .now.addingTimeInterval(15 * 60)
    )

    return try Activity.request(
        attributes: attributes,
        content: content,
        pushType: .token
    )
}

func recordProgress(
    _ state: ExportAttributes.ContentState,
    on activity: Activity<ExportAttributes>
) async {
    await activity.update(ActivityContent(
        state: state,
        staleDate: .now.addingTimeInterval(15 * 60)
    ))
}

func finishExport(
    _ state: ExportAttributes.ContentState,
    on activity: Activity<ExportAttributes>
) async {
    await activity.end(
        ActivityContent(state: state, staleDate: nil),
        dismissalPolicy: .default
    )
}

If the export is entirely local, you may pass pushType: nil and update it while your app has execution time. Here we request .token because the server will finish the job after the app may be suspended. Use a stable jobID to reconcile with your durable job record. On launch, Activity<ExportAttributes>.activities lets you inspect activities still known to the system and reconcile them with current jobs. Do not assume an Activity object stored in a view model survives a process restart.

End with a final .ready or .failed state. Apple’s default dismissal policy can leave the final result on the Lock Screen for a while, which is useful if the person returns shortly after completion. Choose .immediate when the final state has no value or exposes information that should disappear promptly. The activity can end automatically after its time limit, so the export and its result must remain accessible in the app.

4. Keep Server Updates Tied to the Right Activity

pushType: .token does not return a token synchronously. Observe pushTokenUpdates for the first token and replacements, encode each token as hexadecimal, and upload it through your authenticated API. Associate it with the export ID and the specific activity ID. Replace the previous token on the server when it changes.

func observePushTokens(
    for activity: Activity<ExportAttributes>,
    upload: @escaping (String, String, String) async throws -> Void
) async {
    for await token in activity.pushTokenUpdates {
        let hex = token.map { String(format: "%02x", $0) }.joined()
        do {
            try await upload(activity.attributes.jobID, activity.id, hex)
        } catch {
            // Persist a retry marker; the server cannot update without this token.
            // Surface the sync failure in app diagnostics, not in public UI.
        }
    }
}

Run that observer in a task owned by a long-lived app service, and restart observation for active activities when the app launches. Cancel it when the activity ends. The sketch intentionally leaves authenticated transport and durable retry storage to the app; a swallowed upload failure with no retry would make the Live Activity silently freeze. Keep push tokens out of logs and analytics. They authorize updates to one activity and can change during its lifetime.

Your server sends ActivityKit notifications through APNs using apns-push-type: liveactivity and the topic <bundle-id>.push-type.liveactivity. The payload includes an aps.timestamp, event: update or end, and a content-state matching the Swift Codable model. An end event should include the final state. Apple’s push notification guide specifies the headers and payload. Do not use a custom JSON date or key strategy for these updates; the system decodes with its default strategy.

Treat the server job record as authoritative. Persist each state transition, then enqueue a push with a monotonically advancing event timestamp. Retry transient APNs failures with bounded backoff, record permanent token failures, and deduplicate repeated events. APNs delivery is not a durable job queue: an offline device can miss updates, and the system ignores updates that arrive after an activity ended. When the app resumes, fetch the job’s latest state and update or end any surviving activity. This closes the gap between a successful export and a stale Lock Screen.

Broadcast channels are useful when many devices follow the same public event, such as a sports match. A private export has a per-user lifecycle and belongs with a per-activity push token. A push-to-start token is also a different concept: it starts a new activity remotely. We already have the user in the foreground when they start the export, so an in-app request is simpler and easier to explain.

5. Make Stale Data Explicit

staleDate says when the system should consider the state out of date. It does not ask ActivityKit to fetch a replacement, and it does not guarantee a push will arrive. For a fast export, fifteen minutes might be reasonable; for a slow render, choose a threshold that reflects the expected update cadence. Advance it with each verified update.

Show the last confirmed update time, as the view above does. If you support a stale presentation using the activity context’s stale state, replace the optimistic progress label with a neutral message such as “Open app for latest status.” The point is to avoid presenting an old percentage as live truth. The app can observe activityStateUpdates and reconcile when it runs again.

relevanceScore can influence ordering if your app has multiple activities, but it is not a delivery guarantee. Set a sensible priority for the export the user most recently acted on and keep the number of concurrent activities small. Apple’s documented active limit is eight hours; an ended activity can remain on the Lock Screen for up to four more. If rendering may run overnight, do not promise that the Live Activity will last until completion. Provide an in-app job history and an appropriate completion notification.

6. Test Failures, Privacy, and Cost

Test the activity on a real device and in previews for Lock Screen, compact, minimal, expanded, StandBy, and the small activity family where supported. The simulator helps with layout but does not replace APNs delivery tests. Make a checklist that exercises:

  • Live Activities disabled, request failure, and a device without Dynamic Island;
  • app foreground, background, terminated, and restarted during the same export;
  • token arriving late, rotating, or failing to upload;
  • duplicate and out-of-order server events, network loss, and an APNs rejection;
  • stale state, export failure, cancellation, and completion near the activity’s time limit; and
  • Dynamic Type, VoiceOver, long localization, and narrow landscape layouts.

The widget extension must render from its supplied context without a network call or expensive computation. Treat it as a projection of verified state. Send updates when the user can learn something meaningful: a phase change, a meaningful progress increment, or completion. Pushing every encoded frame burns energy and can be throttled. The exact cadence should follow the product’s task and Apple’s update-frequency guidance, then be checked against real delivery metrics.

There are two privacy boundaries here. First, the Live Activity may be visible without unlocking the device, so avoid sensitive file names and client information. Second, push tokens and job identifiers belong in authenticated transport and restricted storage. If you need interaction, use a deep link or an appropriate App Intent that validates the current user and job state. The same principles in App Intents across system surfaces apply: a button tap is a request to perform work, not proof the job is still in a valid state.

For a high-value server action such as creating a paid export, App Attest can bind the request to a verified app key while your backend still checks the user’s permissions and the current job state.

Key Takeaways

A reliable Live Activity starts with a durable job model. Put fixed identity in ActivityAttributes, changing status in a compact ContentState, and only report progress the export has actually completed. Use local updates while the app runs, ActivityKit push notifications when the server owns later stages, and a reconciliation fetch when the app returns. Stale dates, a final state, and a truthful fallback in the app make delayed delivery understandable instead of misleading.

For the interface, design the Lock Screen first, then reduce it deliberately for Dynamic Island and test the other supported surfaces. If you keep the widget lightweight and the server state authoritative, the Live Activity can remain useful even when the network is imperfect.