← All posts

7 min read

Feature Flags and Forced Updates in Mobile Apps: Shipping Without Fear

How I use feature flags and forced updates in mobile apps: remote config, kill switches, staged rollouts, minimum versions, in-app updates and API versioning.

  • Flutter
  • Feature Flags
  • Release
Cover illustration for Feature Flags and Forced Updates in Mobile Apps: Shipping Without Fear

On the web, a bad deploy is fixed by another deploy. On mobile, a bad release sits on people’s phones until they decide to update, which for some users means never. App review adds a delay on top of that.

That’s why every app I ship now has two things wired in before launch: a way to turn features on and off remotely, and a way to tell old versions they have to update. Neither is complicated. Both are painful to add after the fact, because the versions already in the wild don’t have them.

Remote config: the foundation

Feature flags and forced updates both rely on the same thing: the app fetches a small config document at startup and behaves based on it. You have two realistic options.

Firebase Remote Config is the quick path if you’re already on Firebase. It gives you a console, conditions (by platform, app version, country, user property), percentage rollouts and caching.

Future<void> initRemoteConfig() async {
  final config = FirebaseRemoteConfig.instance;

  await config.setConfigSettings(RemoteConfigSettings(
    fetchTimeout: const Duration(seconds: 10),
    minimumFetchInterval: const Duration(hours: 1),
  ));

  await config.setDefaults(const {
    'new_checkout_enabled': false,
    'min_supported_version': '1.0.0',
    'latest_version': '1.0.0',
  });

  try {
    await config.fetchAndActivate();
  } catch (_) {
    // Offline or timed out: we keep the last activated values or the defaults.
  }
}

Your own endpoint is a GET /config on your API that returns JSON. It’s more work up front, but it keeps everything in your stack, lets you compute flags per user on the server, and suits teams that don’t use Firebase. I’ve done both. The app-side code looks almost the same either way, because I hide it behind a small interface:

abstract interface class Flags {
  bool get newCheckoutEnabled;
  bool get paymentsKillSwitch;
  Version get minSupportedVersion;
  Version get latestVersion;
}

Screens depend on Flags, not on Firebase. That makes them easy to test, and swapping providers later doesn’t touch UI code.

Two rules I don’t bend:

  • Defaults must be safe. If the fetch fails, the app should still work. New features default to off. Kill switches default to “not killed.”
  • Don’t block startup on the network. Use cached values immediately and apply fresh ones on the next launch or at a safe moment. A spinner waiting for config on a slow connection is a bad first impression.

Kinds of flags

Not all flags are the same, and mixing them up is how flag systems turn into swamps.

Kind Example Lifetime
Release flag Hide a half-finished feature in production Weeks; delete after launch
Rollout flag Enable new checkout for a percentage of users Weeks; delete after full rollout
Kill switch Turn off payments if the provider is down Permanent
Config value Max upload size, support email, banner text Permanent
Version gate Minimum supported version Permanent

Release and rollout flags are temporary by design. Kill switches and config values are part of the product. Keep them clearly separated in naming (I prefix kill switches with kill_) so nobody deletes one during cleanup.

Kill switches

A kill switch is a flag for a feature that already shipped, there to turn it off when something outside your control goes wrong: a third-party SDK starts crashing, a payment provider has an outage, a backend endpoint is misbehaving.

When the switch is flipped, the app should degrade gracefully: hide the entry point, or show a friendly “temporarily unavailable” message. Never just crash or show a blank screen. I add kill switches around anything that depends on an external service, especially payments. If you’re integrating something like Stripe Connect, a switch that hides checkout and shows a message is cheap insurance.

Test the off state. A kill switch that’s never been flipped in QA is a theory, not a safety net.

Staged features

Rollout flags let you ship code in a release but turn the feature on gradually. Remote Config supports percentage conditions out of the box. With your own endpoint, hash the user ID into a bucket from 0 to 99 on the server and compare it with the rollout percentage, so the same user always gets the same answer.

Pair it with the stores’ own tools. Google Play supports staged rollouts of the binary, and App Store Connect has phased release for automatic updates. Those control who gets the build. Flags control who gets the feature. Using both means a problem can be contained at either level. The release checklist I use covers the store side.

Watch your crash reporting and key metrics as you increase the percentage. The whole point of a gradual rollout is to look before going further.

Forced and soft updates

This is the version gate. The config holds two values:

  • min_supported_version: below this, the app must update. Show a blocking screen.
  • latest_version: below this (but above the minimum), suggest an update with a dismissible banner or dialog.

The comparison uses the app’s own version from package_info_plus and proper semantic version comparison. Don’t compare version strings directly, because "1.10.0" sorts before "1.9.0" as text. The pub_semver package handles this:

enum UpdateStatus { upToDate, optional, required }

Future<UpdateStatus> checkUpdateStatus(Flags flags) async {
  final info = await PackageInfo.fromPlatform();
  final current = Version.parse(info.version);

  if (current < flags.minSupportedVersion) return UpdateStatus.required;
  if (current < flags.latestVersion) return UpdateStatus.optional;
  return UpdateStatus.upToDate;
}

What the update screen does depends on the platform.

Android has a real in-app update API. The in_app_update package wraps it:

Future<void> runAndroidUpdate({required bool immediate}) async {
  final info = await InAppUpdate.checkForUpdate();
  if (info.updateAvailability != UpdateAvailability.updateAvailable) return;

  if (immediate && info.immediateUpdateAllowed) {
    await InAppUpdate.performImmediateUpdate();
  } else if (info.flexibleUpdateAllowed) {
    await InAppUpdate.startFlexibleUpdate();
    await InAppUpdate.completeFlexibleUpdate();
  }
}

An immediate update takes over the screen until it’s done. A flexible update downloads in the background while the user keeps working. This only works for builds installed from Google Play, so keep a “Open Play Store” button as a fallback.

iOS has no equivalent API. The update screen opens your App Store page with url_launcher, using LaunchMode.externalApplication and your app’s https://apps.apple.com/app/id... link.

A few things that make forced updates less hostile:

  • Use them rarely. A forced update is for broken or insecure versions, or ones the backend can no longer support. Not for “we added a feature.”
  • Allow a grace period. Raise latest_version first, wait for most users to update, then raise the minimum.
  • Check on resume, not just on launch. People leave apps open for days.
  • Make sure the store actually has the new version. Bump the minimum only once the release is live on both stores in every region. Forcing users to an update they can’t download yet locks them out entirely.

API versioning so old clients don’t break

Forced updates are the last resort. The first line of defense is a backend that doesn’t break old apps in the first place.

  • Version your API from day one (/v1/, or a header). Breaking changes go into a new version. The old one keeps working until usage is low enough to retire it.
  • Only make additive changes within a version. New optional fields are fine. Renaming or removing fields is not.
  • Make the app tolerant. Ignore unknown JSON fields and unknown enum values instead of crashing on them. An enum with an unknown case is cheap insurance.
  • Send the app version with every request (a header such as X-App-Version). The backend can log it, measure who’s still on old versions, and, as a backstop, return a specific error that the app turns into the forced update screen.

That last point means even a version that missed a config fetch will still hear “please update” the next time it calls the API.

Cleaning up stale flags

Every release flag is a small if that doubles the number of paths through that code. Ten stale flags means a codebase nobody fully understands, with dead branches that still get compiled, shipped and occasionally triggered by a misconfigured console.

My process is simple:

  • Every temporary flag gets an owner and a removal ticket when it’s created.
  • Once a feature is at 100% and stable, remove the flag in the next release. Delete the code path, not just the console entry.
  • Leave the remote key in place until old versions are gone, since older builds may still read it. Then delete it.
  • Review the flag list periodically. If nobody can explain what a flag does, that’s the first one to investigate.

Takeaways

  • Wire in remote config and a version gate before launch. You can’t add them to versions already installed.
  • Use safe defaults and never block startup on fetching config.
  • Separate temporary flags (release, rollout) from permanent ones (kill switches, config, version gates).
  • Force updates rarely: raise the soft “latest” version first, then the minimum, and only once the build is live everywhere.
  • Version your API, make additive changes only, and have the app tolerate unknown fields.
  • Give every temporary flag an owner and a removal date, and actually delete it.

None of this is glamorous, but it’s the difference between a bad release being a crisis and being a slightly busy afternoon.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).