●MAX — Rork Max bills itself as the first web Swift app builder, publishing to the App Store in two clicks with no Xcode required●APPLE — It generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro●EXPO — The standard tier builds native iOS and Android apps on React Native (Expo) from a plain-English description●FUNDING — Rork raised $2.8M from a16z, strengthening its position in AI no-code mobile development●PRICE — Free to start, with paid plans from $25/month — an accessible entry point for solo developers●WWDC — WWDC 2026 pushes Apple Intelligence forward, raising the value of native features and widening AI integration options for no-code apps●MAX — Rork Max bills itself as the first web Swift app builder, publishing to the App Store in two clicks with no Xcode required●APPLE — It generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro●EXPO — The standard tier builds native iOS and Android apps on React Native (Expo) from a plain-English description●FUNDING — Rork raised $2.8M from a16z, strengthening its position in AI no-code mobile development●PRICE — Free to start, with paid plans from $25/month — an accessible entry point for solo developers●WWDC — WWDC 2026 pushes Apple Intelligence forward, raising the value of native features and widening AI integration options for no-code apps
Updating Live Activities Remotely: Putting Live Lock Screen Info on a Rork App
A practical design for updating Live Activities remotely through APNs so the Lock Screen and Dynamic Island stay current even when your app is closed. Covers push-to-start vs update tokens, the content-state payload, stale-date and the update budget, and bridging from Expo, with working code and the issues I hit in production.
When I decided to show wallpaper download progress on the Lock Screen, the first thing that tripped me up was a single question: while the app is closed, who actually updates this display? A Live Activity keeps running when your app is in the background, but if the app itself tries to drive the updates, it runs straight into background execution limits.
The answer was to build a path that updates the Live Activity directly from the server over APNs. Get this design wrong and a stale number stays glued to the Lock Screen forever. The approach is the same whether you ship an Expo app generated by Rork or a native Swift app generated by Rork Max. Here is what I learned, in the order it caused trouble.
There are two update paths
A Live Activity can be updated in two ways: locally and remotely. Local updates call Activity.update(...) during the short window your app is in the foreground. They are immediate, but they stop the moment the app sleeps.
Remote updates push to APNs, and the system rewrites the display when it receives them. Because they arrive even when the app is closed, anything that "moves forward outside the app" — progress, scores, a courier's position — needs this path.
Running several apps solo as an indie developer, I settled on shaping the initial display locally for the first few seconds, then sending every continuing update remotely. Mixing the two halfway makes it impossible to know which value is authoritative.
Two tokens: push-to-start and update
The first thing to grasp about remote updates is that there are two kinds of token. One is the push-to-start token, used to start a Live Activity that does not yet exist from the server side. The other is the update token, used to update one specific Activity that is already running.
The push-to-start token is a single token for the whole app, and you subscribe to it at launch. The update token is issued per Activity and arrives asynchronously right after you start one. Confuse the order and you end up able to start an Activity but never deliver a single update afterward.
import ActivityKit@available(iOS 17.2, *)func registerPushToStart() { Task { for await data in Activity<DownloadAttributes>.pushToStartTokenUpdates { let token = data.map { String(format: "%02x", $0) }.joined() // One per app. Register "this user can accept a fresh start" with the server await sendToServer(kind: "start", token: token, activityId: nil) } }}@available(iOS 16.1, *)func observeUpdateToken(for activity: Activity<DownloadAttributes>) { Task { for await tokenData in activity.pushTokenUpdates { let token = tokenData.map { String(format: "%02x", $0) }.joined() // Issued per Activity, and it expires. Overwrite on the server every time await sendToServer(kind: "update", token: token, activityId: activity.id) } }}
The key detail is that pushTokenUpdates is not a one-shot fetch but a stream that emits more than once. Tokens rotate, so if you save the first one and call it done, updates quietly stop a few hours later. Keep the subscription alive and overwrite the server each time one arrives.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Follow which of the two tokens (push-to-start vs update) to fetch in what order, and where to send each to your server, in working Swift and TypeScript
✦Take home an APNs payload design with stale-date, dismissal-date and relevance-score, plus a sense of the numbers that keep you inside the update budget
✦Understand the three production traps (a frozen Lock Screen, a broken Dynamic Island, an expired token) and the exact way around each
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The body of an update rides in the APNs payload's content-state. The values here must map one-to-one to the Activity's ContentState. If the types do not match, the push arrives but the display never moves — the hardest failure to notice.
timestamp is a Unix time in seconds and is the basis the system uses to order new and old pushes. Send a fixed or past value and an update that should have been newer gets judged "stale" and ignored. Put the current time (the equivalent of Date().timeIntervalSince1970) in every push.
event is either update or end. Sending end terminates the Live Activity, and adding dismissal-date alongside it controls how long the activity lingers on the Lock Screen after it ends.
Two constraints: stale-date and the update budget
The design decision that matters most with remote updates is how you handle update frequency. iOS budgets high-frequency Live Activity updates, and if you fire without limit, later pushes simply stop being delivered.
The rule of thumb I use is to leave at least a few tens of seconds between updates in normal operation, and to skip sending entirely when the change is small. Sending only when progress moves by 5% or more outlasts sending 100 times in 1% steps, both in budget and in battery.
stale-date declares "after this time, treat the display as stale." Setting it prevents a "halfway there" display from lingering forever when the server goes silent. Placing it a little past the expected next update — say twice your expected interval — was an easy setting to work with. A missing stale-date is the single most common cause of a frozen Lock Screen.
relevance-score is a value from 0 to 100 that affects display priority when several Activities line up in the Dynamic Island. Set more important progress higher and it stays in the user's view longer.
Bridging from Expo to native
For an Expo app generated by Rork, ActivityKit is not reachable from JavaScript, so you slip in one thin native module. Limit the JS side to just two jobs — telling native to start, and receiving the tokens to send to your server — and the responsibilities split cleanly.
import { NativeModules, NativeEventEmitter } from "react-native";const { LiveActivityBridge } = NativeModules;const emitter = new NativeEventEmitter(LiveActivityBridge);export async function startDownloadActivity(total: number) { // Native runs Activity.request and begins subscribing to the update token await LiveActivityBridge.start({ total });}export function subscribeTokens(onToken: (kind: string, token: string, id?: string) => void) { // Receive tokens streamed from native and forward them to your own server const sub = emitter.addListener("liveActivityToken", (e) => { onToken(e.kind, e.token, e.activityId); fetch("https://api.example.com/live-activity/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(e), }); }); return () => sub.remove();}
With this structure the display layout (SwiftUI in the Widget Extension) and the business logic (the JS side) stay separated, so fixing one rarely breaks the other. When I moved to Rork Max, I only had to replace the JS instruction part with a Swift call — the server-side sending logic carried over unchanged.
Three traps I hit in production
The first is token expiry. As noted, update tokens rotate. Grab the first one and never let go, and updates die silently within hours. Keep the stream subscribed and overwrite the server every time.
The second is a content-state type mismatch. Send the string "128" from the server to a field defined as Int in your Swift ContentState and the push is accepted with a 200 while the display stays put. Fix the types on the sending side too and keep numbers as numbers.
The third is dropping the end. Forget to send the end event and a finished download sits on the Lock Screen. I explicitly built a server-side step that always sends end with a dismissal-date the moment progress is observed at 100%, and the leftover display went away.
When to adopt Live Activities
A closing word on judgment. Live Activities pay off only when there is a single, clearly advancing event happening while the user is outside your app — download progress, a timer, a delivery status.
They are a poor fit for information that barely changes or updates too irregularly, because it is not worth the budget or the implementation cost. Personally, I reserve them for moments where the progress finishes within a few minutes and the user is actively waiting for that completion. Used as a stand-in for an always-on notification, they tend to burn through the update budget for a display nobody watches.
Once you design the remote path in, Live Activities jump into genuinely useful territory — but whether you bake in the two constraints of token management and the update budget from the start makes a large difference to how stable the whole thing feels. I hope this helps anyone who got stuck at the same spot.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.