Beyond the App Boundary: Engineering Real-Time iOS Live Activities and Dynamic Islands with Expo
In this post, I share my architectural journey and practical breakthroughs in building real-time iOS Live Activities and Dynamic Islands within a managed Expo pipeline. Learn how to bridge the React Native runtime with Swift's ActivityKit without ejecting, using custom config plugins and efficient push token handshakes.

For years, the boundary of a React Native application was cleanly demarcated by the screen edges of your running app. If the user swiped home, your UI thread slept, and your connection with the user dwindled to static push notifications. But with the release of iOS 16.1, Apple opened up a high-value canvas: Live Activities and the Dynamic Island.
When we decided to bring real-time, glanceable tracking to our Expo-based product, the common wisdom was: "You have to eject to bare React Native. Expo can't handle native widget extension targets cleanly."
I’m writing this to tell you that the common wisdom is wrong. Not only can you build rich, real-time Live Activities within the managed Expo ecosystem, but you can also maintain a clean, single-command EAS build pipeline.
Here is how I engineered our bridge beyond the app boundary, the architectural hurdles we cleared, and the code you need to do it yourself.
The Architectural Blueprint
To make Live Activities work, we have to bridge two completely different paradigms:
- The JavaScript/React Native Runtime: Where your business logic, state, and API polling/websockets live.
- The iOS Widget Extension: A lightweight, highly sandboxed native target written in Swift and SwiftUI. It does not run JS. It operates on a strict timeline-rendering model or reacts to remote APNs push payloads.
Here is how the data flows under our architecture:
+----------------------+ Expo Bridge +--------------------------+
| React Native Engine | ------------------------> | ActivityKit Native Module|
| (TypeScript App) | <------------------------ | (Manages Lifecycle & Tok)|
+----------------------+ +--------------------------+
|
APNs v
+----------------------+ +--------------------------+
| Our Backend | =======================> | iOS Dynamic Island / |
| (Node/Go Service) | (High-Priority Push) | Live Activity UI (Swift) |
+----------------------+ +--------------------------+
There are two ways to update a Live Activity: Locally (via a native bridge method from JS) and Remotely (via APNs). If your app is in the background, local updates are throttled or suspended. Therefore, for true real-time tracking (like a delivery or ride-share), APNs-driven updates are non-negotiable.
Step 1: The Magic of Expo Config Plugins
The biggest challenge in Expo is that a Widget Extension is a separate target in the generated Xcode project. To avoid manual modification of ios/, we must write an Expo Config Plugin to generate this target, copy our Swift code, configure the Info.plist, and link the necessary frameworks during the prebuild phase.
Here is a simplified look at how I structured our custom config plugin (plugins/withLiveActivities.js):
Using this plugin, when our CI/CD calls eas build, Expo automatically configures our Xcode workspace with the multi-target structure. No manual Xcode clicking required.
Step 2: Designing the Swift Contract (The Attribute & UI)
We need to define the "contract" or shape of the data that both our React Native side and the APNs server will send. In Swift, this is done by defining an ActivityAttributes struct.
Create ios/LiveActivityWidget/LiveActivityWidget.swift:
Keep in mind the Dynamic Island has strict geometry specifications. Design your compact and minimal views defensively; if your text is too long, iOS will clip it mercilessly.
Step 3: Bridging React Native to ActivityKit
Now, how do we trigger this from JavaScript? We must write a Swift Native Module to interact with ActivityKit. This module must handle starting the activity, passing initial state, and returning a Push Token back to React Native so our backend can send live updates.
Here's the core of our Swift Native Module bridge (ios/ActivityBridge/ActivityBridge.swift):
On the TypeScript side, we import this module and listen for the event. Once we receive the push token, we send it directly to our API server:
Step 4: Pushing Real-Time Updates from Your Backend
When you target Live Activities via APNs, you aren't sending a standard push notification payload. You must format it precisely, targeting the apns-push-type header as liveactivity and matching the Swift ContentState JSON structure exactly.
Here is a raw representation of the payload your Node/Go backend must send to Apple's APNs endpoint:
Crucial Gotcha: The HTTP headers are just as important as the payload. When writing your backend dispatching code, ensure you set:
apns-push-type:liveactivityapns-topic:<Your-App-Bundle-ID>.push-type.liveactivities(Note the suffix!)apns-priority:10(If you want instant execution without system throttling)
Hard-Won Lessons from Production
If you decide to execute this architecture, there are a few bottlenecks that I learned the hard way. Save yourself the debugging hours:
- The 4KB Payload Limit: Your remote updates have a hard limitation of 4KB. Do not pass large base64 image strings or sprawling JSON trees to the Live Activity. Keep payloads lean and strictly primitive.
- Image Handshakes: If you need to display dynamic user avatars or map thumbnails, you cannot send them via the push payload. Instead, write them to an App Group shared container (
UserDefaults/ shared group directory) from your React Native code while the app is active, and read them inside Swift using custom view rendering. - Push Token Life Cycle: Push tokens are ephemeral. If a user force-closes the app, or if iOS decides to recycle resources, a new token is generated. Your API must handle multiple tokens for a single Live Activity, merging them gracefully.
- Handling Stale States: Always provide a sensible
staleDateor let the backend send anendevent. Leaving a "stuck" delivery activity on a user's lock screen hours after the delivery has completed is a swift way to get uninstalled.
Wrapping Up
By leveraging Expo Config Plugins to handle the target generation and utilizing Expo Modules API for clean Swift-to-JS bridge typing, we built a production-grade Live Activity architecture without compromising on the DX of our managed workflow.
We didn't have to sacrifice our fast development cycles, nor did we have to manage custom Xcode configurations within Git. The boundary of the app has truly expanded, and keeping your users engaged is now just a matter of structured Swift structs and high-priority push handshakes.