Mobile and Native Flag Delivery
This guide is part of the Frontend Integration & Client-Side Rendering series. A mobile app is not a browser tab: it launches offline on a subway, runs on a battery, and cannot ship a fix in minutes when the store review takes days. Delivering feature flags to native and mobile clients means designing for intermittent connectivity, a strict energy budget, and a release cadence you do not control — which makes flags more valuable and their delivery more demanding than on the web.
Problem Framing
Applying web flag patterns directly to mobile fails in specific ways. A polling loop that is fine in a browser tab drains a phone battery and burns metered data. A flag read that assumes network availability returns defaults on every subway launch. And the web’s ultimate safety net — ship a fix in minutes — does not exist when a native binary waits days for review, which paradoxically makes remotely-controlled flags the only fast lever you have for a native regression. Mobile flag delivery is the discipline of getting flag control without paying an unacceptable battery, data, or reliability cost.
There is a second-order failure that catches teams later. Because a native binary lingers in the wild for months — some users never update — you cannot assume every device runs the flag schema you deployed last week. A meaningful fraction of your fleet is always running an older app version whose bundled defaults and known flag keys are frozen at build time. A flag you added yesterday simply does not exist for a user on a six-month-old build, and a value type you changed will be misread by clients compiled against the old shape. This is why the mobile model treats the app version as a first-class targeting dimension and why a flag’s default must stay safe for every binary still in circulation, not just the current one. The web lets you assume one deployed version; mobile forces you to design for a wide spread of older versions you can no longer change.
The reliability bar is also higher because the blast radius is a crash, not a re-render. On the web a bad flag paints a broken component and the user reloads; on mobile a flag that drives a native code path can wedge the app into a launch loop that the user cannot escape without deleting and reinstalling — and if the flag that caused it is read before your refresh code runs, you have no remote way back. That asymmetry is the reason every read below resolves from a local cache first and treats the network purely as an eventual-consistency update, never as a precondition for the app starting.
This guide covers the delivery model for native and mobile clients: offline-first caching, lifecycle-aware refresh, and resource budgets. It does not re-cover the web-specific hydration and SSR concerns in preventing UI flicker and SSR flag consistency, which do not apply to native apps. Here the environment is the device, not the render pipeline.
Prerequisites
Core Concept & Architecture
The model is a three-tier fallback baked into the client: evaluate against the in-memory cache, which is hydrated at launch from device storage, which was last populated by a network refresh, and which falls back to the binary-bundled defaults if nothing has ever been fetched. Every read resolves instantly from memory; the network only ever updates the cache in the background, never blocks a read. Refreshes are triggered by app lifecycle — a foreground event after enough time has passed — rather than a timer, so a backgrounded app costs nothing.
// flag-store.ts — three-tier resolution, network never on the read path
class MobileFlagStore {
private memory: Record<string, boolean> = BUNDLED_DEFAULTS; // tier 3, always present
async hydrate() {
const persisted = await deviceStorage.get('flags'); // tier 2
if (persisted) this.memory = { ...this.memory, ...persisted };
}
get(key: string): boolean {
return this.memory[key] ?? BUNDLED_DEFAULTS[key] ?? false; // tier 1: instant, offline-safe
}
async refreshIfDue(reason: 'foreground' | 'manual') {
if (!shouldRefresh(reason)) return; // budget gate
const fresh = await fetchFlags(); // background only
this.memory = { ...this.memory, ...fresh };
await deviceStorage.set('flags', fresh); // persist for next cold start
}
}
The critical property is that get() never awaits the network. A subway launch reads persisted flags; a first-ever launch reads bundled defaults; a foregrounded app quietly refreshes when its budget allows. The network is an update mechanism, never a dependency of a read.
A subtle but important design choice hides in the merge order. Notice that hydrate() spreads persisted values over the bundled defaults, and refreshIfDue() spreads fresh values over the current memory — so a key that exists in the binary but is absent from a partial network response keeps its bundled value rather than vanishing. That ordering is deliberate: it means a truncated or partial refresh degrades gracefully to a superset of known-good keys instead of dropping flags the server happened not to send. If you invert the merge and let the network payload replace the whole map, a backend that omits a key — because of a serialization bug or a scoped response — silently reverts that flag to its type’s zero value, which for a boolean is false and can turn a feature off for your entire active fleet in one refresh. Keep the defaults as the floor and always merge onto them.
Where this model differs most from a browser SDK is the treatment of staleness. On the web you can afford to block a first paint on a fresh evaluation because the network is milliseconds away; on mobile you deliberately serve stale flags and accept a bounded lag — often minutes to a foreground, sometimes a full session — in exchange for an instant, offline-capable read. Make that staleness explicit rather than accidental: stamp each persisted set with the fetch timestamp, and if a decision genuinely cannot tolerate stale data — a legal kill-switch, say — gate that one flag behind a blocking fetch with a tight timeout and a safe fallback, rather than forcing every read to pay the latency of the slowest one.
Step-by-Step Implementation
Step 1 — Ship bundled defaults in the binary
Embed a default flag set in the app so the first launch, before any fetch, renders a coherent experience. These defaults are also the ultimate offline fallback.
// bundled-defaults.ts — compiled into the app binary
export const BUNDLED_DEFAULTS: Record<string, boolean> = {
'app.home.new-feed': false,
'app.checkout.apple-pay': true,
'app.onboarding.v2': false,
};
Pitfall: shipping no defaults means the first launch reads undefined for every flag and the UI flickers or crashes. Always bundle a coherent default set.
Choose each default for the worst case, not the happy path: the value that is safe to serve when you have no other information and no way to correct it remotely for hours. That usually means bundling the conservative, already-shipped experience — the new feed off, the experimental checkout off — so a device that never phones home behaves like the last stable release rather than an unfinished one. Treat the bundled set as a snapshot of the build’s known reality and regenerate it at build time from the same source of truth your backend uses, so the keys and value types in the binary cannot drift out of sync with the server that will later override them. A key present on the server but missing from an old binary is invisible to that binary; a key whose type changed from boolean to string will be coerced or crash on read, so version your schema and never repurpose a live key’s type.
Step 2 — Hydrate from device storage at launch
On cold start, load the last persisted flag set into memory before the first screen renders, so a returning user sees their last-known-good configuration instantly, online or off.
// launch.ts
await flagStore.hydrate(); // device storage -> memory, before first render
renderApp(); // reads resolve instantly from the hydrated cache
Pitfall: rendering before hydration completes shows bundled defaults for one frame, then swaps to persisted values — a visible flicker. Await hydration; it is a fast local read, not a network call.
Keep the hydration read genuinely cheap, because it sits on the cold-start critical path where every millisecond is measured against your app’s launch-time budget. A single serialized blob in a fast key-value store — UserDefaults/SharedPreferences for small sets, a single SQLite row or a flat file for larger ones — reads in well under a millisecond; a per-flag query or a JSON parse of a multi-megabyte document does not, and a slow hydrate quietly taxes every launch your users make. If the persisted set can grow large, store it pre-parsed or memory-map it so the first screen is not waiting on a decode. One more edge worth handling: if hydration throws — corrupted storage, a schema you can no longer deserialize — catch it, fall back to bundled defaults, and clear the bad blob, rather than letting a storage error abort the launch. A flag cache should never be able to prevent the app from starting.
Step 3 — Refresh on lifecycle, gated by a budget
Trigger a background refresh when the app foregrounds and enough time has elapsed, and skip it entirely on a metered connection or low battery. See reducing flag SDK battery and network cost.
// lifecycle.ts
app.on('foreground', async () => {
if (Date.now() - lastRefresh < MIN_INTERVAL) return; // not due yet
if (network.isMetered && !isCritical()) return; // respect data budget
if (battery.level < 0.15 && !battery.charging) return; // respect battery
await flagStore.refreshIfDue('foreground');
});
Pitfall: a fixed-interval timer refreshing in the background wakes the radio repeatedly and drains battery for flags nobody is looking at. Tie refresh to foreground activity instead.
The MIN_INTERVAL gate deserves a deliberate number rather than a guess. A ceiling of one refresh every few minutes per foreground is generous for feature flags — configuration that changes on a human operator’s timescale, not a real-time feed — and it caps the worst case where a user rapidly app-switches and foregrounds you a dozen times a minute. Pick the interval from how fast you actually need a rollout to reach an active user; for most teams “within a minute or two of opening the app” is more than enough, and pushing it tighter buys propagation speed you will never use at a real battery cost. Note also the ordering of the three guards in the example: the interval check runs first because it is free, then the metered and battery checks, so you never even read the battery API on a request you were going to skip anyway. The isCritical() escape hatch exists for the one case that overrides the budget — a security kill-switch you need to land even on a metered, low-battery device — and it should be reserved for exactly that, not used to smuggle ordinary flags past the gate.
Step 4 — Persist every successful refresh
Write each fetched flag set to device storage immediately so the next cold start — possibly offline — hydrates from the freshest values you have.
// already in refreshIfDue: persist after fetch
await deviceStorage.set('flags', fresh); // survives the next offline launch
Persist only after a fully successful fetch and parse, never a partial one, so a refresh that dies mid-flight leaves the previous known-good set intact rather than overwriting it with a truncated payload. If your storage layer can be interrupted by the process being killed — the OS reclaiming a backgrounded app is routine on mobile — write atomically: serialize to a temporary key and swap, or rely on your store’s transactional guarantee, so a half-written blob can never be what the next launch hydrates from. It is also worth persisting a small amount of metadata alongside the values: the fetch timestamp and the flag-set version or ETag you received. The timestamp lets you reason about staleness and drive the MIN_INTERVAL gate across cold starts, and the ETag lets the next refresh send If-None-Match so an unchanged flag set comes back as a 304 with an empty body — the single biggest data saving you can make on a fleet that refreshes far more often than the flags actually change.
Verification & Testing
Test the offline path explicitly: with the network disabled and no persisted state, the app must resolve every flag to its bundled default and render without error.
// mobile-flags.test.ts
test('cold launch offline resolves to bundled defaults', async () => {
network.disable(); deviceStorage.clear();
await flagStore.hydrate();
expect(flagStore.get('app.checkout.apple-pay')).toBe(true); // bundled default
expect(() => renderApp()).not.toThrow();
});
test('a persisted set survives an offline relaunch', async () => {
await flagStore.refreshIfDue('manual'); // online: fetch + persist
network.disable(); await flagStore.hydrate(); // relaunch offline
expect(flagStore.get('app.home.new-feed')).toBe(fetchedValue); // from storage, not default
});
Gotchas & edge cases
- Clock skew breaks time-based gates.
Date.now()on a device reflects the user’s clock, which can be wrong by hours or set deliberately into the future. If yourMIN_INTERVALand staleness logic trust the device clock alone, a skewed clock can either suppress every refresh or trigger one on every foreground. Prefer a monotonic timer (elapsed-since-boot) for interval gating, and trust the server’s timestamp for absolute staleness. - The keyboard extension and the watch app are separate processes. On both platforms, app extensions, widgets, and companion targets run in their own sandboxes and often cannot see the main app’s cache. Either share storage through an app group container or ship each target its own bundled defaults; assuming one shared in-memory store across processes is a common source of “the widget shows the old flag” bugs.
- A returning user can be behind by several schema versions. If someone opens the app after months, their persisted set predates flags you have since added and may hold keys you have since retired. Merge onto current bundled defaults so new keys resolve, and make retired keys inert rather than fatal — an unknown key in storage should be ignored, never fed to a code path that assumes it exists.
- Reinstalls and OS “offload” wipe the cache but keep the binary. An offloaded-then-restored app returns with its bundled defaults but no persisted set, landing back on the first-launch path. Your defaults, not your last sync, are what that user sees until the first successful refresh — another reason the bundled set must be a genuinely usable configuration.
- Background refresh is best-effort, not guaranteed. Even if you register an OS background task to refresh flags, the system decides whether and when to run it based on the user’s habits and battery state; a rarely-opened app may never get one. Never rely on background execution for a change that must land — design for the next foreground being your only reliable opportunity.
Troubleshooting & FAQ
Our flags never update for users who are usually offline. How do we reach them?
They update on the brief moments they do connect — as long as the refresh is tied to foreground activity, not a background timer the OS may suspend. Confirm the foreground refresh actually fires and persists. For genuinely rare connectivity, accept that flag changes propagate slowly and keep the bundled defaults sensible.
Can we push a flag change to a native app instantly like the web?
Not instantly, but far faster than a store release. A remotely-controlled flag reaches the app on its next foreground refresh — seconds to minutes for an active user — versus days for a binary update. That gap is exactly why remote flags are the primary fast-rollback lever for native apps.
Should mobile flags use polling or streaming?
Usually neither in the web sense. A persistent stream drains battery, and tight polling burns data. Lifecycle-triggered refresh — fetch once on foreground when due — fits the mobile energy budget better than either. See polling vs streaming for the trade-offs that shift on mobile.
How do we target a rollout when older app versions are stuck in the field?
Treat the app version as a targeting attribute the backend evaluates against, exactly like country or user segment. Include the build version in the evaluation context the client sends on refresh, and scope any flag that depends on new native code so it can only turn on for builds that actually contain that code. This keeps you from enabling a feature for a six-month-old binary that will crash trying to render it. The bundled defaults handle the users who never refresh at all; version-scoped targeting handles the ones who refresh but run old code.
Should flag evaluation happen on the device or on the server for mobile?
Prefer server-side evaluation with the resolved values cached on the device. Shipping the full targeting rule engine and every segment definition to the client bloats the binary, leaks your rollout logic, and means a rule change requires an app update — the exact bottleneck you are trying to escape. Evaluate on the backend, send the client only its resolved flag set for the context it presented, and let the device cache and serve those values offline. This mirrors the payload-minimization argument in securely passing flags to the browser.
What happens to a user mid-session when a flag flips on the server?
Nothing, until your app decides to act on it. A refresh updates the in-memory cache, but a screen already rendered against the old value does not repaint unless you subscribe it to changes — and on mobile you usually do not want a live view swapping features under a user’s fingers mid-task. The safe default is to apply refreshed flags at natural boundaries: the next screen, the next cold start, or an explicit “restart to apply” prompt for changes that touch navigation or data models. Reserve live in-session updates for flags where an instant flip is genuinely safe, such as a remote kill-switch disabling a broken feature.
How do we roll back a bad flag that is already crashing the app on launch?
Flip the flag to its safe value server-side immediately, then rely on the fact that most crash loops still let the refresh code run before the crashing path executes. The key is ordering: perform your flag hydrate and, where possible, a quick blocking refresh with a tight timeout before the code that the flag gates, so a device that connects even briefly picks up the corrected value and escapes the loop. This only works if the crashing feature was behind a remotely-controlled flag in the first place — which is the entire argument for gating risky native code paths behind flags rather than shipping them unconditionally.
Performance & Scale Considerations
The scarce resources on mobile are battery and data, not CPU, so the whole design minimizes radio wakeups rather than evaluation cost — reads are free in-memory lookups. Batch the entire flag set into a single refresh so a foreground event costs one request, not one per flag, and compress the payload since it travels over metered connections. The radio itself, not the bytes, is often the real cost: a cellular modem stays in a high-power state for several seconds after a single request finishes, so ten small requests spread across a session can cost far more energy than one batched request that lets the radio return to idle. That physics is why batching and coalescing matter more here than raw payload size, and why a chatty SDK that fetches each flag lazily is a battery anti-pattern even when each individual request is tiny.
At scale across millions of devices, a synchronized refresh — all clients fetching at the same wall-clock moment — can hammer your backend into a thundering herd, and mobile has two sneaky sources of synchronization: a push notification that wakes every device at once, and the morning commute when a whole timezone foregrounds your app within the same half hour. Spread refreshes across the foreground events that already arrive naturally staggered, add jitter to any time-based trigger, and never key a refresh off a broadcast that fans out to the entire fleet simultaneously. On the serving side, a 304 response to an unchanged flag set costs you almost nothing to return and saves the device its download, so an ETag-aware endpoint behind a CDN turns the common case — a fleet polling far more often than flags change — into cheap conditional requests. The distributed caching guide covers serving those refresh requests efficiently at the edge, and reducing flag SDK battery and network cost drills into the client-side budgeting.