Sitemap

React Native In-App Updates: The Two Approaches I’ve Used and Which One Survived

8 min readJun 3, 2026

--

Press enter or click to view image in full size

I’d been running a React Native app on both stores for a while and the update prompt I’d wired up was working fine, until it stopped working in ways that were hard to notice. That’s the story of why I replaced three packages with one JSON document I can change from a web console.

This post walks through what I had before, what made me move, and the one rule I’d write in big letters before trusting the new setup to anyone else on the team.

Quick version up top: I used sp-react-native-in-app-updates and react-native-store-version. They worked, then they didn't, in ways that were silent. I rewrote the whole flow on top of a remote config source I control, in my case Firebase Remote Config, but the same pattern works just as well off your own backend. The new flow is about 50 lines and gives me a remotely controlled minimum-version gate, and doesn’t depend on querying store metadata at runtime.

What I had before

My setup was a three package combo:

  • sp-react-native-in-app-updates, which wraps Google Play Core on Android and gives you the native IMMEDIATE and FLEXIBLE update sheets.
  • react-native-store-version, which hits the iTunes Lookup API for iOS and scrapes the Play Store page for Android, returning the live store version as a string.
  • semver, for comparing the strings.

On launch the app asked the store what version was live, ran semver.diff() against the installed version, and decided what to render. A major bump meant a forced update. Minor or patch meant an optional nudge.

const response = await checkVersion({
version: currentVersion,
androidStoreURL: PLAY_STORE_URL,
iosStoreURL: APP_STORE_URL,
});


const diff = semver.diff(currentVersion, response.remote);
if (diff === 'major') showForcedUpdate();
else if (diff === 'minor' || diff === 'patch') showOptionalUpdate();

On Android the forced path used Play Core’s IMMEDIATE flow, which is the full screen native sheet Google ships. Optional used FLEXIBLE, where the new APK downloads in the background and you ask the user to restart when it’s ready. On iOS there’s no native in-app install API, so sp-react-native-in-app-updates handles iOS through react-native-siren under the hood, which shows an Alert and opens the App Store via Linking. Effectively the same thing you'd do yourself.

Press enter or click to view image in full size
Press enter or click to view image in full size

One thing worth flagging early: sp-react-native-in-app-updates.checkNeedsUpdate() returns shouldUpdate plus a storeVersion string, but no major/minor/patch classification. I wanted to split forced vs optional on the semver gap, so I was running semver.diff against the store version myself. I had also pulled in react-native-store-version early on before I leaned on storeVersion, and for a while I was carrying both. That redundancy is part of what got me questioning the whole stack.

For a few releases this was fine. Nothing to complain about, nothing to fix.

Why I walked away from it

Two things wore me down.

First was the version string parsing. App Store version strings aren’t guaranteed to follow strict semver formatting. A version like 1.0 is perfectly valid there, but semver.valid() rejects it. Run semver.valid('1.0') and you get null. In my implementation the update prompt never appeared. I patched it with a normaliser that pads to three parts, but at that point I was patching a library to babysit another library.

The real one was the kill switch I didn’t have. The day I shipped a build that broke a critical flow, my only move was to ship a hotfix and wait on Apple. App Store review took 38 hours that round. For 38 hours I refreshed App Store Connect like it owed me money while users on the bad build kept hitting the bug. That’s not a problem with the libraries, it’s a problem with the architecture. The version check was reading the store, but the store doesn’t know my hotfix is urgent.

What I use now

The pattern is simple: keep five values in a remote source you control, and read them on launch.

  • ios_latest_version
  • ios_minimum_version
  • android_latest_version
  • android_minimum_version
  • update_message

I went with Firebase Remote Config because I was already on it for maintenance mode and feature flags. If you’d rather own the wire, a /config/version endpoint on your backend works just as well, or a JSON file on a CDN, or whatever you have. The mechanism doesn't matter, the principle does: the source of truth is something you can change without shipping a build.

Everything now lives in one file. It reads the values on launch, compares them against the app’s own version, and decides what to render.

import remoteConfig from '@react-native-firebase/remote-config';
import Constants from 'expo-constants';

async function checkUpdate() {
const current = Constants.expoConfig?.version ?? '0.0.0';
await remoteConfig().setDefaults({
ios_latest_version: current,
ios_minimum_version: '0.0.0',
android_latest_version: current,
android_minimum_version: '0.0.0',
update_message: 'A new version is available with improvements.',
});
await remoteConfig().setConfigSettings({ minimumFetchIntervalMillis: 60_000 });
await remoteConfig().fetchAndActivate();
const latestKey = Platform.OS === 'ios' ? 'ios_latest_version' : 'android_latest_version';
const minKey = Platform.OS === 'ios' ? 'ios_minimum_version' : 'android_minimum_version';
const latest = remoteConfig().getValue(latestKey).asString();
const minimum = remoteConfig().getValue(minKey).asString();
const message = remoteConfig().getValue('update_message').asString();
if (compare(current, minimum) < 0) {
setState({ kind: 'forced', latest, message });
return;
}
if (compare(current, latest) < 0) {
if (await shouldSilenceOptional(latest)) return;
setState({ kind: 'optional', latest, message });
}
}

If you’re using your own backend instead, the only thing that changes is the fetch. A fetch('/config/version').then(r => r.json()) returns the same five fields and the rest of the logic stays identical.

My comparator is the boring part, on purpose. It pads to three numbers and compares integers. No semver dependency, no edge case when the store returns 1.0.

function normalize(v: string): string {
const parts = v.trim().split('.');
while (parts.length < 3) parts.push('0');
return parts.slice(0, 3).map((p) => p.replace(/[^0-9]/g, '') || '0').join('.');
}

function compare(a: string, b: string): number {
const pa = normalize(a).split('.').map(Number);
const pb = normalize(b).split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (pa[i] > pb[i]) return 1;
if (pa[i] < pb[i]) return -1;
}
return 0;
}

This intentionally ignores prerelease tags and build metadata because I’m only comparing production store versions.

Get Manthan Kasle’s stories in your inbox

Join Medium for free to get updates from this writer.

Two small things worth pointing out in that snippet.

I set the defaults so current is the latest version and 0.0.0 is the minimum. If the remote source is unreachable or the fetch times out, the app concludes it's up to date and shows nothing. A network hiccup should never trap a user behind a forced screen. This is the kind of thing you only think to set after the first time you accidentally trap a user behind a forced screen.

The fetch interval is 60 seconds, which is fine for development but too aggressive for production. Bump it to a few hours before you ship. Remote Config’s free tier is generous, and a backend endpoint costs you next to nothing, but there’s no reason to hammer either on every cold start.

Once the new code was in I ran:

npm uninstall react-native-store-version semver sp-react-native-in-app-updates @types/semver

And the old getUpdateType.ts helper went in the bin. The migration was one file in, one file and three deps out.

The UI is a bottom sheet Modal styled to match the app, with an Ionicons cloud-download-outline icon. The forced variant hides the Later button, the optional variant shows it. There's also a silence counter wired to AsyncStorage: if a user dismisses the optional prompt three times for the same version, I stop showing it until a newer version ships. The threshold sits in an env var so I can tune it without a release.

The one rule that will bite you

Your remote source tells users what’s available. It does not deliver the binary. The new build has to actually be live on the store before you touch those keys, not just approved.

Get the order wrong and you’ve made things worse than not having the prompt at all. Bump ios_latest_version to 1.2.0 while review is still pending, the user sees the prompt, taps Update, App Store still shows 1.1.1. Now they're confused and you look broken to your own users.

Here’s the order that works:

  1. Build and submit to the store.
  2. Wait until it’s actually live, not just approved.
  3. Then bump the remote value.

When I actually force an update

I keep minimum_version for emergencies. The bar is high. I'll force when:

  • A payment bug is costing real money per transaction.
  • There’s a security issue I can’t patch on the backend.
  • The server made an API change it can’t serve backwards compatibly.

Everything else stays optional with the silence counter doing its job. Most of the time users update on their own anyway. Forcing is for the days you can’t afford to wait.

What I gave up

Play Core on Android is honestly nicer than my custom sheet. Native UI, real progress bar, the download happens inside the app without sending you to the Play Store. I gave it up on purpose. Two reasons.

One, I wanted iOS and Android to behave the same so there’s one flow in my head instead of two. Two, Play Core does nothing for kill switch logic, custom messages, or anything I want to drive from a backend. It’s a UI library, not a release strategy.

If you’re Android only and you don’t need a kill switch, sp-react-native-in-app-updates is still the right choice. For cross platform with backend control, the remote-driven approach wins for me.

Gotchas summary

  • App Store versions sometimes come back as 1.0 instead of 1.0.0. semver.valid() rejects these. Normalise before comparing.
  • Set your remote defaults to the current version and 0.0.0 minimum. A failed fetch should never lock a user out.
  • Bump the remote value after the binary is live on the store, never before. Apple’s “approved” is not the same as “available to download”.
  • Forced updates are aggressive. Reserve minimum_version for emergencies, not routine releases.
  • minimumFetchIntervalMillis: 60_000 is a dev value. Bump it to hours in production. If you're on your own backend, cache the response.
  • Add the silence counter or your optional prompt will train users to swipe it away every launch.

Wrapping up

Scraping the store version is fine if you ship once a quarter and never need to pull a bad build. The day you actually need to force a fix in under an hour, you’ll wish you had backend-controlled update enforcement you could flip from a web console or a curl request.

I’d rather carry one I never use than need one I don’t have at 2am with a broken checkout in production.

If you’re maintaining a React Native app that ships often, this is one of those quiet rewrites that pays for itself the first time you actually need it.

--

--

Manthan Kasle
Manthan Kasle

Written by Manthan Kasle

Software engineer working on cross platform applications with React Native and web, sharing learnings from real world development.