●RAISE — Rork raised $15M. The money is pointed at shortening the distance between a solo developer and a shipped app●ENGINE — Rork Max runs on Claude Code paired with Opus 4.6. Much of its edge on complex app logic and reasoning is attributed to that pairing●CLOUDMAC — The Swift it generates is compiled on a cloud Mac fleet, so you can go from prompt to on-device testing to App Store submission without installing Xcode●AR — Rork Max supports ARKit and LiDAR scanning natively, putting 3D object placement and spatial computing apps within reach●TRACTION — The Rork Max announcement drew more than 8 million views on X, and the company reports its annual revenue doubled within two weeks●TRAFFIC — Rork reports over 743,000 monthly visits with 85% growth, a sign that prompt-to-app tools are settling in as a prototyping entry point●RAISE — Rork raised $15M. The money is pointed at shortening the distance between a solo developer and a shipped app●ENGINE — Rork Max runs on Claude Code paired with Opus 4.6. Much of its edge on complex app logic and reasoning is attributed to that pairing●CLOUDMAC — The Swift it generates is compiled on a cloud Mac fleet, so you can go from prompt to on-device testing to App Store submission without installing Xcode●AR — Rork Max supports ARKit and LiDAR scanning natively, putting 3D object placement and spatial computing apps within reach●TRACTION — The Rork Max announcement drew more than 8 million views on X, and the company reports its annual revenue doubled within two weeks●TRAFFIC — Rork reports over 743,000 monthly visits with 85% growth, a sign that prompt-to-app tools are settling in as a prototyping entry point
When 1.10.0 Gets Locked Out: Measuring Four Version Comparison Approaches for Forced Updates
A remote-config gate for minimum supported version, rebuilt after measuring four version comparison approaches. Includes the cases where localeCompare reports equality, the fail-open boundary, and the incident caused by gating on a build still in review.
A support message arrived a few days after I shipped version 1.10.0: the forced-update screen appears and there is no way past it.
My dashboard had the minimum supported version set to 1.9.0. Version 1.10.0 is obviously newer than that. There should have been nothing to block.
It did not reproduce on my own device. The iPhone running 1.10.0 sailed straight through. It reproduced the moment I pulled the gate's comparison function out and ran it under Node: "1.10.0" < "1.9.0" evaluates to true.
Compare them as strings and the second character decides it — "1" against "9" — so 1.10.0 loses. Obvious in hindsight. But for the eighteen months I spent inside the 1.9.x range, that condition never once came out false. The bug sat quietly and waited.
Running six apps as an indie developer, I keep meeting this category of defect: the one that only surfaces the instant a version segment reaches double digits. This time I rebuilt the comparison itself from measurements, so here is the record.
Four ways to compare version strings, three of which break
Start with the approaches that come to mind first: raw string comparison, localeCompare with the numeric option, parseFloat, and per-segment numeric comparison.
const sgn = (n) => (n < 0 ? -1 : n > 0 ? 1 : 0);// (1) Plain string comparisonconst naive = (a, b) => sgn(a < b ? -1 : a > b ? 1 : 0);// (2) localeCompare with numeric collationconst loc = (a, b) => sgn(a.localeCompare(b, undefined, { numeric: true }));// (3) Read it as a decimal numberconst flt = (a, b) => sgn(parseFloat(a) - parseFloat(b));// (4) Compare dot-separated segments as integersfunction segs(a, b) { const A = a.split("."), B = b.split("."); const n = Math.max(A.length, B.length); for (let i = 0; i < n; i++) { const x = parseInt(A[i] ?? "0", 10) || 0; const y = parseInt(B[i] ?? "0", 10) || 0; if (x !== y) return sgn(x - y); } return 0;}
I ran all four against version strings that actually show up in production. Results are from Node v22.22.3. A -1 means the left side is older, 1 means newer, 0 means equal.
Pair
(1) String
(2) localeCompare
(3) parseFloat
(4) Segments
1.9.0 vs 1.10.0
1
-1
1
-1
1.0 vs 1.0.0
-1
-1
0
0
1.02.0 vs 1.2.0
-1
0
-1
0
2.0.0 vs 2.0.0.1
-1
-1
0
-1
1.4.0 vs 1.4
1
1
0
0
1.4.0-beta.1 vs 1.4.0
1
1
0
1
10.0.0 vs 9.99.99
-1
1
1
1
1.2.10 vs 1.2.9
-1
1
0
1
3.0.0 vs 3.0
1
1
0
0
1.10 vs 1.9
-1
1
-1
1
Bold marks a result that contradicts what the gate needs. Approach (1) gets six of twelve pairs wrong; approach (3) gets five wrong.
My outage came from (1). Note that 10.0.0 vs 9.99.99 fails the same way — the moment a major version reaches double digits, every user of that app is classified as outdated at once.
parseFloat reads 1.2.10 as 1.2, so the patch segment disappears entirely. That is why 1.2.10 vs 1.2.9 comes back equal: everything after the second dot is treated as if it were not there.
Two places where localeCompare insists things are equal
Approach (2), localeCompare(b, undefined, { numeric: true }), handles every numeric case that broke (1) and (3). Both 1.9.0 vs 1.10.0 and 10.0.0 vs 9.99.99 come out right. Looking only at that, it seems sufficient.
I nearly rewrote the gate around it. Two things showed up once I put the results in a table.
First, it reports 1.02.0 and 1.2.0 as 0 — equal. Numeric collation reads runs of digits as numbers, so the leading zero vanishes. If you pad build numbers to a fixed width, two distinct versions become indistinguishable.
The second one does real damage. Comparing 1.4.0 against 1.4 returns 1 — the left side is treated as newer. A difference in segment count is read directly as a difference in magnitude.
If your Android versionName uses a two-segment form like 1.4, then the moment you write 1.4.0 as the remote threshold, devices on 1.4 are judged older than the threshold. Same version, different spelling, and they drop into the forced-update path.
This ran counter to my expectation. I had filed localeCompare under "compares numbers intelligently," when what it actually does is add a numeric reading rule to string collation. It knows nothing about version semantics. "Shorter is smaller when a segment is missing" is a lexicographic rule, not a versioning rule.
Approach (4) handles both of those correctly. It has its own defect: 1.4.0-beta.1 vs 1.4.0 returns 1. parseInt("0-beta") yields 0, the fourth segment 1 then wins, and a beta is ranked above the release. If you distribute prereleases at all, (4) is not usable as written either.
✦
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 measured comparison table of four version-comparison approaches across 12 pairs, including where localeCompare calls 1.02.0 and 1.2.0 equal and ranks 1.4.0 above 1.4
✦Complete comparator code that passes all 16 test cases, measured at 286.4ms across 200,000 calls
✦A fail-open implementation for remote config, with the measured cutover boundary at a 1,500ms budget
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.
None of the four works alone, so I built on (4) and added missing-segment normalization plus prerelease handling.
/** * Split a version string into numeric segments and a prerelease identifier. * Build metadata ("2.1.0+build.55") is excluded from comparison, per semver. */function parseVersion(v) { const [core, pre = ""] = String(v).split("+")[0].split("-", 2); const nums = core.split(".").map((s) => { const n = parseInt(s, 10); return Number.isFinite(n) ? n : 0; // empty or malformed segments fall back to 0 }); return { nums, pre };}/** * Returns -1 when a < b, 1 when a > b, 0 when equal. * Missing segments are treated as 0, so "1.4" equals "1.4.0". */function compareVersions(a, b) { const A = parseVersion(a); const B = parseVersion(b); const n = Math.max(A.nums.length, B.nums.length); for (let i = 0; i < n; i++) { const x = A.nums[i] ?? 0; const y = B.nums[i] ?? 0; if (x !== y) return x < y ? -1 : 1; } // Prerelease only matters once the numeric parts tie if (A.pre === B.pre) return 0; if (A.pre === "") return 1; // a release outranks any prerelease if (B.pre === "") return -1; return A.pre < B.pre ? -1 : 1;}/** True when the running build is older than the threshold. */export function isBelowMinimum(current, minSupported) { return compareVersions(current, minSupported) < 0;}
Results across the 16-case suite:
Input
Expected
Actual
1.9.0 vs 1.10.0
-1
-1
1.0 vs 1.0.0
0
0
1.02.0 vs 1.2.0
0
0
2.0.0 vs 2.0.0.1
-1
-1
1.4.0 vs 1.4
0
0
1.4.0-beta.1 vs 1.4.0
-1
-1
10.0.0 vs 9.99.99
1
1
1.2.10 vs 1.2.9
1
1
0.9.0 vs 1.0.0
-1
-1
3.0.0 vs 3.0
0
0
2.1.0+build.55 vs 2.1.0
0
0
(empty string) vs 1.0.0
-1
-1
1.0.0-rc.1 vs 1.0.0-beta.9
1
1
Zero mismatches out of 16.
One limitation worth stating plainly: prerelease identifiers are still compared as strings, so 1.4.0-beta.2 is ranked above 1.4.0-beta.10. Semver specifies that numeric identifiers be compared numerically, so this is not compliant.
I left it that way. The gate compares the build currently on the store against a threshold I set remotely, and there is no operational path where both sides are prereleases. Fixing it would add a branch to defend for a case that does not occur. If I ever distribute betas outside TestFlight, that is the day to write it.
I also measured speed. 200,000 calls completed in 286.4ms, roughly 1.4µs each. Under identical conditions localeCompare took 1,340.0ms and plain string comparison took 3.4ms.
localeCompare is one to two orders of magnitude slower — and that is irrelevant here. The gate evaluates once at launch, a handful of times at most. The gap between 1.4µs and 6.7µs never reaches a user. Correctness is the axis to optimize, not throughput.
Gate at launch, or gate on the response header
With the comparator settled, the next question was where to run it. Two options, in practice.
Placement
Time to take effect
Network assumption
Best suited for
Fetch remote config at launch
Next cold start
One request per launch
Routine threshold raises
Threshold in API response header
Next request
Rides existing traffic
Emergency stops, server-side compat breaks
I run both, because they protect different things.
The launch-time gate exists to retire old clients on a schedule, with plenty of grace. The header gate exists to stop a combination that is broken right now — when the backend drops compatibility, it needs to bite immediately.
Keep only the first and a user with the app already open never hears about the backend change. Keep only the second and any screen that works offline stays unguarded.
The comparator is shared; only the threshold's entry point is duplicated.
// Resolved once at launch, then read from memorylet gateState = { min: "0.0.0", source: "boot" };export function applyRemoteConfig(cfg) { if (cfg?.minSupported) gateState = { min: cfg.minSupported, source: "config" };}// When a response carries a threshold, keep whichever is stricterexport function applyResponseHeader(headers) { const h = headers.get("x-min-supported-version"); if (!h) return; if (compareVersions(h, gateState.min) > 0) { gateState = { min: h, source: "header" }; }}export function shouldBlock(currentVersion) { return isBelowMinimum(currentVersion, gateState.min);}
Taking the stricter value in applyResponseHeader keeps the two paths from relaxing each other. Cached responses can carry a stale header, and if that lowered the threshold, an emergency stop would silently lift itself.
Fail closed, or fail open
Remote config fetches fail. Bad signal, a congested CDN, your own worker mid-deploy. What should the gate do then?
Access control orthodoxy says deny by default: no evidence, no entry. That is how I wrote it first.
It was wrong here. A gate that stays shut locks out users who are running a perfectly current build. Everyone gets blocked by an event that is not even about the app. I had the wrong thing under protection.
Fail open is correct for this. On failure, fall back to the last config that did arrive; if there is none, open the gate.
function withTimeout(promise, ms) { let t; const timeout = new Promise((_, reject) => { t = setTimeout(() => reject(new Error("config-timeout")), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(t));}const CONFIG_BUDGET_MS = 1500;async function resolveGate(fetchConfig, cachedConfig) { let cfg, source = "network"; try { cfg = await withTimeout(fetchConfig(), CONFIG_BUDGET_MS); } catch { cfg = cachedConfig; // (1) fall back to the last good config source = "cache"; } if (!cfg) { cfg = { minSupported: "0.0.0" }; // (2) no cache either: open the gate source = "open"; } return { min: cfg.minSupported, source };}
With the budget at 1,500ms, I varied the network response time and measured the outcome.
Response time
Config used
Measured time to resolve
80ms
network (threshold 2.4.0)
81.0ms
1,200ms
network (threshold 2.4.0)
1,201.8ms
4,000ms
cache (threshold 2.2.0)
1,500.5ms
9,000ms
cache (threshold 2.2.0)
1,502.0ms
Whether the network takes 4,000ms or 9,000ms, resolution returns at roughly 1,500ms. Promise.race means the slow side is never awaited.
The 1,500ms figure is the longest I am willing to hold a splash screen without it feeling stuck. My time-to-first-paint budget sits around two seconds, so this fits inside it. That number belongs to each app's startup budget — there is no universal answer.
Never gate on a build that is still in review
With the comparator and the placement settled, I tripped over operations instead.
On the day I submitted a new version to the App Store, I raised the remote threshold to that version. It felt like the tidy thing to do — old clients swept away in one motion.
The opposite happened. The submitted build was still in review and not on the store. Every existing user fell below the threshold and got the forced-update screen. Tapping through to the store showed them the previous version. There was nothing to update to.
Staged rollout makes it worse. On day one roughly 1% of users receive the new build, while the threshold has already moved for 100% of them.
The fix was to decouple threshold changes from release work entirely.
Submit the new version (leave the threshold alone)
Confirm it passed review and is actually live
Advance the staged rollout to 100%
Wait several days and confirm the adoption rate reached expectations
Set the threshold to the previous version — not the one just shipped
Step 5 is the point. The threshold tracks "one behind," never "current." Gate on the newest build and you always leave a window where users are locked out before that build can reach them.
Google Play and the App Store also propagate live status at different speeds, so I now confirm both before moving the threshold. I once raised it after only one side went live, and Android users were left in limbo.
Three rules I kept
Three lines went into my checklist at the end of all this.
Keep the comparator next to its tests. Version comparison has an unusually long window in which a broken implementation looks fine — right up until 1.9 becomes 1.10, or a major version reaches 10. The 16-case suite is about sixty lines. There is no reason to skip it.
Validate the threshold input. An implementation whose behavior depends on whether someone typed 1.4 or 1.4.0 is fragile to begin with, but I also made the dashboard reject anything that fails /^\d+(\.\d+){0,3}$/. Hardening the code and validating the input are not substitutes for each other.
Ship the ability to open before you ship the ability to close. A forced update is one of the few mechanisms that can stop a user's hands from your side. So the gate stays disabled until three things exist: it does not close on fetch failure, it falls back to cache, and the threshold can be reverted immediately.
I did not expect to lose this much ground to something as elementary as comparing two version strings. Until I actually ran it and laid the results in a table, I could not see what I had wrong. There is no shortcut around measuring.
If you are stuck at the same spot, I hope this saves you the evening.
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.