Offline-First Flag Caching on Mobile

This how-to is part of Mobile and Native Flag Delivery. Your app already reads flags from a cache, but you need the cache to be trustworthy: how stale is too stale, what happens when the stored schema changes across an app update, and how a half-written cache during a crash is handled. This how-to designs the persistence layer that makes offline flag resolution correct, not just present.

The distinction matters more on mobile than on the web. A browser tab that fails to resolve a flag reloads in seconds; a phone in a tunnel, on a plane, or in a dead-zone basement may run your app for hours with no successful fetch, and every one of those flag reads comes straight out of the cache. That means the cache is not a performance optimization you can treat casually — it is, for the offline window, your entire flag backend. If it can return a wrong-but-confident value, hand the previous user’s variants to the next sign-in, or fail to parse after a crash, those are not rare edge cases; they are the normal operating conditions of a real device. The four properties below — wrapped metadata, validated reads, atomic writes, and schema eviction — each close one of those failure modes so the offline path stays as trustworthy as the online one.

A trustworthy offline cache entry Each cache entry carries the flag values plus metadata: a fetched-at timestamp for staleness, a schema version for cross-update safety, and it is written atomically so a crash never leaves a partial cache. cache entry values flag → variant fetchedAt staleness bound schemaVersion update safety
Values alone are not enough: the timestamp bounds staleness, the schema version survives app updates, and atomic writes keep a crash from corrupting the entry.

Prerequisites

Cache prerequisites A device store, a staleness bound, a schema version, and bundled defaults. Device store MMKV / AsyncStorage Staleness bound max age Schema version payload shape Bundled defaults the floor
The staleness bound is what turns "we have a cached value" into "we have a value we still trust" — without it, a month-old cache looks identical to a fresh one.

Step-by-Step Procedure

Step 1 — Wrap values with metadata

Store the flag values alongside a fetch timestamp and schema version so every read can judge freshness and compatibility. The rule of thumb is that the cache entry must carry everything a read needs to make a trust decision without a network call — because by definition there may not be one. A bare Record<string, boolean> cannot answer “is this still fresh?” or “was this written by a build that agrees with mine?”, so those questions get answered wrong at exactly the moment you cannot afford it.

Keep the metadata fields as siblings of the values, not nested inside them, so a flag literally named fetchedAt can never collide with your bookkeeping. If you evaluate richer variants than booleans — strings, numbers, or JSON objects from a targeting engine — widen the values type accordingly, but keep the shape flat and serializable so a single JSON.stringify round-trips it without a custom encoder.

// cache-entry.ts
interface CacheEntry {
  schemaVersion: number;
  fetchedAt: number;                 // epoch ms
  values: Record<string, boolean>;
}
const CURRENT_SCHEMA = 3;

Step 2 — Validate schema and staleness on read

On hydrate, discard an entry whose schema version does not match the current app build, and mark values stale (but still usable) past the staleness bound so the UI can decide. The two checks are deliberately different in severity: a schema mismatch is a hard reject because the stored shape may not even parse into the fields your code expects, whereas staleness is a soft signal because the values are still valid — they are simply old. Collapsing both into “throw it away” would needlessly drop a perfectly usable, one-minute-past-the-bound cache and leave you on bundled defaults for no reason.

Wrap the JSON.parse in a try/catch as well. A truncated or corrupted string — from a filesystem error, a storage-quota eviction, or a botched write on a device you did not control — should be treated exactly like a missing entry, not thrown as an unhandled exception during your first render.

// read.ts
function loadCache(raw: string | null): { values: Record<string, boolean>; stale: boolean } {
  if (!raw) return { values: BUNDLED_DEFAULTS, stale: true };
  const e: CacheEntry = JSON.parse(raw);
  if (e.schemaVersion !== CURRENT_SCHEMA) return { values: BUNDLED_DEFAULTS, stale: true }; // incompatible
  const stale = Date.now() - e.fetchedAt > MAX_STALENESS_MS;
  return { values: e.values, stale };   // stale values are still served, just flagged
}

Step 3 — Write atomically

Write to a temporary key then swap, so a crash mid-write never leaves a half-parsed cache that fails to load on the next launch. The failure this guards against is subtle: key-value stores like AsyncStorage do not guarantee that a single set of a large string is atomic at the byte level, so a process kill partway through can leave the primary key holding half a JSON document. By staging the full payload under flags.tmp first and only overwriting flags once the staged value exists, the worst a crash can do is leave an orphaned temp key — which the next write cleans up — while the last known-good flags entry stays intact and parseable.

On a store that offers a real transaction or a batched multi-set, prefer it, since it collapses the two writes into one durable operation. Where you only have single-key primitives, the write-then-swap pattern is the portable equivalent, and it costs one extra round-trip — a negligible price paid on a background refresh, not on the hot read path.

// write.ts — write-then-swap for atomicity
async function persistAtomic(entry: CacheEntry) {
  await store.set('flags.tmp', JSON.stringify(entry));
  await store.set('flags', await store.get('flags.tmp') as string);  // swap
  await store.remove('flags.tmp');
}

Step 4 — Evict on schema bump, never silently corrupt

When the app updates to a new cache schema, drop incompatible entries and fall back to bundled defaults until the first fresh fetch, rather than attempting a risky in-place migration. The temptation is to write a migration that reshapes the old payload into the new one, but that migration code has to be correct forever, runs on a device you cannot debug, and buys you almost nothing — the gap it covers is a single background fetch, typically seconds after launch. Evict-and-refetch trades that fragile, permanent code for a bounded, well-understood window on bundled defaults, which is exactly the floor you already ship for a brand-new install with no cache at all.

Bump CURRENT_SCHEMA whenever the payload shape changes in a way old code cannot read — a renamed field, a changed value type, a new required key. Treat it as independent of your app’s marketing version; two releases can share a schema, and a hotfix can bump it. The one discipline to hold is monotonic increase, so a downgrade or a side-loaded older build sees a higher stored version, mismatches, and safely reverts to defaults rather than misreading a newer entry.

// migrate.ts
async function reconcileSchema() {
  const raw = await store.get('flags');
  if (raw && JSON.parse(raw).schemaVersion !== CURRENT_SCHEMA) {
    await store.remove('flags');    // clean slate; defaults cover the gap until refresh
  }
}
The four caching steps Wrap values with metadata, validate schema and staleness on read, write atomically, and evict on schema bump. 1 Wrap metadata ts + version 2 Validate schema + stale 3 Atomic write write-then-swap 4 Evict on schema bump
Evict-and-refetch (step 4) beats in-place migration — a clean slate covered by bundled defaults is always safe, a botched migration is not.

Verification

Assert the three failure modes: a stale entry is served but flagged, a mismatched schema falls back to defaults, and an interrupted write does not corrupt the next load. These three tests are worth more than any amount of manual QA, because the states they cover are precisely the ones that are hard to reproduce by hand — you cannot reliably kill the process mid-write on a simulator, and you would have to wait real calendar time to age an entry past its bound. Encoding them as unit tests over loadCache and persistAtomic lets you exercise every branch deterministically, in milliseconds, on every commit.

Add a fourth case for the corrupted-payload path: feed loadCache a truncated string and assert it returns bundled defaults rather than throwing. That single test is what proves your try/catch actually protects the first render, and it is the one most teams forget until a production crash report points them straight at JSON.parse.

// cache.test.ts
test('stale entry is usable but flagged', () => {
  const e = entry({ fetchedAt: Date.now() - 2 * MAX_STALENESS_MS });
  const r = loadCache(JSON.stringify(e));
  expect(r.stale).toBe(true); expect(r.values).toEqual(e.values);
});
test('schema mismatch falls back to defaults', () => {
  expect(loadCache(JSON.stringify(entry({ schemaVersion: 1 }))).values).toEqual(BUNDLED_DEFAULTS);
});
Three cache states resolved correctly A stale entry is served but flagged stale, a schema mismatch falls back to bundled defaults, and an interrupted write leaves the previous good entry intact. stale served + flagged schema mismatch falls to defaults crashed write prior entry intact
Each state has a defined, safe outcome — the cache never returns a wrong-but-confident value or fails to load.

Gotchas & Edge Cases

Three offline-cache edge cases Clock skew breaking staleness, unbounded stale service with no ceiling, and cached user-specific flags leaking across a logout. Clock skew device time wrong use server age too Forever stale no hard ceiling fall to defaults User leak clear on logout key by user
The user-leak case is a correctness and privacy bug — a cache keyed globally can show the previous account's variants to the next person who signs in.

Troubleshooting & FAQ

How long should the staleness bound be?

Match it to how fast your flags actually change and how bad a stale value is. A cosmetic toggle can tolerate days; a flag tied to a live promotion or a kill switch should be minutes and ideally refreshed aggressively when the app is active. Set it per flag class rather than one global number.

Should a stale value be served or should I show a loading state?

Serve the stale value. On mobile, a usable stale experience beats a spinner waiting on a network that may not be there. Flag it internally so the UI can show a subtle “updating” affordance if appropriate, but never block the screen on a refresh.

MMKV vs AsyncStorage for the cache — does it matter?

For correctness, no — both persist the entry. MMKV is synchronous and faster, which simplifies hydration (no promise to await before first render) and helps on large payloads. AsyncStorage is async and more widely bundled. Choose on performance and dependency preference; the caching logic here is identical either way.

Should I encrypt the cached flag values?

Encrypt them if the variants themselves are sensitive — a flag that reveals an unreleased feature name, a pricing tier, or a user’s segment can be read by anyone who pulls the app’s storage off a rooted or jailbroken device. For ordinary boolean toggles the exposure is low, but the cost is also low: MMKV supports an encryption key at construction, and both platforms offer a keystore-backed secret you can derive one from. Never store the key alongside the cache, and remember that encryption protects confidentiality, not integrity — you still need the schema and staleness checks to decide whether a decrypted entry is trustworthy.

What should the cache return on the very first launch before any fetch succeeds?

Bundled defaults, always. Ship the same default map you compile into the binary as the floor beneath an empty or missing cache, so a brand-new install with no connectivity still renders a coherent UI. This is why the read path returns BUNDLED_DEFAULTS with stale: true rather than throwing — the first launch and a wiped cache are the same state, and both must resolve every flag your code reads without waiting on a network.

How big can the flag cache get, and should I worry about size?

For most apps it is trivially small — a few hundred boolean or short-string variants serialize to a handful of kilobytes, well within any store’s limits. It becomes a concern when you cache large JSON-object variants or per-context evaluations for many users; a payload in the hundreds of kilobytes slows synchronous hydration and pushes AsyncStorage toward its platform size caps. If you get there, cache only the flags the app actually reads rather than the entire catalog, and consider MMKV, which handles large blobs far better than AsyncStorage.

Should the cache be shared across app extensions or widgets?

Only through a store that is explicitly shared, such as an App Group container on iOS. If a widget or share extension reads flags, pointing it at the same underlying cache avoids each process refetching independently and keeps them visually consistent. Be careful with the atomic-write pattern here: two processes writing to a shared store can race harder than two tasks in one process, so lean on a store with real cross-process transaction support and treat the extension as a reader wherever you can.