A Live Activity is a small, event-scoped SwiftUI view declared inside a Widget Extension and managed by the ActivityKit framework. It lives in two places:
- Lock Screen — a full-width card that replaces a notification while the event is in progress.
- Dynamic Island — a morphing pill at the top of the screen on iPhone 14 Pro and later, with four presentation states: compact leading, compact trailing, minimal, and expanded.
Unlike a widget, a Live Activity is temporary. It only exists while something is happening, and it's dismissed when the event ends. And unlike a notification, it updates in place instead of stacking up.
Live Activity vs Widget vs Notification
| Capability | Live Activity | Widget | Notification |
| Persistence | Event duration (≤ 12h) | Always on home screen | One-shot banner |
| Updates in place | Yes | Scheduled timeline | No |
| Dynamic Island | Yes | No | No |
| Interactive | Yes (App Intents) | Yes (iOS 17+) | Actions only |
| Remote trigger | Push-to-start + push updates | Background refresh | APNs |
What's New for Live Activities in iOS 26
If you last shipped a Live Activity back in iOS 17 or 18, a few things have shifted in iOS 26:
- Paired-device display — Live Activities automatically mirror to paired watchOS 11+ and macOS 26+ devices. You can opt out in the
ActivityConfiguration, but by default the system just handles it.
- Interactive buttons via App Intents —
Button(intent:) and Toggle(intent:) now work inside both the Lock Screen card and the expanded Dynamic Island. Meaning: a user can pause a timer or tick off a delivery step without ever opening your app.
- Push-to-start, GA — the push-to-start token API is stable. Your server can create a Live Activity on the device with no prior in-app action, which is huge for web-ordered flows.
- Stricter size budget — the expanded Dynamic Island region still sits around 160pt tall, and APNs
liveactivity payloads are capped at 4 KB. New in iOS 26: payloads over budget fail silently with a telemetry entry, rather than crashing the widget (a quiet but welcome change).
- Swift 6 by default — the Widget Extension template now uses strict concurrency. Your
ActivityAttributes must be Sendable, and anything touching UI state needs @MainActor.
Prerequisites
- Xcode 26 with the iOS 26 SDK.
- A physical iPhone 14 Pro or later for Dynamic Island testing. (The simulator supports Lock Screen and expanded Dynamic Island, but the compact states really do render best on device.)
- An active Apple Developer account if you plan to send push updates. APNs requires token-based authentication (a
.p8 key).
- In your app target's
Info.plist, add NSSupportsLiveActivities set to YES. Only add NSSupportsLiveActivitiesFrequentUpdates if you genuinely need sub-second updates — abusing it tanks your budget.
Step 1: Add a Widget Extension
In Xcode, go File → New → Target → Widget Extension. Name it DeliveryWidgets and make sure Include Live Activity is checked. Xcode will generate a stub ActivityAttributes struct, an ActivityConfiguration, and a WidgetBundle.
Before you write a line of code, make sure your data models are members of both the app target and the widget target. Open each model file, show the File Inspector, and tick both targets. Missing target membership is (by a mile) the #1 reason widgets fail to build against your shared models. I've wasted hours on this; don't be me.
Step 2: Define ActivityAttributes
An ActivityAttributes type has two parts. The outer struct holds values that never change during the activity's lifetime — order number, driver name, route. The nested ContentState holds everything that can update: current step, ETA, progress.
import ActivityKit
import SwiftUI
public struct DeliveryAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable, Sendable {
public var step: DeliveryStep
public var etaSeconds: Int
public var driverLocation: String
public var progress: Double // 0.0 ... 1.0
}
public let orderNumber: String
public let restaurantName: String
public let totalItems: Int
}
public enum DeliveryStep: String, Codable, Sendable {
case preparing, pickedUp, onTheWay, arriving, delivered
}
Keep ContentState small. Every property you add gets serialized into every APNs payload, and remember, the total payload ceiling is 4 KB. If you're tempted to stuff a list of items in there, store an ID instead and render the details from the app.
Step 3: Build the Lock Screen and Dynamic Island UI
The ActivityConfiguration wires your ActivityAttributes to four SwiftUI layouts. Here's a complete, production-shaped implementation:
import ActivityKit
import SwiftUI
import WidgetKit
struct DeliveryLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// Lock Screen / paired-device banner
LockScreenView(context: context)
.activityBackgroundTint(Color.black.opacity(0.85))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
// Expanded
DynamicIslandExpandedRegion(.leading) {
Label(context.attributes.restaurantName, systemImage: "bag.fill")
.font(.caption.bold())
}
DynamicIslandExpandedRegion(.trailing) {
Text(etaText(context.state.etaSeconds))
.font(.caption.monospacedDigit())
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.step.displayName)
.font(.headline)
}
DynamicIslandExpandedRegion(.bottom) {
ProgressView(value: context.state.progress)
.progressViewStyle(.linear)
.tint(.orange)
}
} compactLeading: {
Image(systemName: "bag.fill").foregroundStyle(.orange)
} compactTrailing: {
Text(etaText(context.state.etaSeconds))
.monospacedDigit()
} minimal: {
Image(systemName: "bag.fill").foregroundStyle(.orange)
}
.widgetURL(URL(string: "delivery://order/\(context.attributes.orderNumber)"))
.keylineTint(.orange)
}
}
private func etaText(_ seconds: Int) -> String {
let minutes = max(1, seconds / 60)
return "\(minutes)m"
}
}
Every region has a purpose. Minimal is what the system falls back to when another activity is showing alongside yours, so it must identify your app at a glance — usually a single icon is enough. Compact leading/trailing flank the camera notch; keep each under ~44pt wide. Expanded is the four-region layout shown on long-press, and it must fit inside roughly 160pt of height.
The Lock Screen Card
struct LockScreenView: View {
let context: ActivityViewContext<DeliveryAttributes>
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "bag.fill")
.font(.title2)
.foregroundStyle(.orange)
VStack(alignment: .leading) {
Text(context.attributes.restaurantName)
.font(.headline)
Text("Order #\(context.attributes.orderNumber)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(etaText(context.state.etaSeconds))
.font(.title3.monospacedDigit())
}
ProgressView(value: context.state.progress) {
Text(context.state.step.displayName)
.font(.caption)
}
.tint(.orange)
}
.padding()
}
private func etaText(_ seconds: Int) -> String {
"\(max(1, seconds / 60)) min"
}
}
Step 4: Start an Activity from Your App
From the app side, starting an activity is basically one call. Wrap it in an actor-isolated service so the main thread never blocks:
import ActivityKit
@MainActor
final class DeliveryActivityManager {
private var current: Activity<DeliveryAttributes>?
func start(orderNumber: String, restaurant: String, items: Int) async throws {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
throw ActivityError.notAuthorized
}
let attributes = DeliveryAttributes(
orderNumber: orderNumber,
restaurantName: restaurant,
totalItems: items
)
let initialState = DeliveryAttributes.ContentState(
step: .preparing,
etaSeconds: 30 * 60,
driverLocation: "Restaurant",
progress: 0.1
)
let content = ActivityContent(
state: initialState,
staleDate: Date().addingTimeInterval(60 * 15)
)
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token // enables server-driven updates
)
self.current = activity
// Forward the push token to your server
Task.detached {
for await tokenData in activity.pushTokenUpdates {
let hex = tokenData.map { String(format: "%02x", $0) }.joined()
await ServerAPI.shared.registerActivityToken(hex, activityID: activity.id)
}
}
}
enum ActivityError: Error { case notAuthorized }
}
Three things worth flagging. First, areActivitiesEnabled is per-app and per-user, so show an empty-state if it's false. Second, staleDate is the point after which the system dims your card to signal "this data may be old" — always set it conservatively. Third (and this one trips people up), pushTokenUpdates is an AsyncSequence: the token can rotate mid-activity, so you have to listen continuously, not just read it once.
Step 5: Update Locally
func updateStep(_ step: DeliveryStep, etaSeconds: Int, progress: Double) async {
guard let activity = current else { return }
let state = DeliveryAttributes.ContentState(
step: step,
etaSeconds: etaSeconds,
driverLocation: step == .onTheWay ? "In transit" : "Restaurant",
progress: progress
)
let content = ActivityContent(
state: state,
staleDate: Date().addingTimeInterval(60 * 10),
relevanceScore: step == .arriving ? 100 : 50
)
await activity.update(content)
}
relevanceScore controls ordering when the user has more than one Live Activity on screen — higher scores win the top slot.
Step 6: Update Remotely via APNs
Server-driven updates are what make Live Activities feel production-grade. Here's the full remote flow.
APNs Payload Format
{
"aps": {
"timestamp": 1777080000,
"event": "update",
"content-state": {
"step": "onTheWay",
"etaSeconds": 720,
"driverLocation": "2nd Avenue",
"progress": 0.55
},
"stale-date": 1777080900,
"relevance-score": 75,
"alert": {
"title": "Your driver is on the way",
"body": "ETA 12 minutes"
}
}
}
Required headers:
apns-topic: your.bundle.id.push-type.liveactivity
apns-push-type: liveactivity
apns-priority: 10 for immediate delivery, 5 for throttled
apns-expiration: Unix timestamp (use a short window, e.g. 60 seconds)
authorization: bearer <provider JWT>
Node.js Sender
import http2 from "node:http2";
import jwt from "jsonwebtoken";
import fs from "node:fs";
const token = jwt.sign(
{ iss: TEAM_ID, iat: Math.floor(Date.now() / 1000) },
fs.readFileSync("AuthKey_ABC123.p8"),
{ algorithm: "ES256", header: { alg: "ES256", kid: KEY_ID } }
);
const client = http2.connect("https://api.push.apple.com");
const req = client.request({
":method": "POST",
":path": `/3/device/${pushToken}`,
"apns-topic": `${BUNDLE_ID}.push-type.liveactivity`,
"apns-push-type": "liveactivity",
"apns-priority": "10",
"apns-expiration": Math.floor(Date.now() / 1000) + 60,
authorization: `bearer ${token}`,
});
req.setEncoding("utf8");
req.write(JSON.stringify(payload));
req.end();
Ending a Remote Activity
Set event to end and include the final content-state you want shown during the stale window:
{
"aps": {
"event": "end",
"dismissal-date": 1777083600,
"content-state": { "step": "delivered", "etaSeconds": 0, "progress": 1.0 }
}
}
dismissal-date controls when the Lock Screen card disappears. Without it, the system will keep the final state visible for up to four hours.
Step 7: Push-to-Start
Push-to-start lets your backend spin up a brand-new Live Activity on a device without the app even being in the foreground. Register for the push-to-start token once from your app:
Task {
for await tokenData in Activity<DeliveryAttributes>.pushToStartTokenUpdates {
let hex = tokenData.map { String(format: "%02x", $0) }.joined()
await ServerAPI.shared.registerPushToStartToken(hex)
}
}
Do this from your app's launch sequence — not just when the user places an order. The token is per activity type, not per activity instance.
Then your server sends an APNs payload with event: "start" and the full attribute set:
{
"aps": {
"event": "start",
"timestamp": 1777080000,
"attributes-type": "DeliveryAttributes",
"attributes": {
"orderNumber": "A-7189",
"restaurantName": "Tonkotsu Pasadena",
"totalItems": 3
},
"content-state": {
"step": "preparing",
"etaSeconds": 1800,
"driverLocation": "Restaurant",
"progress": 0.1
},
"stale-date": 1777081500
}
}
Heads up: attributes-type must match your Swift type name exactly. Apple delivers the payload, iOS wakes your widget extension, and your ActivityConfiguration renders the initial state. This unlocks scenarios like "user ordered on the web and now sees a Live Activity on their phone" — zero app interaction required. Pretty magical the first time you see it work.
Step 8: Interactive Buttons with App Intents
In iOS 26, Live Activities can contain a Button and Toggle backed by App Intents. Define the intent in a shared framework so both the app and the widget can see it:
import AppIntents
struct CancelDeliveryIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Cancel Delivery"
@Parameter(title: "Order ID")
var orderID: String
init() {}
init(orderID: String) { self.orderID = orderID }
func perform() async throws -> some IntentResult {
try await DeliveryService.shared.cancel(orderID: orderID)
return .result()
}
}
Then add the button to your expanded Dynamic Island region:
DynamicIslandExpandedRegion(.bottom) {
HStack {
ProgressView(value: context.state.progress).tint(.orange)
Button(intent: CancelDeliveryIntent(orderID: context.attributes.orderNumber)) {
Image(systemName: "xmark.circle.fill")
}
.tint(.red)
}
}
LiveActivityIntent (new in iOS 17.2, polished up in iOS 26) tells the system the intent runs inside your app's process rather than in the extension, giving you full access to your stack. The trade-off? The app briefly wakes in the background, so keep perform() under a second.
Step 9: Ending the Activity
func end() async {
guard let activity = current else { return }
let finalState = DeliveryAttributes.ContentState(
step: .delivered,
etaSeconds: 0,
driverLocation: "Delivered",
progress: 1.0
)
let finalContent = ActivityContent(state: finalState, staleDate: nil)
await activity.end(finalContent, dismissalPolicy: .after(.now + 60 * 5))
current = nil
}
There are three dismissal policies. .immediate pulls the card instantly, .default keeps it up for up to four hours, and .after(Date) gives you precise control. For a successful delivery, I find a 5–10 minute window is the sweet spot — long enough for the user to see "Delivered" when they pick their phone up, short enough that it doesn't clutter the Lock Screen.
Step 10: Testing Live Activities
- Simulator: the Dynamic Island renders on iPhone 14 Pro+ simulators, but compact presentations are genuinely hard to judge there — always verify on a real device.
- Local APNs file: Xcode 26 lets you drag a
.apns JSON file onto a running simulator window to simulate a push. Structure matches the APNs payload above, plus a "Simulator Target Bundle" key.
- Stale state: set
staleDate to 10 seconds in the future and watch the card dim. Confirms your layout still reads as "trust me less."
- Low Power Mode: push updates are throttled or dropped outright. Design for eventual consistency, not real-time delivery.
Performance and Battery Rules
- The widget extension has a 30 MB memory cap. Loading a full image library will crash it silently.
- Each activity can last up to 8 hours active, plus 4 hours stale, for 12 hours total. The system ends activities that overshoot this.
- Push updates are rate-limited. If you send dozens per minute, APNs will quietly drop some — batch or debounce on the server.
- Don't animate the Dynamic Island with heavy transitions. The system prefers
.spring(response: 0.3) or built-in shape transitions; custom TimelineView with frequent redraws gets penalized.
Troubleshooting Checklist
- Activity won't start: confirm
NSSupportsLiveActivities in Info.plist, and that the user hasn't disabled Live Activities under Settings → [Your App].
- Widget shows placeholder: your
ActivityAttributes or ContentState isn't Sendable, or target membership is wrong.
- Push updates silently dropped: check APNs response headers. A
400 BadExpirationDate or 413 PayloadTooLarge (over 4 KB) is usually the culprit.
- Interactive button does nothing: the intent must conform to
LiveActivityIntent (not just AppIntent), and the intent type has to be shared between app and widget — via a framework, or by adding it to both targets.
- Dynamic Island compact views overflow: Apple enforces a ~44pt width per compact slot. Use
.monospacedDigit() on timers and truncate long strings.
Frequently Asked Questions
How long can a Live Activity stay on screen?
Active for up to 8 hours, then up to 4 additional hours in a dimmed stale state — 12 hours total. After that, iOS dismisses it regardless of what your server does.
Do Live Activities work on iPhones without Dynamic Island?
Yes. On iPhones older than 14 Pro, the Lock Screen card still appears; only the Dynamic Island presentation is skipped. Always design your Lock Screen view so it stands alone.
What's the size limit for an ActivityKit push payload?
4 KB total, including the aps wrapper and any custom keys. That's exactly why you should store IDs and reconstruct content from the app, rather than embedding item lists or long strings in ContentState.
Can I use Live Activities without a server?
Yep — omit pushType: .token and update the activity locally with activity.update(...). This works for anything driven by the user's device (timers, workouts, on-device tasks), but not for events driven from the cloud like deliveries or rides.
Do Live Activities require push notification permission?
No, they're governed by a separate toggle in your app's Settings page. Users can disable notifications entirely and still see your Live Activities. Just make sure to check ActivityAuthorizationInfo().areActivitiesEnabled before starting one.
How do I test push-to-start without deploying a server?
Use Xcode 26's drag-and-drop APNs simulation: save a .apns file with event: "start" plus the attributes-type, attributes, and content-state keys, then drop it onto a running simulator. Apple's APNsPush web tool also accepts a push-to-start token for on-device testing.
Conclusion
Live Activities aren't a "nice to have" anymore. For any app built around an ongoing event, they're the primary surface users see. iOS 26's push-to-start, interactive buttons, and paired-device display turn them from a widget-like afterthought into a first-class channel — one that can start, update, and complete entirely from the server side.
The hard parts aren't really the APIs. They're the design trade-offs: what to put in ContentState vs. reconstruct on the fly, how often to push, how aggressively to dismiss. My advice? Start with a tight ContentState, a generous staleDate, and a Lock Screen view that reads at arm's length. Everything else, you can iterate on.