01The shape of the pipeline
Last March, our release process was a five-day calendar event. Open a PR Monday, merge it Tuesday, cut a build Wednesday, wait for review, push to TestFlight, push to the store, wait for review again. By Friday we were lucky to have shipped one bug fix. Today, the same fix takes under four minutes end-to-end — and the team got smaller, not bigger.
The trick isn't expo-updates by itself, and it isn't Remote Config by itself. It's the way they slot together: expo-updates is the truck that carries new JS bundles to devices, and Remote Config is the dispatcher deciding which truck each device should listen for. Neither tool replaces a native build, but together they cover the 80% of changes that are pure JS — copy tweaks, layout fixes, new feature flags, A/B variants, server-driven UI patches.
Here's the whole pipeline, in the order events fire:
- A PR lands on
main. A GitHub Action runs typecheck, lint, and the unit suite. Total time: ~80 seconds. - The same Action runs
eas update --branch production --auto. EAS publishes the bundle and stamps it with the commit SHA. Total time: ~2 min 10s from push. - The Action calls Firebase Remote Config and flips
ota_channelfor the canary cohort (1% of devices). That cohort starts pulling the new bundle on next launch. - A monitor watches PostHog for the canary's crash rate and JS error rate for 60 seconds. If both stay under threshold, the same Action ramps to 10%, waits 30s, ramps to 100%.
- Devices that fetched the update reload into it on the next backgrounding (or immediately, for the canary 1%). A crash-aware launch wrapper holds onto the previous bundle in case JS init fails.
That's it. There are exactly two humans in this loop: the engineer who opens the PR, and the reviewer who merges it. After merge, no humans are required.
Heads-up: this stack only works for changes that stay inside the JS bundle. The moment you touch native code, Reanimated worklets, or anything in expo-modules-core, you need a fresh native build through EAS — and that means TestFlight + store review. See When NOT to OTA.
02Why Remote Config sits in the middle
The naive setup is: publish to production branch, every device pulls it on next launch. That works for the first ten users. It does not work for ten thousand. A bad bundle reaches everyone at the same time and you find out about it from a Slack DM at 9pm.
We needed three things that a flat "publish and pray" model can't give you:
- Cohorting. Send a new bundle to 1% of devices first, learn from the canary, then ramp.
- Hot-routing. Switch a single user (or a paid-tier segment) onto a beta channel without rebuilding.
- Instant rollback. If the new bundle breaks something subtle, flip one Remote Config value and the next launch reverts every device.
Firebase Remote Config is, frankly, an over-engineered key-value store with cohort rules. But it's already in our app for feature flags, the SDK is well-supported on iOS and Android, and it caches values locally — so it works on the very first launch after install, even if the network is flaky. We piggyback its conditions system (the same one we use for "show new onboarding to users created after April 1") to point a small slice of devices at a different OTA channel.
The single value we read is ota_channel. It defaults to production, and we override it for two conditions:
- A random 1% of installation IDs →
canary - The internal team's user IDs →
staff
Two conditions, one key, four lines of client code. That's the whole "routing layer."
03The client-side update loop
The client-side glue is shorter than you'd expect — about 30 lines that run once after Remote Config has fetched, and again whenever the app comes back from background. Here's the version that's been in production for nine months:
// Run after RC fetch on cold start, and on AppState 'active' transitions. import * as Updates from 'expo-updates'; import remoteConfig from '@react-native-firebase/remote-config'; import { PROJECT_ID } from '../env'; export async function checkForUpdate() { const channel = remoteConfig().getString('ota_channel') || 'production'; // Re-point the runtime if RC moved us to a different channel. await Updates.setUpdateURLAsync({ url: `https://u.expo.dev/${PROJECT_ID}?channel=${channel}`, }); const next = await Updates.checkForUpdateAsync(); if (!next.isAvailable) return; await Updates.fetchUpdateAsync(); // Only auto-reload from a safe state: foregrounded, idle, low crash count. if (crashCount() < 2 && isIdle()) { await Updates.reloadAsync(); } }
Three details that took us a while to land on, and that aren't obvious from the expo-updates docs:
- We call
setUpdateURLAsyncon every check, not just once at boot. Without it, a device that gets promoted fromcanaryback toproductionwould keep pulling canary bundles until the app was killed. - We only reload from an idle state.
isIdle()returns false if the user has touched the screen in the last 8 seconds. Reloading mid-tap reveals a flash of nothing and feels broken. - The crash counter gates the reload, not the fetch. We always download the new bundle — that way, even if the current bundle is crashing, the next cold start picks up the fix automatically.
Tip: reach for Updates.checkForUpdateAsync on foreground, not on a timer. A polling interval drains battery, and most users background the app for hours before opening it. Foreground checks line up perfectly with when the user is paying attention.
04Crash-aware rollback in 60 lines
A bad bundle that crashes during JS init is the worst kind of bug. The app opens, the splash screen fades, and then the JS thread dies before any of your error boundaries get a chance to run. The user sees a white screen. You see nothing, because none of your analytics SDKs have booted either.
The fix is a counter persisted to MMKV (a synchronous on-device key-value store — important here because we need to read it before React even mounts). The launch sequence:
- Read
crashCountfrom MMKV. Increment it. Persist immediately. - Render the app.
- If the app reaches its first idle frame (we use
InteractionManager.runAfterInteractionsas a cheap proxy), resetcrashCountto 0. - If on the next launch the counter is already at 2, we don't load the embedded update at all — we call
Updates.clearUpdateCacheExperimentalAsync()and reboot into the prior known-good bundle.
import { MMKV } from 'react-native-mmkv'; import * as Updates from 'expo-updates'; import { InteractionManager } from 'react-native'; const store = new MMKV({ id: 'crash-guard' }); const CRASH_LIMIT = 2; export function guardLaunch() { const count = store.getNumber('count') ?? 0; if (count >= CRASH_LIMIT && Updates.isEmbeddedLaunch === false) { // We've crashed twice in a row on an OTA bundle. Nuke it. Updates.clearUpdateCacheExperimentalAsync().then(() => Updates.reloadAsync(), ); return; } store.set('count', count + 1); return () => { // Called once the app reaches first idle. Reset the counter. InteractionManager.runAfterInteractions(() => { store.set('count', 0); }); }; } export function crashCount() { return store.getNumber('count') ?? 0; }
This has fired in production five times over fourteen months. Three were our fault: an undefined reference in a new screen, a missing translation key crashing on Android, a bad cast in a freshly-typed config parser. Two were Reanimated 3.x regressions that snuck through because the JS surface didn't change but the worklet runtime did. In all five cases, every device self-healed before we'd finished writing the Slack message.
Caveat: clearUpdateCacheExperimentalAsync is, well, experimental — the API may move in a future SDK. We pin expo-updates exactly and re-test the path on every SDK bump. Don't ship this to a million devices without rehearsing the rollback on a staging cohort first.
05Staged rollout: 1% → 10% → 100%
The ramp is the part that surprised me most: we initially thought we'd need a release manager, a dashboard, and a Slack bot. We needed forty lines of YAML.
The GitHub Action that calls eas update also calls Firebase's REST API to flip the ota_channel condition. It does this in three phases, with a thirty-second pause between each, checking PostHog for canary crash rate in the gap.
The thresholds we use:
- Canary (1%) → 10%: canary must have ≥ 50 sessions and crash-free rate ≥ 99.5% for the last 60 seconds.
- 10% → 100%: same crash threshold, plus JS error rate within 0.5σ of the previous bundle.
- Any failure: the Action does not roll back automatically. It pages me, freezes the ramp at the current cohort, and lets the canary keep running. Rollback is one click in the Firebase console.
We deliberately don't auto-rollback because we've found that most "this looks bad" signals are noise from a single noisy device or a flaky network. A pause + human glance is a better default than reverting the bundle the engineer just shipped.
06What we measure (and why)
For about six months I tracked the wrong number — "how many devices have downloaded the new bundle." It's the easy metric: expo-updates emits an event when the fetch succeeds. It is also the wrong one. A device that downloaded the bundle but hasn't reloaded into it yet is still running the old code, and still vulnerable to the bug you just fixed.
The metric that actually matters is the one we call p50 device adoption: the time between bundle publish and the moment 50% of active sessions are reporting the new commit SHA. We compute it nightly in BigQuery from PostHog events. The query is ugly — five CTEs, a self-join on session start — but the chart it produces is the single best signal of "is this rig working?" Right now p50 sits at 38 minutes. Last year it was 6.3 days (the time between two store releases). That's a 240× improvement.
The other three things we watch:
- Crash-free session rate, bucketed by bundle SHA. Two bundles up at the same time are normal during a ramp. We always plot them separately.
- Rollback firings. Every time
crashCounthits 2 and the guard activates, a Sentry breadcrumb is emitted from the prior-known-good launch. We want this number to stay non-zero (it proves the guard works) but bounded — anything above five per week per 10k DAU is a sign we've got a real bug. - Time-to-revert. From the moment we say "this bundle is bad" in Slack to the moment Firebase Remote Config has flipped
ota_channel. Currently it's ~14 seconds on a good wifi day.
07Things we'd do differently
If I were starting this over today, three changes I'd make on day one:
- Skip Firebase, use ConfigCat or our own KV. Firebase Remote Config has a 12-hour client cache by default that we spent an embarrassing week fighting. The fix (setting
minimumFetchIntervalMillis: 0for dev builds and30_000for prod) is one line, but discovering it required a debugger session at 2am. A purpose-built feature-flag service would have told us about this in the README. - Pin
runtimeVersionto a manifest, notappVersion. Tying the runtime version to the user-visible app version means any version bump invalidates every OTA bundle. We now ship aruntimeVersion.jsonin source control and bump it only when native code actually changes. This is covered in detail in Versioning hell. - Build the canary dashboard before launch, not after. For the first two months we watched canary health by tail-ing PostHog. A 200-line static dashboard would have caught two regressions sooner and saved a weekend.
Everything else holds up. The rig is now in its 14th month of production, has shipped 1,847 OTA bundles to ~12k DAU, has rolled itself back five times, and has paged me exactly once. I'd take that trade every time.
If you build something similar, write me — I want to compare crash-guard thresholds. There's an email at the bottom of every page, and a newsletter for the cliffsnotes version of next month's writing.