●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Rork × EAS Update Runtime Version Strategy — Upgrading Expo SDK Across 6 Apps Without Breaking Existing Users
A complete record of how I migrated 6 Rork-generated apps from Expo SDK 50 to 51 in three weeks without a single user-visible incident — runtimeVersion policies, full eas.json, a safety-gated publish script, and a 30-minute incident recovery playbook.
I'm Masaki Hirokawa. I operate six mobile apps in parallel under Dolice Labs, and the single most misunderstood — and most accident-prone — Expo setting I've worked with is runtimeVersion.
This became viscerally clear in April 2026, one day after I bumped one of our Rork-generated wallpaper apps from Expo SDK 50 to 51. The build worked in my simulator. It worked in TestFlight. Yet within hours of releasing, three users still on the App Store-shipped SDK 50 build reported, "the icon layout changed overnight and my favorites are gone." The cause was simple: the JS bundle I built for SDK 51 was published via eas update --branch production to the same runtimeVersion the SDK 50 binaries were listening on.
The official docs say "make sure runtimeVersion matches," but they stop short of explaining what not matching looks like and how to keep yourself from making that mistake again. As one indie developer who has shipped over 50 million downloads since 2014, here is the full setup I now use to keep upgrading SDKs across six apps without breaking anyone.
Why Runtime Version Is the Quiet Landmine
EAS Update delivers JS bundles and assets to users without going through App Store / Google Play review. But the delivery target isn't really "a branch" — it's "any installed binary that shares the same runtimeVersion." Three facts converge here:
If you use runtimeVersion: "appVersion", the runtimeVersion changes whenever you bump app.jsonversion. Users on older versions stop receiving OTAs. Conversely, if you upgrade the SDK without bumping version, you ship a JS bundle built for the new SDK to native binaries built for the old one.
SDK upgrades swap out the Hermes bytecode runtime and internal bridges. A JS bundle compiled against SDK 51's Hermes is unreadable to SDK 50's native. The Simulator and TestFlight won't catch this — they're always on the new binary.
EAS Update doesn't tell users when delivery fails. The CLI prints Published in green. The device quietly keeps running the old JS. Sentry stays silent.
Without nailing all three of these down, you end up in the "it works on my device but the bug reports won't stop" hell.
Three Policies and When Each Is the Right Choice
runtimeVersion has roughly three modes. At Dolice Labs I pick per app based on release cadence and SDK migration frequency.
"appVersion" — for apps that release infrequently
This couples runtimeVersion to version in app.json, so it's hard to misconfigure. The downside: you can't mix multiple SDKs within a single version, so flexibility drops if you release often. Two of my six apps — the ones I push to the stores roughly monthly — use this.
Explicit SDK version — for apps where one SDK lives for a long time
Something like "runtimeVersion": "50.0.0". The runtimeVersion stays the same until you intentionally raise the SDK, so even publishing OTAs daily can never drift away from native. Two of my apps that I tune for AdMob revenue via roughly 30 OTAs a month use this.
Explicit semantic version — for splitting one codebase into two streams
"runtimeVersion": "2026.5.1" or similar, decoupled from the store-facing version. I used this when I shipped an iPad-specific UI on a different runtime in parallel with the iPhone build. Two parallel UIs can coexist and native changes can be confined to one stream.
Expo officially recommends "appVersion" for new apps, but for a Rork-generated codebase where the JS side churns frequently, locking to sdkVersion produces a lower incident rate in my experience.
✦
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
✦Understand exactly how `runtimeVersion: "appVersion"` silently stops OTA delivery to existing-version users during SDK upgrades, with concrete failure cases I hit in production
✦Take home the full implementation set used to migrate 6 Rork apps from Expo SDK 50 to 51 in parallel — eas.json, branch/channel naming, and a publish script with built-in safety gates
✦Get a Sentry crash-free × EAS Update Webhook auto-halt mechanism that stops releases at the first sign of regression, plus a 30-minute recovery playbook for the day you accidentally ship native changes via OTA
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.
Here's the actual sequence I ran from April to May 2026 across six apps. The full migration took three weeks; zero user-visible incidents.
Step 1. Pin every app's runtimeVersion before doing anything else
Walk through all six app.json files. Any app using "appVersion" gets switched to a hard string like "50.0.0". During an SDK migration you want to force the "native and JS are on the same runtime" invariant.
Even if I accidentally run eas update --channel prod-sdk51 on an SDK 50 build, the device's channel binding rejects the update. That's the point.
Step 3. Mirror the split at the Git branch level, and add publish-time safety gates
I split the repo into main-sdk50 and main-sdk51 branches. The publish script's first job is to confirm the currently-checked-out branch matches the target channel.
#!/usr/bin/env bash# scripts/publish-update.shset -euo pipefailCHANNEL="${1:?usage: publish-update.sh <channel>}"BRANCH="$(git rev-parse --abbrev-ref HEAD)"# 1. Enforce branch <-> channel correspondencecase "$CHANNEL" in prod-sdk50) [[ "$BRANCH" == "main-sdk50" ]] || { echo "❌ branch mismatch: $BRANCH on $CHANNEL"; exit 1; } ;; prod-sdk51) [[ "$BRANCH" == "main-sdk51" ]] || { echo "❌ branch mismatch: $BRANCH on $CHANNEL"; exit 1; } ;; *) echo "❌ unknown channel: $CHANNEL"; exit 1 ;;esac# 2. Refuse if native or dependency changes slipped into the diffif ! git diff --quiet HEAD~10..HEAD -- ios android Podfile package.json; then echo "⚠️ Native or dependency changes detected. OTA will not work safely." echo " Promote via a store release instead." exit 2fi# 3. Halt if the last 24h crash-free rate is below threshold (see below)node scripts/check-crash-free.mjs "$CHANNEL" || { echo "❌ crash-free below threshold"; exit 3; }# 4. Ship itnpx eas update --branch "$CHANNEL" --message "release $(date -u +%FT%TZ)"
The "refuse if native changes are present" gate has saved me multiple times. Sometimes a Rork-generated fix touches ios/Podfile or package.json dependencies, and shipping that via OTA is how production breaks.
Step 4. Auto-halt on crash-free regression
Fetch the last 24 hours of crash-free rate from Sentry. If it drops below 99.5%, don't publish. Dolice Labs treats 99.5% as the upper bound for "what a solo operator can sustain."
Wiring this into Step 3's script means I don't have to manually check the Sentry dashboard — risky moments automatically halt the OTA. The same script is shared across all six Dolice Labs apps. During the April SDK 51 migration, one app momentarily dipped to 99.2% crash-free, and the publish step refused to ship, preventing the regression from widening.
Step 5. Commit to "support the old SDK for three months" and stagger store submissions
Don't jump six apps onto SDK 51 the same week. I migrated one app per week, in store-submission order. SDK 50 OTAs stayed alive until the last app had been accepted by the store — at minimum three months of overlap. Roughly 8% of my AdMob revenue still came from users opening with old native binaries, and cutting them off without warning is a poor business call.
A 30-Minute Recovery Playbook for "I Shipped a Native Change via OTA"
Even with all the gates above, accidents happen. In May 2026 I let a SwiftUI bridge change slip into an OTA channel. I noticed when Sentry started reporting JSI: method does not exist 40 times per minute. Here's how I contained the blast radius in under 30 minutes.
Run eas update --branch prod-sdk51 --republish --group <PREVIOUS_GROUP_ID> to roll back to the last known-good update. Find the previous Update Group ID in the EAS dashboard and paste it in.
Tag a Sentry release like 5.4.0+ota-rollback and measure impact (active sessions, unique users) at 5 min, 15 min, and 60 min post-rollback.
Submit an Expedited Review request via App Store Connect. In the notes field, write something like: "JSI bridge incompatibility caused by OTA update; reverted via EAS Update; submitting native fix for parity." In my experience, framed this way, expedited review approves within 4–8 hours.
Apply the native fix to the SDK 51 branch, build, submit, and start the Phased Release at 1%. Then 5% / 20% / 50% / 100% every 24 hours.
Between the rollback and the native fix landing, switch the affected feature off via Remote Config. Once native parity is back, flip it on again. For SwiftUI ↔ React Native bridge code, this "OTA rollback → Remote Config off → calm → Remote Config on" two-step minimizes user-visible churn.
Practical Pitfalls the Official Docs Don't Quite Cover
Bumping runtimeVersion in app.json doesn't change the runtimeVersion of already-built binaries. If you raise it without splitting channels, your eligible audience for new OTAs drops to zero overnight.
Hermes bytecode is pinned to the Expo SDK. SDK 51-built JS is literally unreadable by SDK 50's native. Hermes mismatch errors often don't reach Sentry — they crash at the JSI layer.
If expo-updates is built with enabled: false, OTAs will never arrive — ever. Use eas.jsonenv to flip EXPO_UPDATES_ENABLED per profile.
On iOS, the OTA fetch path during Background Fetch is highly device-dependent. Combining a synchronous fetch at AppDelegate startup with fallbackToCacheTimeout: 0 — "try for one second, then give up" — gave me the lowest incident rate across the six apps.
Where to Start Tomorrow
Everything above is the result of two years of polish across our wallpaper, ambient, and law-of-attraction apps at Dolice Labs. If I were starting from scratch tomorrow, this is the order I'd tackle it:
Pin every app's app.json to runtimeVersion: "<sdk number>". Roughly five minutes per app to enforce the "no drift" baseline.
Split eas.json into per-SDK channels and add the safety gates from scripts/publish-update.sh. About one hour.
Wire up the Sentry crash-free check and document the Update Group rollback flow in the README. Another hour.
Even steps 1 and 2 alone make the next SDK bump dramatically less stressful. Personally, the moment I shifted from "never let an incident happen" to "if one happens, contain it inside 30 minutes," my ability to keep operating six apps as a solo developer extended significantly. If this lets you skip the rougher version of that lesson, that's the best outcome I could ask for.
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.