React Native Live Activities with Expo: Dynamic Island and ActivityKit in 2026
Ship iOS Live Activities and Dynamic Island from an Expo app: Swift widget extension, ActivityKit bridge, APNs push updates, and Android fallback patterns I've used in production.
React Native Live Activities are iOS 16.1+ widgets that display real-time data on the Lock Screen and inside the Dynamic Island, and in 2026 you can ship them from an Expo app by combining Apple's ActivityKit framework with a native widget extension exposed through an Expo config plugin. This guide is the path I'd take today on a production app: a single Live Activity module, a Swift widget bundle, and APNs push updates that keep the UI alive when the JS thread is suspended. I've shipped this on two apps and have opinions about which parts to skip.
Live Activities are an iOS-only feature (16.1+); Android has no equivalent and you'll need a foreground service for parity.
You need a Swift Widget Extension target. JavaScript alone cannot render a Live Activity. Expo config plugins generate the Xcode wiring automatically.
The activity attributes (static) and content state (dynamic) are separate Swift structs that must mirror the data you send from JS.
Use APNs Live Activity push updates for server-driven changes; Activity.update() only works while the app is foregrounded.
Each Live Activity is capped at 12 hours active, then 4 hours in a "stale" state, so design content states with that ceiling in mind.
Dynamic Island has three presentations (compact, minimal, expanded) and you must build all three or the layout falls back to a system default.
What are Live Activities and how do they work?
A Live Activity is a SwiftUI view backed by the ActivityKit framework that renders on the Lock Screen and, on devices with a Dynamic Island, in the cutout at the top of the display. Apple introduced the API in iOS 16.1 and expanded it in iOS 17 with interactive buttons (App Intents) and in iOS 18 with broadcast and Smart Stack support. They are designed for ephemeral, time-sensitive information — a food delivery ETA, a sports score, a workout timer, a ride share location, not for general notifications.
The architecture is split deliberately. Your JS code starts the activity and supplies an initial state. iOS hands the state to your Swift widget extension, which renders the UI. From that point on, two channels can update the content: a foreground Activity.update(using:) call from inside the app, or an APNs push payload with "event": "update" that reaches the device even when the app is suspended or killed. The system enforces hard limits: 12 hours of active runtime, 4 additional hours in a stale-but-visible state, then dismissal. Plan your activity around the ceiling, not around it.
Expo prerequisites for Live Activities in 2026
You need three things in place before any Swift code goes near the project: Expo SDK 53 or later (54 is current as of June 2026), a development build of your app (Expo Go does not load custom widget extensions), and EAS Build configured for a recent Xcode toolchain. I run SDK 54 with expo-modules-core 2.x because earlier versions had a stale-cache bug when the Swift widget bundle changed without a JS file changing.
Two community plugins do most of the heavy lifting in 2026: @bacons/apple-targets for generating the widget extension target, and either expo-live-activity or a hand-rolled Expo Module for the ActivityKit bridge. I lean toward writing the module myself because the surface area is small (start, update, end, list) and you almost always end up customizing it. If your team has not built an Expo Module before, our walkthrough on building native modules with Turbo, Expo, and Nitro covers the scaffolding.
The widget extension is a separate Xcode target that ships inside your main app bundle. With @bacons/apple-targets you declare it in targets/widget/expo-target.config.js and the plugin generates the Info.plist, signs the target, and embeds it on every prebuild. The actual UI lives in Swift files inside that target folder. Here is a minimal attributes struct plus widget for a delivery tracker.
// targets/widget/DeliveryAttributes.swift
import ActivityKit
import SwiftUI
import WidgetKit
struct DeliveryAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var etaMinutes: Int
var stage: String // "preparing" | "out_for_delivery" | "arriving"
var courierName: String
}
var orderId: String // static, never changes mid-activity
var restaurantName: String
}
struct DeliveryLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// Lock Screen / banner presentation
LockScreenView(context: context)
.activityBackgroundTint(Color.black.opacity(0.8))
.activitySystemActionForegroundColor(Color.white)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) { Text(context.attributes.restaurantName) }
DynamicIslandExpandedRegion(.trailing) { Text("\(context.state.etaMinutes) min") }
DynamicIslandExpandedRegion(.bottom) { Text(context.state.stage.capitalized) }
} compactLeading: {
Image(systemName: "bag.fill")
} compactTrailing: {
Text("\(context.state.etaMinutes)m")
} minimal: {
Image(systemName: "bag.fill")
}
}
}
}
The split between ActivityAttributes (immutable for the life of the activity) and ContentState (the part you update) is the single most important design decision. Anything you change after start must live in ContentState; anything you put in the outer struct is locked in at Activity.request time.
Bridging ActivityKit with an Expo Module
The Expo Module exposes four functions to JS: start, update, end, and list. Swift's Activity<DeliveryAttributes>.request returns an Activity instance whose id is the handle you'll use for later updates, so the module needs to hand that string back to JS and keep activities addressable.
// modules/live-activity/ios/LiveActivityModule.swift
import ExpoModulesCore
import ActivityKit
public class LiveActivityModule: Module {
public func definition() -> ModuleDefinition {
Name("LiveActivity")
AsyncFunction("start") { (orderId: String, restaurantName: String,
etaMinutes: Int, stage: String, courierName: String) -> String in
let attributes = DeliveryAttributes(orderId: orderId, restaurantName: restaurantName)
let state = DeliveryAttributes.ContentState(
etaMinutes: etaMinutes, stage: stage, courierName: courierName)
let content = ActivityContent(state: state, staleDate: nil)
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token // request an APNs push token for server updates
)
// Hand the push token back to JS via an event when it arrives
Task {
for await tokenData in activity.pushTokenUpdates {
let token = tokenData.map { String(format: "%02x", $0) }.joined()
self.sendEvent("onPushTokenUpdate", ["activityId": activity.id, "token": token])
}
}
return activity.id
}
AsyncFunction("update") { (id: String, etaMinutes: Int, stage: String) async in
guard let activity = Activity.activities.first(where: { $0.id == id })
else { return }
let state = DeliveryAttributes.ContentState(
etaMinutes: etaMinutes, stage: stage, courierName: activity.content.state.courierName)
await activity.update(ActivityContent(state: state, staleDate: nil))
}
AsyncFunction("end") { (id: String) async in
guard let activity = Activity.activities.first(where: { $0.id == id })
else { return }
await activity.end(nil, dismissalPolicy: .immediate)
}
Events("onPushTokenUpdate")
}
}
Starting, updating, and ending an activity from JS
With the module in place, the JS API is just a thin wrapper. I keep it inside a hook so the screen that owns the activity also owns the lifecycle and tear-down. Honestly, it's far too easy to leak an activity if you sprinkle start and end across unrelated components.
// hooks/useDeliveryActivity.ts
import { useEffect, useRef } from "react";
import { LiveActivity } from "../modules/live-activity";
export function useDeliveryActivity(order: Order | null) {
const idRef = useRef(null);
useEffect(() => {
if (!order) return;
let cancelled = false;
(async () => {
const id = await LiveActivity.start(
order.id, order.restaurantName,
order.etaMinutes, order.stage, order.courierName
);
if (cancelled) {
await LiveActivity.end(id);
return;
}
idRef.current = id;
})();
const sub = LiveActivity.addListener("onPushTokenUpdate", ({ activityId, token }) => {
// POST to your backend so it can push updates to this specific activity
fetch("/api/live-activities/register", {
method: "POST",
body: JSON.stringify({ orderId: order.id, activityId, pushToken: token }),
});
});
return () => {
cancelled = true;
sub.remove();
if (idRef.current) LiveActivity.end(idRef.current);
};
}, [order?.id]);
}
Foreground updates from JS are fine for testing but unreliable in production. The moment your app is backgrounded, Activity.update() cannot fire. Treat the JS update path as a development convenience and the APNs push path as the real source of truth.
Designing the three Dynamic Island layouts
The Dynamic Island has three discrete presentations you must each implement: compact (a small badge on either side of the cutout when your app is the only Live Activity), minimal (a single icon when sharing the cutout with another activity), and expanded (a full SwiftUI layout when the user long-presses). Skip any one of these and the system substitutes a placeholder that looks broken.
Apple's safe-area rules for the expanded view are unintuitive: the .leading and .trailing regions sit either side of the camera cutout, the .center region appears below the cutout, and the .bottom region spans the full width underneath. Putting too much content in .leading or .trailing causes the system to truncate without warning. Treat .bottom as your primary content area and the side regions as glanceable labels.
// Compact + minimal must be ~16pt of content (measure with the simulator)
compactLeading: {
Image(systemName: "bag.fill").foregroundColor(.orange)
},
compactTrailing: {
Text("\(context.state.etaMinutes)m")
.monospacedDigit() // prevents jitter as minutes tick down
.font(.system(size: 14, weight: .semibold))
},
minimal: {
// Used when sharing the island with another activity. Single glyph only.
Image(systemName: "bag.fill").foregroundColor(.orange)
}
Sending Live Activity push updates from your server
The push token you collected in pushTokenUpdates is sent to APNs with the liveactivity push type and a payload that mirrors your Swift ContentState. The endpoint and headers differ from a normal notification, and the apns-topic header must end in .push-type.liveactivity, which is the most common cause of silently-dropped updates.
// Node.js example using node-apn (sending an update push)
const apn = require("node-apn");
const provider = new apn.Provider({
token: { key: "AuthKey_XYZ.p8", keyId: "...", teamId: "..." },
production: true,
});
async function sendDeliveryUpdate(pushToken, etaMinutes, stage) {
const note = new apn.Notification();
note.topic = "com.example.app.push-type.liveactivity"; // .push-type.liveactivity required
note.pushType = "liveactivity";
note.priority = 10; // 10 = immediate
note.rawPayload = {
aps: {
timestamp: Math.floor(Date.now() / 1000),
event: "update", // or "end"
"content-state": { etaMinutes, stage, courierName: "Alex" },
"stale-date": Math.floor(Date.now() / 1000) + 600, // mark stale after 10 min
},
};
return provider.send(note, pushToken);
}
If you're already sending standard push notifications, the routing logic (which device, which topic, which credentials) is the same plumbing. Our guide on React Native push notifications with Expo covers the certificate setup that applies here too.
Android parity: what to ship instead
Android has no Live Activities equivalent. Material You added "Live Updates" notifications in Android 14+ (an expanded notification template with progress and inline actions), but the API surface is far narrower than ActivityKit: no Lock Screen widget, no Dynamic Island analogue, no push-driven background updates without a foreground service.
For most apps the pragmatic move is to ship a Foreground Service with an ongoing notification on Android and a Live Activity on iOS, behind a shared JS interface. The notification can update its progress and chronometer in real time, which covers the most common Live Activity use case (a running timer or ETA) without you having to special-case the UI in three places. Our React Native background tasks with Expo guide covers the foreground-service permission model and the gotchas around Android 14's restrictions on starting them from the background.
Common pitfalls and how to debug them
The single most common bug is forgetting to add the widget extension to your provisioning profile after enabling Live Activities, which produces an opaque "request failed" error with no underlying cause. Open the device's Console.app while reproducing. ActivityKit logs are verbose and will tell you whether the failure is a missing entitlement, an invalid attributes struct, or an exceeded budget. A few more I've hit on real apps:
Activity starts but never updates from push. Check that apns-topic ends in .push-type.liveactivity; the default topic from your normal push setup will not work.
Dynamic Island shows a system placeholder. One of compactLeading, compactTrailing, minimal, or any of the expanded regions is missing or returning an empty view.
Updates work in debug, silent in TestFlight. Production APNs uses api.push.apple.com; sandbox uses api.sandbox.push.apple.com. TestFlight builds use production.
ETA jitters as it counts down. Wrap any numeric label in .monospacedDigit() or render with Text(timerInterval:) so the layout doesn't reshuffle every second.
Activity is dismissed after 8 hours, not 12. iOS reduces the budget if the device is on low power or if your app has been backgrounded for the entire window. Don't assume 12 hours; design for graceful end.
Frequently Asked Questions
Can you build Live Activities with Expo Go?
No. Live Activities require a custom iOS widget extension target, which means you have to use an Expo development build (via EAS Build or a local prebuild). Expo Go ships a fixed binary and cannot load custom Swift code.
How long can a Live Activity stay on the screen?
iOS allows up to 12 hours of active runtime, after which the activity becomes "stale" and remains visible for up to 4 more hours before the system dismisses it. Low Power Mode and long backgrounding can shorten that window.
Does Dynamic Island work on every iPhone?
No. Dynamic Island is hardware-specific to iPhone 14 Pro and later Pro models, plus all iPhone 15, 16, and 17 models. Devices without it still show the Live Activity on the Lock Screen, so your widget code runs everywhere from iOS 16.1+.
Why is my Live Activity push update not arriving?
The most common cause is the apns-topic header missing the .push-type.liveactivity suffix. The push will be silently dropped with no error returned to your server. Check the topic, then verify you're posting to the correct APNs environment (sandbox vs production) for the build.
Is there an Android equivalent to Live Activities?
Not directly. Android 14+ added "Live Updates" expanded notifications and Wear OS Tiles, but neither offers the Lock Screen widget or push-driven background updates Live Activities provide. Most teams ship a Foreground Service with an ongoing notification on Android as the closest analogue.
Ship real-time ML Kit face detection and OCR in an Expo development build using React Native Vision Camera v5 frame processors, runAsync, and worklets.
Install and configure expo-image in React Native: native caching, Blurhash and Thumbhash placeholders, WebP/AVIF, prefetching, and a benchmark vs FastImage.