A barcode scanner in a phone app looks like a callback: the camera sees a code, you look it up. In practice expo-camera reports the code on every frame it stays in view, many times a second, and some of those reads are corrupt decodes. Munchable charges one of five free monthly scans per product, so the callback decides whether somebody pays. That made the gate in front of the lookup worth getting right, and it has been rewritten three times.
Version one: a boolean that could not keep up
const [locked, setLocked] = useState(false);
Set it true on the first read, false after the lookup. The scanner fires a burst of frames faster than a state update re-arms the guard, so a stale locked === false closure lets several reads through. The replacement was a timestamp in a ref plus a three-second cooldown: a ref is read at call time, not at render time.
Version two: a check digit and a second frame
Two more problems showed up once the cooldown worked. Corrupt decodes were reaching the lookup, and some real decodes were being rejected because a UPC-E code was being validated with the EAN-8 sum, which refuses roughly four in ten perfectly good reads. So the gate gained GTIN check-digit validation with proper UPC-E expansion, and a rule that a code must come back on two separate frames before anything happens.
The second frame is not a counter:
/**
* A code seen once, waiting for a matching second read to confirm it.
*
* There is no read counter: "at least twice" is structural, because a first read
* of a code returns a `speculate` decision and stops there, so anything that
* reaches the time check in `decideScan` is already the second read or later. A
* counter here would only look like a gate that could be tightened, while
* actually letting three reads inside one 16 ms frame burst pass.
*/
export interface PendingScan { code: string; firstAt: number; }
Version three: a pure function with no imports
The gate now lives in a module that imports nothing, next to the check digit code, and the reason is stated in the file: the module that talks to the network reaches storage and the auth client through its imports, which the plain node --test runner cannot load. "The gate has already had two race conditions in it. It is not logic that should only be checkable by hand."
export function decideScan(state: ScanGateState, read: string, now: number): ScanDecision {
if (state.lookingUp) return IGNORE;
if (now - state.lastScanAt < SCAN_COOLDOWN_MS) return IGNORE;
// 1. Structural check. A decode whose check digit does not add up is a
// misread, not a product.
const code = normalizeBarcode(read);
if (!isValidGtin(code)) return IGNORE;
// 2. Stability check. A code with no live confirmation, a different code from
// the one being confirmed, or one whose confirmation has gone stale, all
// start a fresh confirmation rather than completing the old one.
const pending = state.pending;
if (pending === null || pending.code !== code || now - pending.firstAt > SCAN_CONFIRM_WINDOW_MS) {
return { action: 'speculate', pending: { code, firstAt: now } };
}
if (now - pending.firstAt < SCAN_CONFIRM_MS) return IGNORE;
return { action: 'confirm', code };
}
The order is the design. The cooldown and the lookup lock come first because they are free and reject the common case. The check digit comes before anything is remembered, so a corrupt decode cannot disturb a confirmation the user is halfway through. A test called "a frame burst inside the hold confirms nothing" feeds reads at 0, 8, 16 and 119 milliseconds and expects no confirmation.
What the hold is for, and why it came down to 120 ms
The hold was 300 ms. That was the single largest thing between the user and their answer, because the network round trip started only once the hold was over. The comment on the constant explains the reframing:
it is not a duration the 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 if 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.
The other change that made 120 safe is that the lookup no longer waits for it. A check-digit-valid first read fires a prefetch, so the round trip runs underneath the hold instead of after it:
if (decision.action === 'speculate') {
pendingScanRef.current = decision.pending;
// The head start. This is a plain cache fill: it cannot count a scan,
// cannot spend quota and cannot push a screen, and by the time the
// second frame confirms the code its answer is often already sitting in
// memory. It is not issued at all for someone with no quota left.
if (useProfile.getState().remainingScans() > 0) prefetchProduct(decision.pending.code);
return;
}
The prefetch and the confirmed lookup have to be the same promise, not two requests racing, or the user waits on the second one and the head start bought nothing. A small single-flight map does that, and it drops a settled run so a failed request is never remembered as the answer. It is capped at three in-flight speculative lookups, because a user panning across a shelf arms a different code every time the scanner catches one, and React Native's fetch shares a small connection pool: a dozen requests nobody asked for would be a dozen requests ahead of the one for the pack in the user's hand.
Two more things the gate had to learn
The camera keeps scanning under a modal. The scan screen stays mounted underneath the result, capture and paywall sheets, so a stray read could shove a new screen over whatever the user was reading. The camera's active prop and its scan callback are both tied to a focus flag that the navigation hooks flip, and the same flag exists as a ref for the async paths that navigate after an await.
A stale lookup must not count. Every re-arm bumps a generation counter. A lookup captures it at the start and does nothing on completion if it no longer matches, because otherwise a second scan could start during the first and both would count a scan and push a result screen. Two of five free scans, and two stacked modals, for one product.
The text arrives later than the frame on purpose
A confirmed scan of a product Munchable already has can now be over in well under 200 ms. The gold frame around the code is immediate; the words underneath wait 400 ms. Text that appears and disappears inside 200 ms is not information, it is a flicker.
You can try the scanner without installing anything: sign in and choose "Continue in browser", which opens the app with camera access in the browser. Hold a barcode steady and watch how quickly the result arrives compared with how long the code takes to be drawn. The server side of the same latency work is a separate post.