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.

Constraints unique to mobile flag delivery Mobile flag delivery must handle intermittent connectivity with an offline cache, a battery and network budget that limits refresh frequency, and a slow store-review cycle that makes remote flags the only fast control. native app flag consumer Offline-first cache last-known-good launch without network Battery budget refresh on lifecycle not on a timer Store cadence flags are the only fast rollback
Three constraints shape every decision here: the app must work offline, sip battery and data, and give you a control faster than the app store's review queue.

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.

Why web flag patterns fail on mobile A browser polling loop drains battery and burns metered data on a phone, a network-assuming read returns defaults on an offline launch, and the web's minutes-to-ship safety net does not exist behind a days-long store review. Polling loop fine in a tab drains battery + data Network-assuming read subway launch returns defaults Ship-a-fix safety net days of review gone — flags are it
Each web assumption breaks on the device: always-connected, unmetered, and instantly patchable are all false — which is exactly why the mobile model looks different.

Prerequisites

Mobile delivery prerequisites Device-persistent storage, a bundled default set, access to lifecycle events, and platform battery and network signals. Device storage persist flags Bundled defaults first launch Lifecycle events time refreshes Battery/net signal budget refreshes
The bundled default set is what lets the very first launch — before any network — render the right experience instead of a blank screen.

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.

Three-tier offline-first resolution A flag read resolves from the in-memory cache, which is hydrated from device storage at launch, which is populated by background network refreshes, all backed by binary-bundled defaults that guarantee a value even on first launch. read get() instant memory cache tier 1 device storage tier 2 bundled tier 3 network (background) The read stops at tier 1; the lower tiers only ever fill it.
The read path is one hop — memory. Storage, network, and bundled defaults are the fill chain behind it, none of them ever on the critical path.

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.

The four implementation steps Ship bundled defaults, hydrate from device storage at launch, refresh on lifecycle gated by a budget, then persist every successful refresh. 1 Bundle defaults first launch 2 Hydrate from storage 3 Refresh lifecycle + budget 4 Persist for next launch
The loop is self-healing: each refresh persists, so even a device that is usually offline gradually converges to current flags whenever it briefly connects.

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
});
Two offline launch paths verified A first offline launch resolves to bundled defaults and renders; a subsequent offline launch resolves to the previously persisted values. first offline launch bundled defaults, renders later offline launch persisted values, not defaults
Both offline cases must pass: a fresh install works on defaults, and a returning user keeps their last-synced configuration with no network.

Gotchas & edge cases

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

Optimize radio wakeups, not evaluation On mobile the scarce resources are battery and data, so cost reduction targets fewer, batched, staggered network refreshes rather than the already-free in-memory reads. Reads in-memory lookup free Refreshes radio wakeup + data the real cost Fleet scale stagger + jitter no herd
Reads are already free; every optimization here is about making the network refresh rarer, smaller, and naturally spread across devices.

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.