Munchable's mobile app carries a copy of curated ingredient knowledge on the device, because the answer has to arrive at the shelf whether or not the signal does. The server improves that data continuously, which creates the ordinary problem of every offline-first app: the device is holding something, the server has something better, and nobody has told the device.

The usual answers are a poll, a push, or a version check in front of every operation. We have a poll, and it almost never does anything useful, because the real mechanism is a response header riding along on requests that were about something else entirely.

Every API response passes through one Set

The app's API client is thin: attach the bearer token, parse the JSON, never throw. The one extra thing it does is let anyone watch responses go past.

type ResponseObserver = (res: Response) => void; const observers = new Set<ResponseObserver>(); /** * Observe every API response (headers only, before the body is consumed). * Used by the taxonomy store to notice an `x-taxonomy-version` newer than the * one loaded on device. Observers are registered by the stores; this module * never imports a store, so there is no import cycle. */ export function onApiResponse(fn: ResponseObserver): () => void { observers.add(fn); return () => observers.delete(fn); } 

The direction of that dependency is the whole reason the pattern is worth it. The client does not know what a store is. The store knows what the client is. If the client imported the store to call noteServerVersion directly, the store would import the client to fetch, and you have a cycle to break with a lazy require or a shrug.

The dispatch sits inside the fetch wrapper, before the body is read:

for (const fn of observers) { try { fn(res); } catch { // an observer must never break a request } } 

Headers only, and every observer is wrapped. A side channel that can turn a successful product lookup into a thrown error is not a feature, it is a liability with a nice name.

The subscriber is four lines

// Learn about newer ingredient knowledge from any API response, without the // API client importing this store (no import cycle). onApiResponse((res) => { const v = res.headers.get('x-taxonomy-version'); if (v) void useTaxonomy.getState().noteServerVersion(v); }); 

And on the server, four separate routes attach that header through one helper, which is careful about the one thing a stamp like this must never do:

/** * `x-taxonomy-version` for a response, so the app learns it is behind without * an extra call. Never blocks the caller: on any failure the header is omitted. */ export async function taxonomyVersionHeaders(version?: string): Promise<Record<string, string>> { try { const v = version ?? (await overlayVersion()); return { 'x-taxonomy-version': v }; } catch { return {}; } } 

An absent header means "no information", and no information is the state the app starts in anyway. A 500 on a product lookup because the version lookup failed would be trading the user's actual request for a background optimisation.

There is one extra line needed to make this work in the web build of the same app, and it is the kind of thing you discover by watching a feature work on a phone and do nothing in a browser:

const EXPOSED_HEADERS = 'Retry-After, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, ETag, x-taxonomy-version'; 

A cross-origin response has headers. JavaScript cannot see them unless Access-Control-Expose-Headers names them. The native app never cared, so the bug was invisible on the platform where it was developed.

Two timers, doing very little

const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; const FORCED_DEBOUNCE_MS = 30 * 1000; 

The six hour interval is the floor for the unprompted check, and it is deliberately lazy because it is the path that exists only for an app that is open and idle. The thirty second debounce guards the header path: a scan can fire several requests in a few seconds, all of them stamped with the same new version, and the store must refresh once rather than three times.

noteServerVersion: async (version) => { if (!isNewerVersion(version, getTaxonomyVersion())) return; if (inflight) return inflight; const now = Date.now(); if (version === lastForcedVersion && now - lastForcedAt < FORCED_DEBOUNCE_MS) return; ... return get().refresh({ force: true }); }, 

Four guards before any work happens, and three of them are about the same new version arriving more than once. The inflight check returning the existing promise rather than undefined is a small kindness to callers that do await it.

The version itself is an epoch-ms number in a string, and the comparison helper exists to say so:

/** Versions are epoch-ms decimal strings; compare numerically, never as text. */ export function versionNumber(v: string | null | undefined): number { const n = Number(v); return Number.isFinite(n) ? n : 0; } 

An unparseable version reads as zero, which means "older than anything", which means a corrupted header can never convince the app it is up to date.

What this buys

The practical effect is that the app's data goes stale in the window between a publish and the user's next request, rather than in the window between a publish and the next poll. The user scans something, that response carries the stamp, the refresh happens behind them, and the next scan is on the newer data. Nobody waits for a version check that is usually going to say "nothing changed".

The same curated data is what the website's static pages are built against, so if you want to see the current state of it without installing anything, the ingredient answer pages each resolve one ingredient against one condition, and the condition guides list what each one covers. The recipes are the same idea applied to a list of ingredients rather than a product.

The pattern generalises past this app: if your client already talks to your server regularly, "your cache is stale" is a header, not an endpoint.