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.
Prerequisites
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
}
}
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);
});
Gotchas & Edge Cases
- Device clocks lie. Staleness computed from device time breaks if the user’s clock is wrong — and a surprising number are, whether from a dead battery resetting to epoch, a manual timezone fiddle, or a game that asks players to advance the clock. Where possible, record the server’s response time (from a
Dateheader or the payload itself) and compute age relative to it, or at minimum treat a negative age as immediately stale so a clock set to the future cannot make a month-old cache look freshly fetched. - Stale should have a hard ceiling. Serving a stale-but-flagged value is fine for a while; serving a value fetched months ago is not. Past a hard maximum — a second, larger bound above your normal staleness threshold — fall back to bundled defaults rather than trusting an ancient cache. The two-tier model matters because the soft bound only downgrades confidence, while the hard ceiling actually refuses the data; without the ceiling, a device that never regains connectivity would serve a value indefinitely, long after the flag it represents was retired server-side.
- User-specific flags must clear on logout. A cache keyed only by app, not by user, will hand the previous account’s variants to the next sign-in — a paid-tier user’s features leaking to a free account, or one person’s experiment bucket bleeding into another’s. Key the cache by a stable user identifier and clear it on logout, mirroring the session-scoping in securely passing flags to the browser. Clearing on logout alone is not enough if the app can crash before logout completes, so scope the read by the current identity too and ignore an entry whose stored user id does not match.
- Storage can be evicted underneath you. The OS may reclaim app storage under memory or disk pressure, and some stores silently drop values rather than error. Treat a missing entry as an expected state, not an anomaly — it is functionally identical to a cold install, and your bundled-defaults floor already covers it. Never assume a value you wrote an hour ago is still present.
- Concurrent writers can race. A foreground refresh and a background-fetch task can both try to persist within the same second, and the last
setwins. Because each write stages a complete, self-consistent entry, an interleaving still leaves a valid cache — but if you also track a separate “last refreshed” marker, update it inside the same atomic swap so it can never disagree with the values it describes.
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.