●MAX — Rork Max builds native Swift apps rather than React Native, spanning iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●APIS — It reaches native Apple APIs including SwiftUI, ARKit, HealthKit, HomeKit, Core ML, and Metal, so AR/LiDAR scanning and on-device ML are in scope●CLOUD — Rork Max writes Swift, compiles it on cloud-hosted Macs, and streams a live simulator that accepts real touch input●SUBMIT — From there it prepares the build for device install or App Store submission●SEED — Rork raised a $15M seed led by Left Lane Capital in April, joined by Peak XV and a16z Speedrun●PRICE — It's free to start at roughly 5 prompts a week, paid plans begin at $25/month, and Rork Max runs $200/month●MAX — Rork Max builds native Swift apps rather than React Native, spanning iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●APIS — It reaches native Apple APIs including SwiftUI, ARKit, HealthKit, HomeKit, Core ML, and Metal, so AR/LiDAR scanning and on-device ML are in scope●CLOUD — Rork Max writes Swift, compiles it on cloud-hosted Macs, and streams a live simulator that accepts real touch input●SUBMIT — From there it prepares the build for device install or App Store submission●SEED — Rork raised a $15M seed led by Left Lane Capital in April, joined by Peak XV and a16z Speedrun●PRICE — It's free to start at roughly 5 prompts a week, paid plans begin at $25/month, and Rork Max runs $200/month
Growing a Staged OTA Update System Without Breaking It
Shipping an EAS Update to every user at once is dangerous. From channel design to staged rollout to automatic rollback, here is the delivery architecture I settled on across 50M cumulative downloads, with working code.
The scariest thing about over-the-air (OTA) updates is the default behavior: the moment you press publish, every user's device reaches out for the new bundle. Running a family of apps with 50 million cumulative downloads, the night that chilled me most was exactly this — an "instant, full-fleet" push where a broken JavaScript bundle landed on tens of thousands of devices at once. The upside of OTA is that you can fix an app without going through store review. The flip side is that broken code also spreads instantly, with no review to catch it.
EAS Update (Expo's OTA infrastructure) is excellent, but its default usage is simple: publish to a branch, and devices watching that channel fetch it on their next launch. That is fine while you are small. As your user base grows, "everyone, immediately" becomes the risk itself. In this article I'll show how to turn that instant full-fleet push into a 5% to 25% to 100% staged rollout, and then add automatic rollback so a device that grabbed a broken bundle returns to the previous version on its own — all with code I actually run in production.
Why "channel = environment, branch = content"
EAS Update has two concepts, channel and branch, and getting this design wrong up front guarantees pain later. My mental model is simple: a channel is an "environment label" baked into the build; a branch is the actual code content you deliver. A native binary watches a channel, and a channel can be repointed at any branch. That layer of indirection is the foundation for both staged rollout and rollback.
// eas.json — design production as a delivery lane, not a single fixed channel{ "cli": { "version": ">= 12.0.0" }, "build": { "production": { "channel": "production", "autoIncrement": true }, "production-canary": { "extends": "production", "channel": "production-canary" } }, "submit": { "production": {} }}
The key move is to bake the production channel into the production build, but at delivery time point production at a versioned branch like production-v1.4.0. With this in place, rolling back is just "repoint the channel at the previous branch," and every device returns to the old bundle on its next launch. You don't re-publish a new bundle to overwrite — you change the pointer. That single difference decides how fast you can respond to an incident.
Implementing 5% to 25% to 100% with rollout
EAS Update offers --rollout-percentage, which controls what share of devices on a channel see a new branch. In my operation, I push a new bundle to 5% first, then raise it in stages while watching crash and adoption rates.
#!/usr/bin/env bash# scripts/staged-publish.sh — fold staged delivery into one commandset -euo pipefailBRANCH="production-$(date +%Y%m%d-%H%M)"MESSAGE="${1:?commit message required}"# 1. Publish to a fresh branch (no one sees it yet)eas update --branch "$BRANCH" --message "$MESSAGE" --non-interactive# 2. Point the production channel at the new branch for only 5% of deviceseas channel:edit production \ --branch "$BRANCH" \ --rollout-percentage 5 \ --non-interactiveecho "✅ Shipped $BRANCH to 5%."echo " In 24h, run scripts/promote.sh $BRANCH 25"
#!/usr/bin/env bash# scripts/promote.sh — promote after observing. Percentage as an argument.set -euo pipefailBRANCH="${1:?branch required}"PCT="${2:?percentage required}"eas channel:edit production \ --branch "$BRANCH" \ --rollout-percentage "$PCT" \ --non-interactiveecho "✅ Promoted $BRANCH to ${PCT}%."
Two numbers always gate my promotions. First, adoption rate — the share of targeted devices that actually fetched and applied the new bundle. If, 24 hours after shipping, fewer than 70% of the rollout cohort has picked it up, I suspect the delivery path or the bundle size. Second, the crash-rate delta between old and new; if the new bundle's crash rate is more than 20% worse than the old one, I don't promote. At 50M-download scale, even 5% is hundreds of thousands of devices, so a statistically meaningful sample arrives within 24 hours. If your app is small, 5% won't give you enough signal — start the first stage closer to 20%.
✦
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
✦A minimal staged-delivery setup (5% to 25% to 100%) using eas.json plus separated channels/branches, in code you can use as-is
✦How to implement a boot-time health check and rollback that sends a device stuck on a broken bundle back to the previous version on its own
✦Concrete thresholds for adoption and crash rates over the first 24 to 72 hours, and exactly when to promote versus when to roll back
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.
Letting a device on a broken bundle roll itself back
Even with staged delivery, that 5% can grab a bundle that crashes on launch. If you roll back on the server, devices that already stored the broken bundle will keep relaunching and crashing until they manage to fetch a healthy one. So I add a health check that records whether the previous launch completed cleanly, and rolls back to the old bundle on its own if it detects a launch-time crash loop.
// src/lib/updateHealthGuard.tsimport * as Updates from 'expo-updates';import AsyncStorage from '@react-native-async-storage/async-storage';const PENDING_KEY = 'update.pendingVerify';const FAIL_COUNT_KEY = 'update.bootFailCount';const MAX_BOOT_FAILS = 2;// Call at the very start of app boot (before rendering)export async function guardOnBoot(): Promise<void> { if (__DEV__ || !Updates.isEnabled) return; const currentId = Updates.updateId ?? 'embedded'; const pending = await AsyncStorage.getItem(PENDING_KEY); // This bundle was "awaiting verification" — last boot failed and we grabbed the same one if (pending === currentId) { const fails = Number(await AsyncStorage.getItem(FAIL_COUNT_KEY) ?? '0') + 1; await AsyncStorage.setItem(FAIL_COUNT_KEY, String(fails)); if (fails >= MAX_BOOT_FAILS) { // Repeated boot failures -> force rollback toward a healthy bundle await AsyncStorage.multiRemove([PENDING_KEY, FAIL_COUNT_KEY]); await Updates.fetchUpdateAsync().catch(() => {}); await Updates.reloadAsync(); // next time the server returns a healthy branch, take it return; } } else { // Grabbed a new bundle -> record it as awaiting verification await AsyncStorage.setItem(PENDING_KEY, currentId); await AsyncStorage.setItem(FAIL_COUNT_KEY, '0'); }}// Call once you reach real initialization (navigation built, first API response)export async function markBootHealthy(): Promise<void> { await AsyncStorage.multiRemove([PENDING_KEY, FAIL_COUNT_KEY]);}
// App.tsx — guard at the very top, healthy-mark at "rendering succeeded"import { useEffect } from 'react';import { guardOnBoot, markBootHealthy } from './src/lib/updateHealthGuard';export default function App() { useEffect(() => { guardOnBoot(); // rollback decision before rendering }, []); return ( <AppNavigator onReady={() => { // navigation actually came up = this bundle is alive markBootHealthy(); }} /> );}
The crux is where you call markBootHealthy(): at the point where the app has actually started doing something useful. Place it not right after App mounts, but in navigation's onReady or after the first screen's data load succeeds — that way you also catch the "JS booted but dies instantly during render" class of bug. The official docs explain the expo-updates API well, but where to put the definition of a healthy boot is something you can only decide in operation. I only pushed mine all the way down to the render-complete point after it bit me.
A 72-hour post-ship monitoring table
Staged rollout is not "ship and forget" — each stage comes with the monitoring you need to make the next decision. Here are the thresholds I actually use.
Ship to 6h: monitor the new bundle's crash rate above all else. If it exceeds +20% versus the old bundle, immediately repoint the channel back to the old branch (and do not promote).
6h to 24h: check adoption. Below 70% of the cohort, suspect bundle size (is the diff too large?) and the delivery CDN.
24h onward: if crash rate is at or below parity and adoption is 70%+, promote 5% to 25%.
Hold the same conditions at 25% for 48h: promote to 100%.
Keep monitoring for 24h even after reaching 100%. Some crashes tied to specific devices or OS versions only surface at full fleet.
One number: in my operation, rollouts whose crash rate did not worsen in the first six hours needed a rollback later less than 10% of the time. Put differently, the first six hours are where it's worth spending the most human attention; after that, automated threshold checks carry the load.
Where to start
If you're already shipping full-fleet with EAS Update, the first thing to do is migrate to "point the channel at a versioned branch." That requires zero code changes — you only change how you operate eas channel:edit, and rollback becomes a single command to repoint. Add the --rollout-percentage staging and the health check in the stage after that. Don't try to land all of it at once; just change the one thing today — stop shipping instantly to everyone.
If this spares even one late-night incident for someone running a large app solo, I'll be glad. Thank you for reading.
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.