Munchable is a gut health scanner: you point a phone at a barcode and it tells you whether the product fits the conditions you manage. The demo on munchable.app is the real flow, the viewfinder, the "Hold steady" line, the result card, so the easiest way to follow this post is to watch that happen a few times.
What follows is the part of it nobody sees: what the app does with one barcode read.
One scan is not one read
expo-camera calls onBarcodeScanned on every frame the code stays in view. A pack held still in front of the lens for two seconds is not one read, it is dozens. So the first thing the scan path needs is not speed, it is a decision about which of those reads counts.
The second thing it needs is distrust. A glare band across a curved label turns one digit into another, and the decode still arrives looking exactly like a real product code. The user gets "we don't have this one" for a product we do have, blames the catalogue, and the bug is invisible in any log we keep.
Munchable's answer is a gate with three constants and no opinions:
export const SCAN_COOLDOWN_MS = 3000; export const SCAN_CONFIRM_MS = 120; export const SCAN_CONFIRM_WINDOW_MS = 2500; The cooldown is "this code has been dealt with, stop handing it to me". The confirmation hold is how long the same code has to keep coming back before the app acts on it. The window is when a half-confirmed code is forgotten, because the user moved the phone to a different product and the next label should start clean.
The gate is a function, not a component
Reads arrive from the camera faster than React commits a state update, so the screen keeps its copy of the gate state in refs. That only works if none of the ordering lives in the component:
export type ScanDecision = | { action: 'ignore' } | { action: 'speculate'; pending: PendingScan } | { action: 'confirm'; code: string }; export function decideScan( state: ScanGateState, read: string, now: number, ): ScanDecision; State in, a read, a clock, a decision out. The order of the checks inside is the whole design. The cooldown and the "a lookup already owns the camera" lock come first because they are free and they reject the common case. The structural check comes next, so a corrupt decode cannot disturb a confirmation the user is halfway through. Only a decode that could be a real product is allowed to touch the pending slot.
There is no read counter in that state, and that is deliberate. "Seen at least twice" is structural: the first read of a code returns speculate and stops there, so anything that reaches the time check is already the second read or later. A counter would look like a knob you could tighten while actually letting three reads inside one 16ms frame burst satisfy it between them. One of the twelve tests on this function exists only to hold that line:
test('a frame burst inside the hold confirms nothing', () => { const state = { ...IDLE, pending: { code: CODE, firstAt: T0 } }; for (const at of [T0, T0 + 8, T0 + 16, T0 + SCAN_CONFIRM_MS - 1]) { assert.equal(decideScan(state, CODE, at).action, 'ignore', `at ${at}`); } }); 120ms, and why it used to be 300
The hold was 300ms, and 300ms was the single largest thing standing between a user and their answer, because the request only started once the hold was over. Two changes brought it down.
The first is that the lookup now starts on the first valid read and runs underneath the hold. The hold stopped being time added to the wait and became time spent inside it.
The second is being honest about what the hold is for. It is not a duration a decode has to survive. It is proof that the second read came off a different frame, and a frame is 16ms at 60fps, or 66ms when the scan callback is throttled to 15fps. 120ms clears the slowest of those with room to spare while sitting under the ~150ms mark where a delay starts to read as lag. What a shorter hold would let through has to be a misread that satisfies its own check digit and repeats itself identically on a second frame, which is a far narrower failure than the one the guard was added for.
The speculative lookup has to be the same promise
Starting a request early is easy. Making sure the user then waits on that request, rather than a second one racing it, is the part worth writing down. Both go through one map of in-flight promises keyed by the code:
export interface SingleFlight<T> { peek(key: string): Promise<T> | undefined; run(key: string, start: () => Promise<T>): Promise<T>; readonly size: number; } peek is what makes the prefetch cheap, and run is what makes the confirmed lookup join rather than duplicate. The prefetch is fire and forget, capped at three in flight, and the list of things it is not allowed to do is longer than the function:
- it does not count a scan, and does not read or spend quota
- it does not navigate
- it does not need the focus or generation guards, because it has no outcome to guard
Everything a user can notice still happens exactly once, in the confirm path, on a code that two frames agreed about. A prefetch that lands after somebody has walked away has filled a cache entry nobody reads, which costs nothing and dies with the process. The .catch(() => {}) on it is not decoration either: nothing awaits the promise, and every joiner of a rejected single-flight run is handed the rejection, so without it a dropped connection surfaces as an unhandled rejection warning.
The bug that only existed on small packs
Before any of this, a read has to be normalised, and that is where the most embarrassing defect in the scan path lived. Eight digits off a small pack can be an EAN-8 or a compressed UPC-E, and those two have different check-digit sums over different digits. Running the EAN-8 sum over a UPC-E rejects roughly four in ten perfectly good reads. To the user that is not an error message, it is a camera that appears to be dead: no haptic, no hint, no lookup.
An 8-digit read is now tested both ways, and a rewrite only happens when the literal reading is provably wrong first:
if (digits.length === 8 && !hasValidCheckDigit(digits)) { const upcA = expandUpcE(digits); if (upcA && hasValidCheckDigit(upcA)) return `0${upcA}`; } return digits; Rewriting a code the scanner actually read needs evidence, in that order.
Why it lives in a module that imports nothing
The gate and the check digit sit in one file that imports nothing at all. That is not tidiness, it is the reason there are tests: the API client reaches AsyncStorage and the auth client through its imports, and neither of those loads outside React Native. Logic parked there is logic that can only be checked by scanning a real pack with a real phone. This gate has already had two race conditions in it. Twenty tests run against it in milliseconds on a laptop, and none of them need a camera.
If you want to see what the gate is protecting, the FAQ on the homepage is the other half of the story: a lookup carries the barcode and nothing else, and the verdict is computed on the device. There is a fixed amount this code is allowed to know, which is a pleasant constraint to write a scanner under. The ingredient guides at munchable.app/answers and the conditions it checks against are at munchable.app/conditions, if you would rather read than scan.

