DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our scanner looks up a barcode before you have confirmed it, and the lookup may only change the latency

Munchable is a gut health scanner. You point the phone at a barcode, and it weighs the product's ingredients against the conditions you manage and gives you one verdict with the reasons under it.

The scan itself is instant. The lookup is not: somewhere between the barcode being readable and the verdict appearing there is a request to our server, and the user is standing in an aisle holding a phone still, waiting for it. This post is about removing that wait without letting the thing that removes it change any other outcome in the app.

You can watch the whole path run in a browser: app.munchable.app is the same Expo app served through react-native-web, and the onboarding runs with no account at all.

The scanner knows the barcode before the user has confirmed it

A camera scanner does not fire once. It fires on every frame it can decode, so one pack held in front of the lens produces a stream of identical reads. Our hub does not act on the first one: it waits for the code to hold still for a moment, because a single frame can be a misread, and a misread that navigates is worse than a scan that takes another 200ms.

That stability hold is dead time with the answer already knowable. So the first valid read starts a speculative lookup, and the confirmed scan a moment later waits on the same promise:

export function scanProduct(barcode: string): Promise<ResolvedProduct | null> {
  const code = normalizeBarcode(barcode);
  if (!code) return Promise.resolve(null);
  const hit = peekProduct(code);
  if (hit) return Promise.resolve(hit);
  return scanLookups.run(code, () => resolveForScan(code));
}
Enter fullscreen mode Exit fullscreen mode

scanLookups is a single-flight map keyed by barcode. Thirty frames of one pack produce one request, and the request the user ends up waiting on is the one that started while they were still steadying their hand.

The prefetch is allowed exactly one effect

The dangerous thing about a speculative call is not the call. It is everything a lookup function is tempted to do on the way past. Ours sits next to code that counts scans against the monthly free allowance, writes the recent list and navigates to the result screen, and if any of that leaked into the speculative path then panning a camera across a shelf would spend a user's allowance on products they never looked at.

So the boundary is written down, in the file, above all three functions:

// None of it counts a scan, spends quota or navigates. Those are decisions for
// the hub, made once, after a code is confirmed. Everything here only ever
// fills a cache.
Enter fullscreen mode Exit fullscreen mode

And the prefetch itself is deliberately boring:

export function prefetchProduct(barcode: string): void {
  const code = normalizeBarcode(barcode);
  if (!code) return;
  if (peekProduct(code)) return;
  if (scanLookups.peek(code)) return;
  if (scanLookups.size >= MAX_SPECULATIVE_LOOKUPS) return;
  void scanLookups.run(code, () => resolveForScan(code)).catch(() => {});
}
Enter fullscreen mode Exit fullscreen mode

Four guards and a fire-and-forget. A prefetch that lands after the user has walked away has filled a cache entry nobody reads, which costs nothing and is thrown out with the process.

The .catch is not decoration. Nothing awaits that promise, and every joiner of a rejected single-flight run is handed the rejection, so without it a dropped connection in a supermarket surfaces as an unhandled rejection warning.

Three concurrent speculations, and the reason is the phone

MAX_SPECULATIVE_LOOKUPS is 3, which looks like a server-protection number and is not:

The cap is not really about server load, which is one cheap keyed read per request; it is about the device. React Native's fetch shares a small connection pool, so a dozen requests nobody asked for are a dozen requests ahead of the one for the pack the user is actually holding, and the speed-up would turn into a slow-down.

Someone panning across a shelf arms a different code every time the scanner catches one. Unbounded speculation on that gesture is a self-inflicted queue, and the request at the back of it is the only one that matters.

The cache leg has two conditions, and one of them is about trust

Behind the single flight, the resolve chain is cheapest leg first: this session's memory, then a small AsyncStorage cache of resolved products, then the server. The persisted leg is the interesting one, because answering a scan from disk means answering it without asking whether the answer is still true.

const stored = await loadPersistedEntry(code);
if (
  stored &&
  stored.entry.status === undefined &&
  Date.now() - stored.at < SCAN_CACHE_MAX_AGE_MS
) {
  resolved.set(stored.entry.product.barcode, stored.entry);
  return stored.entry;
}
return lookupProduct(code);
Enter fullscreen mode Exit fullscreen mode

Two conditions, for two different failure modes.

The age bound is one hour, which caps how long one of our own corrections can go unseen at about an hour of use. A cache with no expiry is a promise you cannot withdraw.

The status === undefined test is the one worth copying. Products added by other people carry a trust status, and that status can be demoted between one scan and the next by someone else reporting the row. A device cache that served those would keep handing out a row we have since withheld, which is precisely the failure the withhold exists to prevent. A row with no status is our own curated data, which does not move.

The peek that refuses to remember a failure

One more guard, and it came out of a real dead end. When a label is read on the device but the server could not save it, the app keeps the result in session memory so the result screen can show it, tagged source: 'session'. The scan path's synchronous peek ignores exactly those entries:

export function peekProduct(barcode: string): ResolvedProduct | undefined {
  const hit = resolved.get(barcode) ?? resolved.get(normalizeBarcode(barcode));
  if (!hit || hit.source === 'session') return undefined;
  return hit;
}
Enter fullscreen mode Exit fullscreen mode

A device-only read is not Munchable having the product. The first version of this peek returned those entries, and rescanning a pack whose save had just failed landed on the stale device-only result instead of the "we do not have this one yet" flow that sends you to the camera. Caching a success is a performance decision. Caching a failure is a bug with a cache in front of it.

Try it

Open app.munchable.app, walk through the onboarding (region, conditions, allergies, shopping preferences, all of which stay on the device), then scan a barcode twice. The second one answers out of memory with no request at all. Our seven condition guides at munchable.app/conditions say what each rule set is actually weighing while you wait those few hundred milliseconds.

The rule underneath all of it: a cache may change when an answer arrives, and nothing else.

Top comments (0)