Shipping a web feature is one HTTP request away from every user.
Shipping a mobile feature is two weeks away from your slowest-updating user, blocked behind an app-store review queue, gated by whether their device is on Wi-Fi when your SDK tries to refresh the flag, and at the mercy of whatever crashed in the background last time the app launched.
Most feature-flag advice is implicitly web advice. Edge evaluation, percentage rollouts, kill switches that take effect in 500ms, all true on the web, all approximately none of those things on mobile without extra work. If you’re wiring up flags in an iOS or Android app and the patterns from your backend team don’t quite fit, that’s because they don’t.
This post is about the three constraints mobile engineers have that web engineers don’t, and the patterns that exist because of them.
Three constraints the web doesn’t have
App-store latency. When you push a fix to a web bundle, it reaches every browser on next page load. When you push a fix to a mobile binary, it reaches the App Store review queue first. Review can take hours or days. Then Apple or Google’s staged rollout takes several more days. Then users have to actually update, which a non-trivial percentage of them won’t do voluntarily for weeks. Your “fix” is eight to fourteen days from being on every device.
That’s why kill switches matter more on mobile, not less. The flag is the only lever that operates on a sub-day timeline.
Offline behavior. A web app calling your flag service from a desk in an office is online. A mobile app riding the subway, in a basement, on a flight, or just behind a flaky cell tower is regularly offline. Your flag SDK has to behave correctly with no network, meaning it has to ship with a cached value, default to a known-safe behavior when the cache is empty, and never block the UI on a flag fetch.
Persistent local state. A web user’s state mostly lives on the server. A mobile user’s state is on the device, often in a SQLite database, a Core Data store, or a Room schema. When a feature flag flips, that local state doesn’t flip with it. Code paths that wrote v2-shaped data to disk yesterday are now reading it as v1, or vice versa. The off-state of the flag has to handle data that was created during the on-state.
These three constraints, slow propagation, unreliable network, stateful disk, are why mobile feature-flag patterns diverge from web ones.
Cache strategies, ranked
Every mobile flag SDK has a caching strategy. Most of them are wrong for at least some use cases.
Synchronous fetch on launch. The app blocks startup until the flag service responds. This is never the right answer for production. Cold-start time is one of the most-watched mobile metrics, and the first thing that breaks on a flaky network is your launch screen.
Lazy fetch. The app launches with whatever’s in cache, fetches in the background, and applies the new values when they arrive. This is what most SDKs default to. It works fine for slowly-changing flags but is dangerous for kill switches, a user whose app launches and then crashes before the background fetch completes will keep crashing.
Eager fetch with timeout fallback. The app waits for fresh values for a budget of, say, 200ms. If the response arrives in time, use it. Otherwise, use the cached value and let the new one apply on next launch. This is the right default for most apps, fresh when possible, cached when not, never blocking.
Foreground fetch on activate. The app re-fetches every time it comes back from background. This is the closest analog to web’s “every page load.” It’s expensive in battery and bandwidth, so reserve it for apps where session boundaries matter (chat, finance) and not where they don’t (read-only content).
The right strategy is rarely just one of these. A typical production setup uses eager-with-timeout on cold start and foreground-fetch on resume, with a few specific high-criticality flags marked as “must be fresh.”
Targeting on app version
On the web, every user is on the same version because there is no version, there’s whatever was deployed last. On mobile, a single feature flag is being evaluated by users running ten different app versions simultaneously, some of whom have code paths that don’t exist anymore and some of whom don’t have code paths that do.
Every mobile flag rule should target on app version.
val showRedesignedFeed = flags.getBoolean(
"redesigned-feed",
context = mapOf(
"userId" to user.id,
"appVersion" to BuildConfig.VERSION_NAME,
"platform" to "android",
"osVersion" to Build.VERSION.RELEASE,
),
default = false,
)
The flag rule then says something like: enabled for app version >= 4.7.0. A user on 4.6.2 simply gets false, regardless of their bucket, because the code that implements the redesigned feed doesn’t exist in their binary. Without version targeting you’ll eventually flip a flag, ship it to a v3 user, and trigger a code path that calls into v4-only code. That’s a crash on every launch until they update.
appVersion plus platform plus osVersion is the minimum context for any mobile flag. country and locale are usually worth including too.
The force-update lever
Sometimes a bug is so bad that even the kill switch can’t fix it, usually because the bug is in the code that fetches the kill switch. A native crash before the SDK initializes. A startup loop that bails before the background fetch completes. A serialization error in the cached flag store.
For these cases, mobile apps have a lever the web doesn’t: the force-update prompt. A small, separate config check, served from a CDN with aggressive caching, that on launch says “this version is no longer supported, please update.” The user is sent to the App Store and the app refuses to start.
This is a kill-switch of last resort, and it’s only as good as the next binary you have available to update to. Treat the force-update mechanism as something to keep healthy and tested, not something you reach for once a year and discover doesn’t actually work.
Crash-loop protection
The worst mobile failure mode is a feature flag whose on-state crashes the app on launch. A user’s app crashes, restarts, fetches the latest flags (still on), crashes again, restarts. The app never stays alive long enough for the background fetch to deliver the off-state. The user uninstalls.
Two patterns help.
Watchdog: count crashes since last successful launch. If the app has crashed three times in a row, ignore the cached flag store and fall back to safe defaults. This is a single counter in UserDefaults or SharedPreferences, reset to zero on a successful launch.
let crashCount = UserDefaults.standard.integer(forKey: "crash_count")
let useSafeDefaults = crashCount >= 3
let flags = useSafeDefaults
? FlagStore.safeDefaults
: FlagStore.cached ?? FlagStore.safeDefaults
UserDefaults.standard.set(crashCount + 1, forKey: "crash_count")
// ... reset to 0 once initialization completes successfully
Foreground-only flag changes. Apply new flag values only at app foregrounding, not in the middle of an existing session. This lets crashes attributed to a flag flip stay confined to a single relaunch attempt, rather than triggering mid-session for users who started before the change.
These two patterns together prevent the most expensive mobile failure mode: a flag-induced uninstall.
Anti-patterns
The synchronous launch fetch. Already covered, but worth repeating: never block app launch on a flag service. The flag service goes down for ten minutes and your app’s launch metrics light up like a Christmas tree.
The schema-changing flag. A flag whose on-state writes data to disk in a different shape than the off-state. Flipping it twice corrupts the local store. If your feature genuinely changes persisted-data shape, do the schema migration unconditionally and gate only the behavior, not the storage.
Treating mobile like web in the SDK. A backend team that picks a flag SDK based on web benchmarks and ports it directly to mobile will discover, painfully, that the abstractions don’t translate. Pick an SDK that knows about offline mode, caching strategies, version targeting, and crash-loop protection, or accept that you’re going to build those layers yourself on top.
Running a single flag for all platforms. A flag named new-checkout evaluated identically on web, iOS, and Android sounds clean until you need to ramp differently per platform, which you always do, eventually. Either bake platform into your targeting rules from day one, or use platform-prefixed flag names (web.new-checkout, ios.new-checkout).
Mobile flagging is a different problem
The same flag primitive, a switchable value evaluated against user context, works on the web, in backends, on mobile, and in embedded devices. The patterns around it diverge sharply once your runtime can be offline, your binary can be a week stale, and your kill switch has to fight an App Store.
If you’re a mobile engineer and your team’s flag system was designed by web engineers, the gap is real. The patterns above are the bridge: cache eagerly with a timeout, target on version, treat force-update as your last lever, and protect against crash loops. Get those four right and you have a flag system that survives the constraints mobile actually has.
ShipSilently SDKs ship with offline-first caching, app-version targeting built into the rule engine, and crash-loop protection on by default. Try it free.