Have you ever shipped a Rork app, only to realize later that you need to change an API contract, fix a critical billing bug, or sunset an old screen — and then asked yourself, "How do I get users off the old version?" I have run into this more than once with my own apps. Without a way to nudge users forward, the long tail of stale installs becomes a support drain, and you can never confidently retire the old API on the server.
This guide walks through how I now design force-update flows for Rork apps: the policy decisions, the actual code, and the operational pitfalls I have hit. The examples use Expo's JavaScript/TypeScript stack, but the same logic applies to native iOS/Android projects generated by Rork Max.
Why a force-update path is worth building early
App Store and Google Play both have "auto update" toggles, but plenty of users have them turned off. When billing rules shift, when Rork's SDK ships a major version, or when your backend introduces a non-backward-compatible change, you need a proactive way to ask older clients to upgrade.
In my experience, the apps that benefit most are the ones that take payments. The trap is that if you wait until crash reports start rolling in, the very users you need to reach are the ones running the broken build. I treat the version-check stub as something I build into every Rork project from day one, even before I think I need it.
Design: separate soft updates from hard updates
There are really two different escalation levels worth keeping distinct.
- Soft update: A dialog appears at launch, but a "Later" button lets the user continue. This is right for new features and minor improvements
- Hard update: The dialog only offers a button to the store, and the rest of the app is gated. This is for critical billing bugs, security fixes, or when the server is about to retire the old API
Drive the policy from a remote config
The decision boils down to comparing the installed app version against a "minimum required version" that the server controls. Crucially, that threshold should not be hardcoded in the app — store it in Firebase Remote Config, Supabase, Cloudflare KV, or any small JSON endpoint. That way you can flip the policy without shipping another build.
A practical shape for the config looks like this.
{
"ios": {
"minimumVersion": "1.4.0",
"softUpdateVersion": "1.5.0",
"storeUrl": "https://apps.apple.com/app/idXXXXXXXXX"
},
"android": {
"minimumVersion": "1.4.0",
"softUpdateVersion": "1.5.0",
"storeUrl": "https://play.google.com/store/apps/details?id=com.example.app"
}
}Versions below minimumVersion get the hard prompt. Versions below softUpdateVersion get the soft prompt. Storing the per-platform store URL alongside the policy gives you flexibility when one store needs special handling.
Implementing the launch-time check
Here is the client-side utility. It uses Expo's expo-application to read the installed version and compares it against the remote policy.
// utils/checkAppVersion.ts
import * as Application from "expo-application";
import { Platform, Linking, Alert } from "react-native";
type RemoteVersionConfig = {
ios: { minimumVersion: string; softUpdateVersion: string; storeUrl: string };
android: { minimumVersion: string; softUpdateVersion: string; storeUrl: string };
};
// Compare semantic versions like "1.4.0" by splitting and walking each segment
function compareVersion(a: string, b: string): number {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
export async function checkAppVersion(remote: RemoteVersionConfig) {
// Read the installed version (e.g. "1.3.2")
const current = Application.nativeApplicationVersion ?? "0.0.0";
const cfg = Platform.OS === "ios" ? remote.ios : remote.android;
if (compareVersion(current, cfg.minimumVersion) < 0) {
// Hard mode: only an "Update" button, no escape hatch
Alert.alert(
"Update Required",
"Please update to the latest version to continue using the app.",
[{ text: "Update", onPress: () => Linking.openURL(cfg.storeUrl) }],
{ cancelable: false }
);
return "hard" as const;
}
if (compareVersion(current, cfg.softUpdateVersion) < 0) {
// Soft mode: "Later" is a valid choice
Alert.alert(
"A New Version is Available",
"A newer version with improvements is available.",
[
{ text: "Later", style: "cancel" },
{ text: "Update", onPress: () => Linking.openURL(cfg.storeUrl) },
]
);
return "soft" as const;
}
return "ok" as const;
}Call this once from a useEffect in your root layout (_layout.tsx or similar). The expected return values are "hard" | "soft" | "ok" — when the result is "hard", halt the rest of the boot sequence; when it is "ok", fall through to your normal flow.
Routing to the store without breaking the experience
When you call Linking.openURL, double-check whether https://apps.apple.com/... or itms-apps:// works more reliably on your device targets. In my own testing on recent iOS, the HTTPS URL routes more consistently to the App Store.
Also remember that Linking.openURL does not open the actual store on Expo Go or simulators. Real testing has to happen in TestFlight or an internal track build — the Rork × TestFlight Beta Testing Guide covers that pipeline. Even after you switch to hard mode, leave a "Help" or "Contact" link visible on the dialog. Users on managed devices, very old hardware, or restricted networks may genuinely be unable to update, and that escape hatch is what saves them from churning silently.
Reach for EAS Update before reaching for force-update
If the issue is a small JS-side bug or a copy fix, ship it through over-the-air updates first. The Rork × EAS Update Guide walks through the workflow. Forcing a full store update for something OTA could solve burns goodwill you do not need to spend.
My personal cutoff:
- Native module changes, boot-order changes, or anything touching billing — store release plus force-update
- JS-only copy or UI changes, bug fixes, configuration tweaks — EAS Update is enough
In practice this means I keep the force-update path quiet most of the time, and I save it for situations where I genuinely cannot reach users any other way. When you treat the prompt as a precious resource rather than a free dial to turn, users notice — they stop dismissing it on autopilot, and the rare hard prompt actually gets a response.
Watch the rollout after you flip the switch
Right after enabling the policy, watch Sentry or Crashlytics for the install count of the affected version. If it shrinks more slowly than expected, the cause is often something other than user inattention — devices low on storage, MDM-managed corporate phones blocked from the store, or update-over-Wi-Fi-only settings holding back the upgrade. If you do not yet have crash visibility wired up, the Rork × Sentry Crash Monitoring Guide is a good prerequisite — without it, you cannot really measure whether the cutover worked.
It also helps to log the prompt outcomes themselves: how often the soft prompt is shown, how many users tap "Update" versus "Later," and how the share of installs below softUpdateVersion changes day over day. A simple analytics event at the moment the dialog mounts, plus another when the user taps a button, is enough to tell whether the policy is actually moving the needle. I have seen rollouts where the install graph looked flat for two days, only to drop dramatically on day three when a Wi-Fi-restricted segment finally caught up — without that breakdown, it is easy to escalate to hard mode prematurely.
Pitfalls I have hit, in case they save you time
A few specific mistakes have cost me hours, and they tend to reappear in different shapes:
- Comparing versions as strings.
"1.10.0" < "1.9.0"istruelexically, which is the opposite of what you want. The numeric split-and-compare approach above is unglamorous but reliable, and it survives non-numeric pre-release suffixes if you decide to strip them on input - Forgetting Android's separate
versionCode. On Google Play it is fine to compare semantic strings for user-facing prompts, but if you also need to gate by the build number, read it fromApplication.nativeBuildVersionand treat it as a parallel comparison — not a replacement - Showing the dialog before the user can read it. If the dialog races with a splash animation, users tap through reflexively. I now wait for at least one render frame after the splash is dismissed before triggering the alert
- Looping prompts. When the user picks "Later," do not re-run the check on every navigation. Cache the dismissal time in memory or in
AsyncStorageand skip the prompt for a sensible cool-down — twelve to twenty-four hours is what I usually pick
Treating these as a checklist when you wire up the flow is much cheaper than discovering them in production.
Wrap-up — your first concrete step
Force-updates are less of a feature than a piece of release operations. The code is small; the judgment calls are what last. The shape of the policy — when soft becomes hard, who controls the threshold, what the user sees on the way to the store — outlives any single release, so it is worth getting right while the stakes are still low.
If you want one concrete next step, add minimumVersion and softUpdateVersion keys to your remote config and write the version-comparison function with a small unit test. Once you can reason about the dialog's behavior in isolation, the day you actually need to flip to hard mode will feel a lot less daunting. I would also encourage running the soft prompt for a real release before you ever need the hard one — that is when you discover whether your store URL works on every locale, whether your copy reads right in the dialog, and whether your support inbox can absorb the questions that follow.