The version you need users to leave
Somewhere in your release history is a build you need people off of. Maybe it crashes on launch for a growing slice of devices. Maybe it writes data your new backend can’t read. Maybe it talks to an API that no longer exists. Whatever the reason, the problem was never deciding that old versions must go — it’s the enforcement.
Do it softly and nobody moves: a dismissible “new version available” prompt is ignored by exactly the users you need to move. Do it hard and you break the people mid-work — the vendor in a low-signal area who’s trying to record a transaction, the user three days into a flow with nowhere to reload. And every scheme falls apart the moment the update-checker itself can’t reach the network, and you’ve hard-walled a user because of your outage, not their version.
This is for anyone shipping a mobile app with users on old builds and a backend that keeps moving. You should know what Remote Config is and roughly how app stores gate releases.
The forcing function
The backend doesn’t stop. We ship to the app stores, and the moment a new backend feature lands, every old binary becomes a slowly-rotting liability: it doesn’t know the new endpoint, it misunderstands the new payload, it writes rows the new code can’t read. You have exactly two ways to cope — reject old clients at the server, or get them to leave. The honest design does both, and neither one points a gun at a working user.
Our version gate is the client side of that. It’s a pure function with three outcomes, a control panel you can change without shipping a build, and one rule above all: a network hiccup must never block a working user.
Three buckets, not one hammer
The heart of it is a small pure module — five statuses, one decision ladder, zero dependencies. Not even a semver library; the comparison is a hand-rolled numeric segment compare, because pulling in semver for one comparison is how you end up with a dependency audit finding in your release notes:
static compareVersions(a: string, b: string): number {
const parse = (v: string) =>
v.split('.').map(part => parseInt(part, 10))
.map(n => (Number.isNaN(n) ? 0 : n));
const pa = parse(a);
const pb = parse(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] ?? 0;
const nb = pb[i] ?? 0;
if (na < nb) return -1;
if (na > nb) return 1;
}
return 0;
}And the decision ladder that gives the whole feature its personality — three buckets, in a specific order:
if (compareVersions(currentVersion, config.minSupportedVersion) < 0) {
return { status: 'blocked', updateMessage: config.forceUpdateMessage };
}
if (compareVersions(currentVersion, config.latestVersion) < 0) {
return { status: 'update_available', updateMessage: config.updateAvailableMessage };
}
return { status: 'ok', updateMessage: '' };Below the minimum you support → blocked, the hard wall. At or above the minimum but below the latest → update_available, the nudge. At or above the latest → ok, silence. The two comparisons encode the whole policy, and the ordering matters: blocked is decided first, because the nudge must never paper over a version you’ve decided is unsafe to run.
The same ladder, as a tree:
flowchart TD
V[VersionGateEngine.evaluate] --> C1{below minSupported?}
C1 -->|yes| B[blocked<br/>hard wall — no dismiss]
C1 -->|no| C2{below latest?}
C2 -->|yes| N[update_available<br/>nudge with Later]
C2 -->|no| OK[ok<br/>silence]
E[config fetch throws] --> ERR[error<br/>fail open — let everyone through]
Three buckets for the user’s version, one for your own outage.
The control panel that doesn’t need a release
The whole point is that the gate must change without shipping a build — you’re forcing users off a build precisely because they don’t install updates. So the thresholds live in Firebase Remote Config, under seven namespaced keys:
export const DEFAULTS = {
vendor_min_supported_version: '0.0.0',
vendor_latest_version: '0.0.0',
vendor_force_update_message: `A mandatory update is required to continue using the ${appName} app…`,
vendor_update_available_message: `A new version of the ${appName} app is available with bug fixes and improvements.`,
vendor_ios_store_url: 'https://apps.apple.com/…',
vendor_android_store_url: 'https://play.google.com/…',
vendor_android_native_update_enabled: false,
};The vendor_ prefix is deliberate — if several of our apps ever share a Firebase project, the keys can’t collide. Two of those values are worth calling out: the user-facing messages ship as config too, so the copy can be rewritten in a dashboard without a release; and the store URLs are config, not constants, so a store URL change is one field, not a hotfix. Defaults are seeded into the Remote Config instance, so the gate still has values on a first launch before config ever arrives.
Fail open
Here’s the design’s spine. If fetching config throws, the gate resolves to a fifth status — error — and returns empty store URLs:
try {
const config = await configProvider.fetchConfig();
return VersionGateEngine.evaluate(currentVersion, config);
} catch (err) {
console.warn('[versionGate] Remote Config check failed, failing open:', err);
return {
status: 'error',
currentVersion,
minSupportedVersion: DEFAULTS.vendor_min_supported_version,
latestVersion: DEFAULTS.vendor_latest_version,
updateMessage: '',
storeUrl: { ios: '', android: '' },
androidNativeUpdateEnabled: false,
};
}The modal logic renders nothing for error. The user sails through. That’s a product decision, written down in the strategy doc in a sentence I don’t want to lose: “a network hiccup must never block a vendor from working.” The gate exists to protect the server from stale clients — it must never be the thing that stops a healthy user from doing their job because our infrastructure blinked.
The check re-runs on every foreground transition (AppState → active), so a user who’s been sitting in the background for a month gets re-evaluated the moment the app returns, and the gate catches up on its own.
Two personalities, one modal
The modal has exactly two shapes, and the difference is the entire UX philosophy:
- Blocked — titled “App Update Required”, a single “Update Now” button, and no dismiss affordance at all. Not a close icon, not a “Later”. The screen just doesn’t offer a way out, because leaving would be the wrong outcome.
- Soft — titled “App Update Available”, “Update Now” and “Later”. Tapping “Later” persists the dismissal per version in storage (
vendor_dismissed_update_version), so the nudge hides until the next release — it can’t nag you again for the same version, and it can’t be permanently dismissed.
“Update Now” opens the platform’s store URL via Linking.openURL. There’s no in-app kill switch — a blocked user isn’t force-quit, they’re just walked to the store and the modal stays on top when they come back.
On Android, when vendor_android_native_update_enabled is on, the soft path hands off to the native Google Play flexible update instead of the in-app modal — and the module that drives it is lazy-imported so iOS builds never even bundle it:
if (gateResult.status === 'blocked') {
// Hard block owns the screen. Never call the Android nudge here.
return;
}The native nudge is once per session; the in-app nudge is once per version. Two different persistence scopes, each tuned to how annoying it’s allowed to be.
The backend doesn’t trust the client
The gate is UX. Enforcement is the server. Every API request carries the installed binary’s real version, read from the native build — not from a constants file, not from Remote Config:
params.headers['X-App-Version'] = Application.nativeApplicationVersion ?? '0.0.0';
params.headers['X-App-Build'] = Application.nativeBuildVersion ?? '0';Two headers, deliberately split: X-App-Version is the clean semver, X-App-Build the native build number. Keeping them separate is documented in the ADR — a combined "1.1.6 (build)" string breaks standard semver parsers on the backend. The server’s job is to validate that header and reject requests from versions you’ve withdrawn, and the client gate exists to move users off those versions before the server has to say no. Belt and suspenders, in the right order: the gun only fires at the client gate when the client won’t move on its own.
The release loop that keeps the gun holstered
The interesting part is the release side — the discipline that decides when the gate gets teeth. Releasing is automated: conventional commits → semantic-release computes the next version → a sync script writes it into the Expo config → the version tag pushes → EAS builds and submits to both stores.
But a major release stops the pipeline for a human. The sync script prints the loudest message in the codebase:
console.log('⚠️ MAJOR VERSION RELEASE — MANUAL ACTION REQUIRED ⚠️');
console.log('Decide whether to bump `min_supported_version` in Firebase Remote Config.');
console.log('Do NOT flip it automatically — confirm the store build is LIVE first');And it writes a .major-release-pending file that the CI surfaces as a warning in the run log, so the decision isn’t buried in terminal scrollback. The rule is deliberate: raising min_supported_version is a product decision, not a mechanical consequence of semver. Minor and patch bumps sail through automatically — old users keep working and get nudged. A major bump kicks everyone off an old version, and that can only happen once a human has confirmed the new build is actually live in the store. The automation does everything except the one step that would be dangerous to automate.
1.1.4 ≥ min 1.1.4, < latest 1.1.6 → update available
A mandatory update is required to continue using the app. Please update to the latest version to ensure smooth operations.
A new version of the app is available with bug fixes and improvements.
blocked can't be dismissed — "Later" only exists on the soft nudge
Move your installed version around. Below the minimum, the hard wall; at the minimum but below latest, the nudge with a working “Later”; at latest, silence. Now flip “config unreachable” — the same user, the same version, and nothing blocks them.
The honest gotchas
- The server-side rejection is documented intent, not verified fact. The strategy doc describes the backend rejecting
X-App-Versionwith426 Upgrade Required— but that code lives in a backend I can’t show you, and the client has no 426 handler. Today, the client gate is the enforcement. - Fail-open means config errors hide the gate. If Remote Config is misconfigured, not just unreachable, the whole mechanism goes quiet. That’s the trade we accepted, and it’s the trade I’d take again — a silent gate is a live-user problem; a loud one is a working-user problem.
- The automation is young. One release tag exists in the repo so far. The pipeline has run exactly once end-to-end; it’s a reasonable design, not a battle-tested one. Don’t mistake a diagram for a track record.
- Fail-open + per-version dismissal lets a user sit on an old-but-supported version indefinitely. That’s fine — it means “supported” was a decision you made, not an accident you discovered at 2am from a support ticket.
The rule
An update gate is three buckets: force what’s unsafe to leave, nudge what’s worth moving, and default to letting everyone through when you can’t prove otherwise.
The thing I’d rescue from this whole design is the ordering of that sentence. Most update systems I’ve seen decide “how aggressive should we be?” and then apply that mood to everyone. This one asks a different question first: what’s unsafe? Only that gets the hard wall. Everything newer than the floor is treated as a person making a choice, and every failure of the gate itself defaults to the choice that hurts no one.
The gun stays in the holster because it only ever points at the versions you decided can’t be trusted. Everyone else — and every outage — walks through.