Feature flags on mobile let you control releases after the app is already installed: ship code behind a gate, turn it on gradually, and flip a kill switch if things go sideways. This is how you avoid “app store panic” when a bug slips through review or a server change breaks a flow. In this guide you’ll build a mobile-friendly flag setup: fast startup, offline-safe defaults, targeted rollouts, and a clean process that doesn’t leave your codebase full of forever-flags.
Quickstart
If you only do a few things this week, do these. They’re the highest leverage steps to ship safely and roll back fast
without turning your app into a nest of if(flag) statements.
1) Create a flag catalog (names, types, owners)
A catalog prevents the two classic mobile failures: duplicate flags for the same thing, and “mystery flags” nobody dares to delete.
- Use stable keys (snake_case) and keep them consistent across iOS + Android
- Pick a type per flag: release, kill switch, experiment, or config
- Assign an owner + an expiry date (yes, even for “temporary”)
- Define a safe default that works offline
2) Make flags “read fast, refresh later”
Mobile startup is sacred. Flags should be available instantly from cache and updated asynchronously.
- Load cached flags on app start (local storage)
- Refresh in the background with a timeout
- Use TTL + ETag (or a version) to avoid unnecessary downloads
- Never block first render on a network fetch
3) Add a kill switch for risky surfaces
A kill switch isn’t a feature rollout. It’s your emergency brake: a single flag that disables a risky path instantly.
- Gate the new feature entry point (button, route, deep link)
- Also gate the backend call (server-side validation)
- Define a safe fallback UI (message + alternative action)
- Write a “rollback drill” and test it monthly
4) Instrument exposure (so you can debug)
When a bug appears, you need to know: who saw the flag, what variant they got, and when it changed.
- Log a single “exposure” event per session/flag
- Include: flag key, value/variant, config version, app version
- Use this data to compare crash rates and funnel changes
- Keep the payload privacy-safe (no raw PII)
If the app can’t reach your flag service, it should still behave safely. Offline-safe defaults + cached config is the difference between “controlled rollout” and “random rollout.”
Overview
Mobile releases are uniquely stressful: once an app build is in the store (and installed), you can’t “hotfix” the binary for everyone instantly. Review delays, staged rollout delays, and user update behavior mean bugs can linger longer than on the web. Feature flags on mobile solve this by separating shipping code from exposing behavior.
What you’ll learn in this post
- Which mobile features should (and shouldn’t) be flaggable
- How to design a flag catalog that stays maintainable across platforms
- A mobile-friendly client architecture: cached reads, async refresh, and fail-closed rules
- Rollout patterns: percentage, cohorts, allowlists, and “kill switches”
- Testing and observability so flags don’t become invisible tech debt
- A deletion workflow to remove flags once they’re done
| Problem | What happens without flags | What flags enable |
|---|---|---|
| Risky feature launch | All users get it at once (or you delay shipping) | Gradual rollout + easy rollback |
| Backend change | Old clients break unexpectedly | Client compatibility toggles + safe defaults |
| Experimentation | Ship a full release for every A/B test | Targeted variants and controlled exposure |
| Operational incidents | Only options are “wait” or “ship a new build” | Kill switches + quick mitigation |
Store rollouts control who can install the binary. Feature flags control what the binary does. In practice you’ll use both: staged rollout for the app update, and flags for behavior and fast rollback.
Core concepts
The fastest way to get feature flags wrong is to treat them as “a boolean from the cloud.” On mobile, flags are a configuration system with strict constraints: startup performance, offline behavior, privacy, and cross-platform consistency.
1) The four flag types you should name explicitly
| Type | What it’s for | Lifetime | Mobile tip |
|---|---|---|---|
| Release toggle | Gradually enable a new feature | Temporary | Always add an expiry date and deletion task |
| Kill switch | Emergency disable for risky paths | Long-lived (rarely used) | Fail closed (default off) and test the drill |
| Experiment flag | A/B tests and variants | Temporary | Log exposure once per user/session to avoid noisy analytics |
| Config parameter | Numbers/strings: thresholds, timeouts, UI text | Can be long-lived | Validate ranges and keep safe fallback values |
2) Mobile constraints that change the design
What mobile makes harder
- Cold start: network calls are expensive and flaky
- Offline periods: users may open the app on a plane or with spotty signal
- Review/update lag: fixes can take days to reach everyone
- Multiple app versions: your fleet is always mixed
What a good flag system guarantees
- Instant reads (from cache) for UI decisions
- Safe defaults that preserve core flows
- Version-aware rules (don’t enable features on unsupported builds)
- Deterministic targeting (same user gets same variant)
3) The flag lifecycle: ship, ramp, graduate, delete
Feature flags become “flag debt” when they never leave. The clean mental model is a lifecycle: you introduce a flag for a purpose, run it, then remove it when it’s no longer needed.
A healthy lifecycle (copy/paste)
- Create: add to catalog (owner, type, default, expiry)
- Implement: code behind a gate + safe fallback path
- Ramp: 1% → 5% → 25% → 50% → 100% (watch metrics)
- Graduate: remove the gate once stable (or keep a kill switch)
- Delete: remove flag definition and dead code, then clean analytics dashboards
4) “Fail open” vs “fail closed” (choose per flag)
The most important policy decision is what happens when the flag service is unreachable. There is no universal answer, but there is a universal requirement: it must be deliberate and documented.
| Policy | Meaning | Good for | Bad for |
|---|---|---|---|
| Fail open | If unsure, enable | Non-risky UI polish, low-stakes tweaks | Payments, auth, destructive actions |
| Fail closed | If unsure, disable | Kill switches, risky flows, backend migrations | Must-have experiences where disabling breaks core UX |
Most teams use cached last-known-good behavior: use the last fetched config if it’s recent, otherwise fall back to safe defaults. This prevents both “random enablement” and “random disablement.”
5) A tiny flag catalog example (keeps teams aligned)
Whether you use a third-party tool or a custom service, the idea is the same: treat flags as product artifacts with owners, defaults, and rules. Here’s a minimal catalog shape.
flags:
checkout_v2:
type: release_toggle
owner: payments-team
default: false
expires_on: "2026-03-31"
fail_policy: fail_closed
rules:
- if_app_version_gte: "3.12.0"
rollout_percent: 5
- if_app_version_gte: "3.12.0"
user_bucket_percent: 25
segments: ["beta_testers"]
search_ranker_variant:
type: experiment
owner: growth-team
default: "control"
expires_on: "2026-02-28"
fail_policy: fail_open
variants: ["control", "v2"]
rules:
- if_app_version_gte: "3.11.0"
rollout_percent: 50
disable_image_uploads:
type: kill_switch
owner: mobile-oncall
default: false
expires_on: "never"
fail_policy: fail_closed
rules:
- rollout_percent: 0 # set to 100 during incident
metadata:
schema_version: 1
updated_at: "2026-01-09T14:21:53+01:00"
The key is not the format—it’s the discipline: every flag has a default, an owner, and a plan for removal.
Step-by-step
This is a pragmatic implementation path that works for native iOS/Android and most cross-platform stacks. The goal is a feature flag system that is fast, offline-safe, and operationally sane.
Step 1 — Decide what should be flaggable
Good candidates
- New screens/flows (gate entry points)
- Backend migrations (old vs new API, new payloads)
- Performance-risky features (image/video processing, heavy queries)
- Experiment variants (layout, copy, ranking, onboarding)
Bad candidates
- Security and permissions decisions (should be enforced server-side)
- Core stability fixes (don’t hide a crash fix behind a flag)
- Anything that requires “instant sync” across devices without guarantees
- Complex combinatorial UI (too many interdependent toggles)
Step 2 — Define a cross-platform contract (keys + types)
Before you write code, align on the contract shared by iOS and Android: stable keys, types, defaults, and how targeting works. This prevents “Android shipped it, iOS didn’t” drift.
Contract checklist
- Keys are stable and never reused for different meanings
- Typed accessors exist (bool, string, number) with validation
- Defaults are safe and documented in the catalog
- Targeting rules include app version guardrails
- Variants are deterministic per user (bucketing strategy)
Step 3 — Choose a delivery model (and make it cache-first)
Mobile flag delivery is typically either: (1) a dedicated remote config service, (2) flags embedded in your API responses, or (3) a lightweight custom endpoint (JSON) with caching. Whatever you choose, the client rule is the same: read from disk, refresh in background.
| Approach | Pros | Cons | When to use |
|---|---|---|---|
| Remote config service | UI, targeting, auditing, experimentation support | Vendor coupling, SDK overhead | You want fast iteration + team workflows |
| Config via your API | No extra endpoint; can be user-specific naturally | Requires login/API call; not great for first-launch gating | Flags depend on server user state |
| Custom JSON endpoint | Simple, portable, easy to cache/ETag | You must build tooling + guardrails yourself | You want minimal dependencies and full control |
If you must fetch flags before rendering, you’ve accidentally built “server-driven startup.” That can work for some products, but it’s a much bigger architecture choice than “let’s add feature flags.”
Step 4 — Implement the mobile client: typed, cached, and testable
Treat the flag reader as a small local SDK: it loads cached config synchronously, exposes typed getters, and refreshes asynchronously. Keep it behind an interface so UI/business code never cares where flags come from.
Minimum capabilities
- Synchronous getters (no suspend/async required to read)
- Disk cache + TTL
- Config version/ETag support
- Typed reads with validation and safe fallbacks
- Exposure logging hook
Nice-to-have
- Local overrides for QA (protected behind debug tools)
- Kill switch shortcuts (one place to flip multiple related toggles)
- Rule evaluation on-device (simple segments)
- Snapshot of flags in bug reports
Example: a lightweight Android/Kotlin implementation (cache-first, background refresh). Adapt the networking layer and storage to your stack.
import android.content.SharedPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class FeatureFlags(
private val prefs: SharedPreferences,
private val http: SimpleHttpClient,
private val endpointUrl: String,
private val scope: CoroutineScope
) {
private val cacheKeyJson = "ff_cache_json"
private val cacheKeyEtag = "ff_cache_etag"
private val cacheKeyFetchedAt = "ff_cache_fetched_at"
private val ttlMs = TimeUnit.MINUTES.toMillis(15)
/** Read is synchronous and safe. */
fun isEnabled(key: String, defaultValue: Boolean = false): Boolean {
val json = loadCachedJson() ?: return defaultValue
return json.optBoolean(key, defaultValue)
}
fun getString(key: String, defaultValue: String): String {
val json = loadCachedJson() ?: return defaultValue
val v = json.optString(key, defaultValue)
return v.ifEmpty { defaultValue }
}
/** Refresh in the background; do NOT block UI. */
fun refreshAsync() {
scope.launch(Dispatchers.IO) {
val now = System.currentTimeMillis()
val last = prefs.getLong(cacheKeyFetchedAt, 0L)
if (now - last < ttlMs) return@launch
val etag = prefs.getString(cacheKeyEtag, null)
val resp = http.get(url = endpointUrl, ifNoneMatch = etag, timeoutMs = 1500)
when (resp.code) {
200 -> {
// Persist atomically.
prefs.edit()
.putString(cacheKeyJson, resp.body)
.putString(cacheKeyEtag, resp.etag)
.putLong(cacheKeyFetchedAt, now)
.apply()
}
304 -> {
// Not modified; extend freshness window.
prefs.edit().putLong(cacheKeyFetchedAt, now).apply()
}
else -> {
// Network failed or server error; keep last-known-good cache.
}
}
}
}
private fun loadCachedJson(): JSONObject? {
val raw = prefs.getString(cacheKeyJson, null) ?: return null
return try { JSONObject(raw) } catch (_: Exception) { null }
}
}
data class HttpResponse(val code: Int, val body: String, val etag: String?)
interface SimpleHttpClient {
fun get(url: String, ifNoneMatch: String?, timeoutMs: Int): HttpResponse
}
When flags are synchronous to read, you can safely gate UI and logic without turning everything into async code. And when refresh is async, your app still opens quickly on slow networks.
Step 5 — Rollout + targeting that won’t surprise you
Start simple. The most reliable rollout strategies are deterministic and explainable: percent rollout + version guard + (optional) allowlist. Save fancy segmentation for later unless you truly need it.
A safe rollout pattern
- Guard by app version: don’t enable on builds missing the code path
- Ramp gradually: 1% → 5% → 25% → 50% → 100% (with monitoring between steps)
- Keep a “holdback” cohort: small % that stays off for comparison
- Use deterministic bucketing: stable hashing of user/device ID
Step 6 — Observability: know when the flag hurts you
A rollout without observability is gambling. You don’t need a complex dashboard, but you do need a few signals tied to exposure:
Must-watch metrics
- Crash-free sessions (overall + flagged users)
- Key funnel conversion (overall + flagged users)
- Latency for the flagged flow (p50/p95)
- Error rate of relevant API calls
Debug metadata to log once
- Flag key + value/variant
- Config version (or ETag)
- App version + platform (iOS/Android)
- Whether value came from cache or fresh fetch
Step 7 — Prepare the rollback drill (so it’s actually fast)
“Roll back fast” is a promise you can only keep if you’ve practiced. Make the rollback path boring: a button in your config UI, a Slack/on-call runbook, and a predictable app-side fallback.
Rollback drill checklist
- Kill switch flips within minutes and is audited
- Users see a safe message + alternate path (no blank screen)
- Backend also rejects the risky operation if needed
- Post-incident: create follow-up tasks for fixes + flag cleanup
Step 8 — iOS implementation pattern (same principles)
iOS should follow the same contract: sync reads from cache, async refresh, typed getters, and exposure hooks. Here’s a compact Swift pattern you can drop into a project and expand.
import Foundation
final class FeatureFlagProvider {
private let defaults: UserDefaults
private let endpoint: URL
private let session: URLSession
private let cacheKey = "ff_cache_json"
private let fetchedAtKey = "ff_cache_fetched_at"
private let ttl: TimeInterval = 15 * 60
init(defaults: UserDefaults = .standard,
endpoint: URL,
session: URLSession = .shared) {
self.defaults = defaults
self.endpoint = endpoint
self.session = session
}
// Synchronous read: safe for UI decisions.
func isEnabled(_ key: String, default defaultValue: Bool = false) -> Bool {
guard let json = cachedJSON() else { return defaultValue }
return (json[key] as? Bool) ?? defaultValue
}
func string(_ key: String, default defaultValue: String) -> String {
guard let json = cachedJSON() else { return defaultValue }
let v = (json[key] as? String) ?? defaultValue
return v.isEmpty ? defaultValue : v
}
// Async refresh: do not block app start.
func refreshIfStale() {
let now = Date().timeIntervalSince1970
let last = defaults.double(forKey: fetchedAtKey)
if now - last < ttl { return }
var req = URLRequest(url: endpoint)
req.timeoutInterval = 1.5
session.dataTask(with: req) { [weak self] data, response, _ in
guard let self = self else { return }
guard let data = data,
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return // keep last-known-good cache
}
self.defaults.set(obj, forKey: self.cacheKey)
self.defaults.set(Date().timeIntervalSince1970, forKey: self.fetchedAtKey)
}.resume()
}
private func cachedJSON() -> [String: Any]? {
return defaults.dictionary(forKey: cacheKey)
}
}
Avoid scattering flag reads across view controllers and composables. Put decisions in a small “feature gates” layer (or a domain service) so you can test and delete flags easily.
Step 9 — Delete flags on purpose (the part everyone skips)
Once a release toggle reaches 100% and is stable, it should stop existing. Otherwise you accumulate dead branches, unreachable code paths, and “why is this off for some users?” mysteries.
Graduation checklist
- Feature is at 100% for at least one stable release cycle
- No active incidents or regressions tied to it
- Backend is compatible with the new behavior
- Rollout metrics are documented (what changed, what improved)
Deletion checklist
- Remove the flag condition + fallback path
- Delete flag definitions and dashboard entries
- Remove exposure logging for the flag
- Add a short changelog note so future you understands why
Common mistakes
Feature flags are deceptively simple. Most failures are not about “how to fetch a boolean,” but about lifecycle, defaults, and cross-platform consistency. Here are the mistakes that repeatedly cause mobile pain—and how to fix them.
Mistake 1 — Blocking app startup on a flag fetch
Cold starts become unpredictable, and your app feels slow on bad networks.
- Fix: load from disk synchronously, refresh asynchronously with a timeout.
- Fix: design safe defaults so the app works even without a fetch.
Mistake 2 — No version guardrails
You enable a feature for users who don’t have the code path yet (mixed versions in the wild).
- Fix: include app version gating in rules (min supported build per feature).
- Fix: keep a compatibility flag during migrations (old API vs new API).
Mistake 3 — Treating kill switches like release toggles
During incidents, nobody knows what to flip, and the “rollback” isn’t actually instant.
- Fix: make kill switches separate, rare, and fail-closed by default.
- Fix: rehearse the rollback drill and verify the fallback UI.
Mistake 4 — Flagging security decisions client-side
A modified client can bypass the flag. Also, cached flags can “stick” longer than you expect.
- Fix: enforce sensitive permissions and destructive actions on the server.
- Fix: treat client flags as UX gates, not security controls.
Mistake 5 — Creating “flag spaghetti” (nested gates everywhere)
Code becomes hard to read and impossible to test; deletion becomes scary.
- Fix: centralize decisions in a small feature-gates layer.
- Fix: prefer one top-level gate per flow over many tiny UI gates.
Mistake 6 — Never deleting flags (flag debt)
Six months later you don’t know why a flow is different for different users.
- Fix: require an owner + expiry date for every temporary flag.
- Fix: add a monthly “flag cleanup” ticket to your engineering hygiene.
If your app renders a screen, then a refresh arrives and flips the flag, users can see UI elements appear/disappear. Avoid this by using cached values for the duration of a session (or by designing the UI so changes are non-jarring).
FAQ
Do feature flags on mobile work offline?
Yes—if you design for it. Mobile feature flags should use offline-safe defaults and a cached last-known-good config. When the network is unavailable, the app should continue using cached values (within a TTL) or fall back to safe defaults.
Will feature flags slow down my app startup?
They shouldn’t. The key is to make flag reads synchronous from local storage and refresh asynchronously. If you’re waiting on a network call before rendering, you’ve turned flags into a startup dependency—avoid that unless it’s a deliberate architecture choice.
How do I keep iOS and Android flag behavior consistent?
Use a shared contract. Define flag keys, types, defaults, and targeting rules in one catalog, and implement typed accessors on both platforms. Also add app-version guardrails so you never enable a feature on a build that lacks the code.
Are feature flags secure?
They’re not a security boundary. A client can be modified, and cached configs can persist. Use feature flags to gate UX and behavior, but enforce permissions and sensitive operations server-side.
What’s the difference between app store staged rollout and feature flags?
Staged rollout controls distribution of the binary; flags control behavior inside the binary. In practice you use both: staged rollout reduces exposure to a risky build, and flags let you turn specific features on/off without waiting for another release.
How should I test flagged code paths?
Test both sides of the gate. Add unit tests for feature-gate decisions, include UI tests for “on” and “off” states, and provide a safe debug-only override mechanism for QA. Make sure your CI runs at least one suite with the flag enabled.
When should I keep a flag permanently?
Only for true kill switches or long-lived configuration parameters. Release toggles and experiments should expire and be deleted. Keeping everything permanently is how you accumulate flag debt and unpredictable behavior.
Cheatsheet
A scan-fast checklist for building and running feature flags on mobile without surprises. Use this as a pre-launch and post-launch routine.
Design
- Flag has a clear type (release / kill switch / experiment / config)
- Owner is assigned + expiry date is set (for temporary flags)
- Default value is safe offline (document why)
- Rule includes app version guardrails
- Targeting is deterministic (stable bucketing)
Client implementation
- Reads are synchronous (from cache)
- Refresh is async with timeout and TTL
- Config is validated (types, ranges) before use
- Fallback path exists (UI and/or server-side)
- Debug override exists (dev builds only)
Rollout
- Start at 1% with monitoring
- Ramp in steps with pauses
- Keep a holdback cohort for comparison
- Have an incident plan (who flips what)
- Verify rollback doesn’t require a new build
Observability + hygiene
- Exposure logged once per session/user (not spammy)
- Metrics compared for flagged vs unflagged users
- Flag status included in bug reports
- Monthly flag cleanup (remove dead toggles)
- After graduation, delete conditions and catalog entries
For high-risk launches, use a pair: a release toggle (gradual ramp) and a kill switch (emergency off). The release toggle gets deleted after graduation; the kill switch stays but is rarely used.
Wrap-up
Feature flags on mobile are a superpower when they’re treated as an engineering system, not a quick hack: cached reads, safe defaults, deterministic rollouts, and a clear lifecycle that ends with deletion. Done right, you ship code confidently, ramp features without drama, and roll back in minutes instead of waiting for store review.
What to do next
- Start a flag catalog today (keys, defaults, owners, expiry)
- Implement cache-first flags (read fast, refresh later)
- Add one kill switch for your riskiest surface and practice the drill
- Schedule a monthly cleanup so flags don’t become permanent clutter
If you’re building a full release pipeline, pair this with CI, testing strategy, and offline-first thinking (see the related posts below).
Quiz
Quick self-check (demo). This quiz is auto-generated for mobile / development / architecture.