With fast releases, we kept seeing the same thing in our numbers: a lot of users stuck on old versions of the app. Almost nobody updates apps by hand anymore. And this isn’t just a number that looks bad on a chart, it means more support tickets, more confused users, and people missing out on features we already shipped for them.
So we built something we call the Gatekeeper. It nudges users to update, and we can control everything about it remotely, without waiting on an app store review.
Design inspiration:
How it works
There are three parts: a config that holds the rules, a dashboard to change those rules live, and logic inside the app that decides what to show the user.
The config
Store the config in DB, added a cache layer (redis) so it loads fast when the app opens.
// AppUpdateGateConfig
const config = {
rev: 102,
ios: {
minRequiredVersion: "1.8.0",
latestVersion: "1.9.22",
coolDownHours: 24
},
android: {
minRequiredVersion: "1.8.0",
latestVersion: "1.9.20",
coolDownHours: 48
}
};
If the config fails, show nothing
One rule we were strict about: if the config can’t be reached, we don’t block the user or guess. We just skip the nudge entirely.
// Simplified fetch logic
async function getAppUpdateGateConfig() {
try {
const response = await fetch("/app-config");
return await response.json();
} catch (error) {
console.error("Config service unreachable, failing open ", error.message);
return null;
}
}
The main logic
The mobile app evaluates state in a specific priority. This ensures that a critical “Force Update” always trumps a “Soft Nudge” or a “Global Cooldown.”
Here’s the order:
- Is the version too old to even use? → Force an update. No exceptions.
- Did we already show a nudge recently? → Show nothing.
- Did the last OTA update fail? → Ask them to retry (Over-the-Air)
- Is a newer version simply available? → Show a soft nudge.
function getUpdateDecision(appState, config) {
const { currentVersion, otaFailed, lastNudgeAt } = appState;
const { minRequiredVersion, latestVersion, coolDownHours } = config;
// 1. Force update — this always wins, no cooldown check
if (isVersionLower(currentVersion, minRequiredVersion)) {
return "FORCE_UPDATE";
}
// 2. Cooldown — don't nudge too often
const cooldownMs = coolDownHours * 60 * 60 * 1000;
if (Date.now() - lastNudgeAt < cooldownMs) {
return "NONE";
}
// 3. Retry a failed OTA update
if (otaFailed) {
return "OTA_RETRY";
}
// 4. Soft nudge — a newer version exists
if (isVersionLower(currentVersion, latestVersion)) {
return "SOFT_NUDGE";
}
return "NONE";
}
A few things worth mentioning
1. Avoiding “Nudge Fatigue”
By implementing a Global Cooldown, we ensure we aren’t “nagging” the user. If they dismiss an OTA retry, they won’t be immediately hit with a Store Update prompt. We value the user’s session flow over aggressive metrics.
2. Rescuing Failed OTA Updates
OTA (Over-The-Air) updates are brilliant but brittle. Sometimes a download fails due to a spotty connection. Instead of leaving the user on a stale JS bundle, we detect the failure and show a specific “Retry” nudge, allowing them to fix the app state without a full App Store trip.
3. Real-Time Admin Control
The Admin Console allows us to set these thresholds per platform. This is critical because iOS and Android have different review cycles. We can “turn up the heat” on a version update once we see stable metrics on one platform while leaving the other untouched.
Conclusion
This system moves us away from static versioning into a dynamic, experiment-ready infrastructure. By respecting user cooldowns while enforcing critical floor versions, we’re narrowing the version gap and ensuring every user has the best version of app in their pocket.