●FUNDING — Rork raised a $15M seed led by Left Lane Capital, with Peak XV, True Ventures, Goodwater, and a16z Speedrun joining●ENGINE — Rork Max runs on Claude Code and Claude Opus 4.6; it drew 8M+ views on X and doubled annual revenue in two weeks●SWIFT — Rork Max is the first web-based Swift app builder, positioned to replace Apple's traditional Xcode●PRODUCT — Rork Max covers the whole Apple ecosystem: iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●CLASSIC — The original Rork uses React Native (Expo), building iOS/Android apps from a plain-English description●PRICING — Start free; paid plans begin at $25/mo, and Rork Max is $200/mo●FUNDING — Rork raised a $15M seed led by Left Lane Capital, with Peak XV, True Ventures, Goodwater, and a16z Speedrun joining●ENGINE — Rork Max runs on Claude Code and Claude Opus 4.6; it drew 8M+ views on X and doubled annual revenue in two weeks●SWIFT — Rork Max is the first web-based Swift app builder, positioned to replace Apple's traditional Xcode●PRODUCT — Rork Max covers the whole Apple ecosystem: iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●CLASSIC — The original Rork uses React Native (Expo), building iOS/Android apps from a plain-English description●PRICING — Start free; paid plans begin at $25/mo, and Rork Max is $200/mo
When EAS Update Ships but the Bug Won't Die — Why OTA Stalls Silently, and How I Operate Around It
EAS Update can succeed and still fail to reach a slice of your users. These are field notes on runtimeVersion drift, updates that publish but never get adopted, and choosing the right rollback — with the instrumentation that actually helped on my Rork apps.
The relief you feel after wiring up EAS Update tends to curdle into a different anxiety after a while: "eas update succeeded. It printed Published. And yet the bug reports keep coming." On one of the Rork-built apps I run as an indie developer, I once shipped the same fix two or three times over. The problem was never the code. The update was quietly failing to reach a portion of devices.
OTA delivery is a genuinely powerful way to erase store-review latency. But a publish succeeding and that publish actually rendering on a user's screen are two different events. This article takes the "I shipped it and nothing changed" situation apart, isolates the cause, and lays out the operational habits that keep it from recurring. These are field notes from where I tripped, not a tidy tutorial.
"Published" means the publish succeeded, not that it was adopted
The Published that eas update returns means a new update was registered on EAS's servers. Between there and actually running on a device, there are several gates.
Stage
What it means
What failure looks like
Publish
The update is registered with EAS
The CLI errors out, so you notice immediately
Matching
The device runtimeVersion equals the update runtimeVersion
A mismatch means it never arrives — silently
Download
The bundle is fetched on launch
Delayed depending on network and check settings
Apply
The new bundle takes over on the next launch
The very first launch still runs the old bundle
The cruel one is the second gate, matching. The CLI shows no error. The publish succeeds and the update appears in the dashboard. Yet devices whose runtimeVersion doesn't match receive nothing at all. "It succeeded but nothing was fixed" is, more often than not, exactly this.
The first thing to check is which runtimeVersion the update is bound to
Triage starts by comparing the runtimeVersion of the update you published against the runtimeVersion of the build your users are actually running.
# List published updates with their runtimeVersioneas update:list --channel production --json --non-interactive \ | jq '.[] | {id, runtimeVersion, message, createdAt}'
If that runtimeVersion doesn't match the runtimeVersion of the build currently live in the store, the update will not reach current users. You can read the live build's value from the build list.
If those two values disagree, it isn't a code problem. You need to re-publish targeting the runtimeVersion of the live build. I learned this the slow way, re-shipping correct code again and again before checking the one thing that mattered. Once I made this the first thing I look at, triage got dramatically faster.
✦
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
✦The common ways an OTA update publishes successfully yet never lands on devices, plus a five-minute triage to find the cause
✦How to prevent runtimeVersion drift by design, and how to read adoption across a mixed-binary install base
✦The real difference between republish, channel:edit, and embedded rollback — and how to pick the right one per type of incident
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 decision axis is simple. If you want OTA to reach as many users as possible, sdkVersion wins because its runtimeVersion changes rarely. If you never want to get native compatibility wrong, fingerprint is the safe pick — it's computed from a hash of your native configuration, so it won't ship JavaScript to an incompatible binary and crash it.
In practice I lean on sdkVersion for small apps that stay on one SDK, and fingerprint for apps where I add and remove native modules. I reserve appVersion for cases where I have a clear intent to re-scope OTA per release. Choosing appVersion without that intent quietly strands users on older builds every time you bump the version.
See adoption in numbers, not assumptions
To turn "it should be landing" into "it is landing," instrument adoption on the device. expo-updates exposes the identity of the running bundle at runtime.
import * as Updates from "expo-updates";export function reportUpdateState() { // Run on boot to make the running bundle observable const state = { updateId: Updates.updateId ?? "embedded", // null if running the embedded bundle channel: Updates.channel ?? "unknown", runtimeVersion: Updates.runtimeVersion ?? "unknown", isEmbedded: Updates.updateId == null, createdAt: Updates.createdAt?.toISOString() ?? null, }; analytics.track("app_boot_update_state", state); return state;}
Aggregate that event and you can see the share of devices running the latest updateId. If the embedded share hasn't dropped hours after publishing, that's an unambiguous sign the update isn't being adopted. Since I started checking this adoption rate on every publish, I've been able to sense "fixed but not actually fixed" before the next deploy.
Why check at runtime at all? Because a successful eas update is a server-side fact, not a device-side one. This boot event is what closes the gap between the two.
When you need it now, don't make users wait
The default checkAutomatically: "ON_LOAD" checks for an update on launch and applies a downloaded update on the next launch. So even an emergency fix stays on the old bundle until the user closes and reopens the app. When checkout is broken, that one beat can be unacceptable.
import * as Updates from "expo-updates";// Only for high-urgency fixes: fetch and apply on the spotexport async function applyCriticalUpdateIfAny() { if (__DEV__) return; // OTA does not run in dev builds try { const result = await Updates.checkForUpdateAsync(); if (!result.isAvailable) return; await Updates.fetchUpdateAsync(); // It's kinder to tell the user before reloading await Updates.reloadAsync(); } catch (e) { // It keeps running on the old bundle, so record instead of swallowing analytics.track("ota_force_apply_failed", { message: String(e) }); }}
The point is not to run this forced path for every update. Calling reloadAsync on every launch interrupts whatever the user was doing. I route through this path only for updates flagged as urgent, and let everyday updates take the default next-launch apply — which avoids flicker and interruption.
Rollback isn't one thing — match the method to the incident
"Revert the bad update" sounds singular, but the correct mechanism changes with the situation. Get it wrong and you create a second incident: you think you rolled back, but you didn't.
Type of incident
Correct rollback
Why
The latest OTA introduced a defect
Republish the previous update
Fastest return to a known-good state
You want to point the channel elsewhere
channel:edit to a stable branch
Swap the source of delivery itself
You distrust OTA as a whole
Roll back to the embedded bundle
Retreat to the known store binary
The one I reach for most is a republish of the previous good update.
# Pick the previous good update group and re-ship iteas update:list --channel production --json --non-interactive \ | jq '.[1] | {id, message}' # [0] is the bad one, [1] is the prioreas update:republish --channel production --group GOOD_GROUP_ID \ --message "Rollback: revert the checkout-defect update"
When you decide to distrust the OTA mechanism itself for a moment, rolling back to the embedded bundle is the right move. It isn't "stop sending new JavaScript" — it's an explicit instruction to return to the binary as shipped to the store, pulling devices back to a known state.
# Send everyone back to the store-reviewed embedded bundleeas update:rollback --channel production \ --message "Emergency: retreat to embedded bundle"
The deciding question is where the fault lives. If a specific update is the problem, republish is enough. If you suspect the OTA path itself or a runtimeVersion mismatch, retreating to the reviewed binary is safer. Since I started asking "is the cause the update's content, or the delivery machinery?" first, I hesitate far less over which rollback to use.
Stop shipping to everyone at once
OTA's power to reach all users instantly means a bad update also reaches everyone instantly. Staged rollout softens that. Ship to a slice first, watch adoption and anomalies, then widen.
# Roll out to only 10% of users firsteas update --channel production --message "Profile revamp" \ --rollout-percentage 10# Watch adoption and crash-free for a while, then widen if cleaneas update:edit --rollout-percentage 100
The evidence for that decision comes from the boot event above plus crash metrics. If, right after the 10% rollout, the crash-free rate among devices running that update is clearly below the population, you can stop before going to 100%. Adding this "observe before widening" beat has, on several occasions, let me cancel an update before it became a fleet-wide outage. OTA's speed is a fang when the update is bad; staged rollout is the insurance that blunts it.
The pre-publish check I run for myself
Finally, the check I always run right before publishing. Nothing exotic — but trouble showed up precisely on the days I skipped it.
First: does the runtimeVersion of the update I'm about to publish match the runtimeVersion of the build currently live in the store? Next: for an emergency fix, am I routing through the immediate-apply path rather than staged rollout — and conversely, am I not mistakenly calling reloadAsync on every launch for routine updates? And: if I end up reverting, have I decided in advance whether republish or an embedded rollback is the right call? Saying those three out loud shrinks the post-publish anxiety considerably.
OTA is a dependable way for a solo developer to dismantle the review wall. It also carries a quiet gap between the success message and the actual rollout. Making that gap visible in numbers is, in my ongoing experience, the surest way to cut down on the late-night support emails.
If this helps you isolate your own "it won't arrive" a little faster, I'm glad.
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.