Force Update in Flutter (Part 1): Why Mobile Apps Need It
Force Update series: 1. Why you need it · 2. Upgrader · 3. Remote config intro · 4. force_update_helper · 5. GitHub Gist · 6. Firebase Remote Config · 7. Dart Shelf API
Picture this: you ship a build that corrupts local data for a small set of users. On the web you deploy a fix and everyone is healed on refresh. On mobile? Half your users still open last week's binary for days — sometimes weeks — even with "automatic updates" enabled.
That is the moment force update stops being a nice-to-have and becomes production infrastructure.
This post is how I implement force update in Flutter apps: why it exists, the strategies that work, soft vs hard prompts, and how I wire remote minimum versions without turning the app into a nag screen.
Why Mobile Needs Force Update
iOS and Android can auto-update, but:
- Updates are not immediate
- Many users disable auto-update (storage, data plans, caution)
- Enterprise / sideloaded devices are even slower
Without a force-update path you cannot honestly assume "everyone is on the latest version." That costs real money:
- Critical security fixes never reach a chunk of the install base
- Old clients keep calling deprecated backend endpoints forever
- Backend migrations stall because v1.2 still hits the old schema
- QA has to regression-test every version you ever shipped
A strict force-update policy lets mobile and backend teams support a window of versions — not infinite history.
Catch that kills teams: force update logic must ship in a release before you need it. Users already stuck on builds that never check versions will never see your prompt. Bake it into v1.0 if you can.
Force Update vs Code Push
Two tools for two jobs:
| Approach | What it does | When I use it |
|---|---|---|
| Force update | Block (or strongly nudge) until the user installs a store build | Native plugin changes, store policy, schema breaks, security |
| Code push (e.g. Shorebird) | Patch Dart over the air without a store round-trip | Logic bugs, UI fixes, non-native hotfixes |
They complement each other. Shorebird cannot replace a store update when you change native code, add plugins, or need a new binary. Force update cannot deliver a same-day Dart fix as fast as OTA. I plan for both; this article focuses on force update. (I wrote more on OTA in my Shorebird guide.)
Strategies That Work
At the core the algorithm is boring — and that is good:
- Read current app version (
package_info_plus) - Fetch required (or supported) version from somewhere remote
- Compare with a real semver comparator
- If outdated → show a store CTA (hard) or a dismissible nudge (soft)
How you define "outdated" is the product decision:
1. Always upgrade to latest store version
Packages like upgrader compare pubspec / installed version to App Store / Play listing and prompt whenever something newer is live.
Pros: Zero backend work, great for consumer apps that always want people current.
Cons: You cannot say "1.4 is still fine while we stage 1.5." Every store release becomes a potential nag.
Use this when you want constant currency and do not need remote control.
2. Minimum required version (my default)
Remote config (or your API) exposes something like required_version: 1.4.0. If current < required, force update. Everyone on 1.4.x+ keeps working even if 1.5 is already in the store.
Pros: You control the kill switch. Ship freely; only raise required_version when you must.
Cons: You own the config and the comparison logic.
This is what I put in production for apps with a real backend.
3. Rolling support window
Allow the last N store versions (or anything ≥ min and ≤ max). Useful for gradual deprecation.
Soft vs hard update
- Hard: non-dismissible. Only "Update now" → store URL. Use for security, data corruption, broken auth, incompatible API.
- Soft: "Update available" with Later / Ignore. Use for polish releases and features users can live without for a week.
Never hard-block for vanity version bumps. Users hate it, reviews reflect it.
Option A — upgrader (store comparison)
Quick path when you mainly care that people match the store.
dart pub add upgrader package_info_plus url_launcher
If you use named routes / GoRouter, wrap with MaterialApp.builder and pass a root navigatorKey so the dialog has a navigator:
final rootNavigatorKey = GlobalKey<NavigatorState>();
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: rootNavigatorKey,
builder: (context, child) {
final dialogStyle =
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS
? UpgradeDialogStyle.cupertino
: UpgradeDialogStyle.material;
return UpgradeAlert(
navigatorKey: rootNavigatorKey,
dialogStyle: dialogStyle,
// Soft by default — tighten for hard force:
// showIgnore: false,
// showLater: false,
// barrierDismissible: false,
child: child ?? const SizedBox.shrink(),
);
},
onGenerateRoute: onGenerateRoute,
);
}
}
How to test: the package compares installed version to the published store listing. Use a real published app, drop version: in pubspec.yaml below the store version, and test on a device. For local iteration only:
// DEV ONLY — remove before release
await Upgrader.clearSavedSettings();
When I reach for it: simple apps, no custom backend deprecation story.
When I skip it: I need remote control of when a version dies, independent of "is there a newer build on the store."
Option B — Remote minimum version (recommended)
Pseudocode every production force-update I ship ends up looking like:
Future<bool> isAppUpdateRequired() async {
if (kIsWeb) return false;
if (defaultTargetPlatform != TargetPlatform.iOS &&
defaultTargetPlatform != TargetPlatform.android) {
return false;
}
final required = await fetchRequiredVersion(); // remote
final info = await PackageInfo.fromPlatform();
return _isLower(info.version, required);
}
Where you store required_version is a team choice:
| Source | Good when | Watch-outs |
|---|---|---|
| GitHub Gist / static JSON | Prototypes, indie apps | Rate limits, no auth story |
| Firebase Remote Config | App already on Firebase | Fetch intervals, defaults offline |
| Your own API | Real backend, multi-env | You operate the endpoint |
Packages like force_update_helper wrap the fetch + compare + store open flow so you are not re-inventing dialogs. The important part is the policy, not the package name.
Firebase Remote Config sketch
Future<String> fetchRequiredVersion() async {
final remoteConfig = FirebaseRemoteConfig.instance;
await remoteConfig.setConfigSettings(RemoteConfigSettings(
fetchTimeout: const Duration(seconds: 10),
minimumFetchInterval: const Duration(hours: 1),
));
await remoteConfig.setDefaults(const {
'required_version': '1.0.0',
});
await remoteConfig.fetchAndActivate();
return remoteConfig.getString('required_version');
}
Raise required_version in the Firebase console only after the fixed build is live on both stores you care about. Force-updating people into a version that is not downloadable yet is a support nightmare.
Custom backend sketch (Shelf / any stack)
// GET /required_version → plain text "1.4.2"
ForceUpdateClient(
fetchRequiredVersion: () async {
final response = await dio.get('$baseUrl/required_version');
return response.data as String;
},
iosAppStoreId: Env.appStoreId, // Android uses package name from the platform
);
Point baseUrl at env-specific hosts (dev / stg / prod) so QA can force-update staging without bricking production. Backend env var example:
Response requiredVersion(Request request) {
final version = Platform.environment['REQUIRED_VERSION'] ?? '1.0.0';
return Response.ok(version);
}
Opening the right store
Hard prompts must deep-link correctly:
Future<void> openStoreListing({
required String androidPackageId,
required String iosAppStoreId,
}) async {
final uri = Platform.isIOS
? Uri.parse('https://apps.apple.com/app/id$iosAppStoreId')
: Uri.parse(
'https://play.google.com/store/apps/details?id=$androidPackageId',
);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
On Android, url_launcher needs the VIEW intent query in AndroidManifest.xml or store links silently fail on newer OS versions.
Semver Comparison — Do Not Hand-Roll Casually
String compare ("1.10.0" < "1.9.0") is a classic footgun. Use a small semver helper or a package, and decide how you treat build numbers (+42) and pre-releases.
bool isLower(String current, String required) {
List<int> parts(String v) => v
.split('+')
.first
.split('-')
.first
.split('.')
.map((p) => int.tryParse(p) ?? 0)
.toList();
final a = parts(current);
final b = parts(required);
final len = max(a.length, b.length);
for (var i = 0; i < len; i++) {
final x = i < a.length ? a[i] : 0;
final y = i < b.length ? b[i] : 0;
if (x < y) return true;
if (x > y) return false;
}
return false;
}
Unit-test this harder than the dialog widget. Wrong comparison = either silent non-update or infinite force-update loops.
Where It Lives in the App Tree
I gate the app after splash / startup but before authenticated home:
App launch
→ init SDKs / theme
→ fetch required_version (with timeout + cache)
→ if required → ForceUpdateScreen (hard) or soft banner
→ else → normal navigation
Failure modes matter:
- Network down: do not hard-block if you cannot reach config (unless last-known-required already says update). Cache the last successful required version.
- Timeout: fail open for soft policy, fail closed only for known-critical flags you already cached.
- Web / desktop: skip store force update; those platforms redeploy differently.
Soft Update UI Pattern
Hard screens are easy. Soft updates deserve restraint:
// Once per version, not every cold start
final dismissedFor = prefs.getString('soft_update_dismissed_for');
if (dismissedFor == latestStoreVersion) {
// user already said later for this build
}
Show a bottom sheet or banner, not a full-screen wall. One primary CTA ("Update"), one secondary ("Not now"). Track analytics: soft_update_shown, soft_update_accepted, soft_update_dismissed.
Checklist I Use Before Raising required_version
- Fixed build is approved and available on Play (and App Store if you ship iOS)
required_versionis ≤ the version users can actually download- Release notes explain why (security / data / login) — not "we felt like it"
- Support knows the kill switch and how to lower it if a bad store build ships
- Staging env already validated the prompt + store deep link
- Shorebird / OTA considered first if the fix is Dart-only
What I Ship in Real Apps
For Focus Flow, Dev Discipline, and similar products my default is:
- Remote minimum version (API or Remote Config) — control stays with me
- Hard update only for security, data integrity, auth, and API breaks
- Soft update for normal releases (optional;
upgraderor custom banner) - Shorebird for urgent Dart-only patches when a store wait is unacceptable
- Force-update client in the first store build, not "we'll add it later"
That combo keeps maintenance windows honest without harassing users for every purple-button tweak.
Summary
- Mobile auto-update is not a force-update strategy.
- Ship the check early; you cannot retroactively force builds that never ask.
- Prefer remote minimum version over "always newest store build" when you have a backend.
- Hard block rarely; soft nudge often.
- Pair with code push for speed, store force update for binary truth.
Next: implement the simplest store-based prompt with the upgrader package.



