The core loop of Munchable is one request. You point a phone at a barcode, and a product comes back, and the app decides whether that product suits your gut condition. So the shape of that one request is most of the app's architecture, and the most consequential thing about it is a call it does not make.
The chain, in full
Redis (24h TTL)
-> catalog.products (one row per barcode)
-> 404 { canContribute: true } + a 24h miss marker
That is all of it. There is no fourth step, no live third-party product API behind the database, no "if we miss, ask the internet". A barcode Munchable holds is answered from Munchable's own catalog, and a barcode it does not hold is a miss.
Two things follow from having no step four, and they are the reason for the decision rather than a side effect of it.
The first is that nothing leaves the building. The request path reaches no external service, so no scan of yours is disclosed to anyone else, not as a barcode and not as traffic timing. Privacy policies are easy to write and hard to mean; an architecture with no outbound call in the hot path means it whether or not anyone reads the policy.
The second is editorial. Every verdict the app shows is built on data we curate and can correct. We publish that plainly on munchable.app/licenses: Munchable builds and maintains its own product database. A live fallback would mean some verdicts rest on a row nobody here has ever looked at, and when one of those is wrong there is nothing to fix, only somebody else to blame.
The miss is the growth mechanism
A miss is not a dead end, which is the part that makes the decision survivable. The 404 carries canContribute: true and a request:
We do not have this one yet. Photograph the label to add it and earn points.
And when the catalog holds the product but not its ingredients, the same 404 carries the name, the brand and whatever nutrition is known, so the app can ask for one photo instead of a whole label:
We know this product but not its ingredients yet. Photograph the ingredients panel to add them and earn points.
So the gap closes by being hit. The products users actually scan are the products that get added, in the order they are reached for, which is a far better ranking than any import could guess. The contributor gets points toward a reward, the next person to scan that barcode gets an answer from Redis in milliseconds, and the catalog grew by exactly one row that somebody wanted.
A miss also leaves a marker behind (miss:<barcode>, 24 hours), so a barcode that is genuinely absent costs one database read per day rather than one per scan.
What the response does not contain
No verdict. No rule data. No thresholds.
The lookup is condition-blind: it answers "what is this product", never "is this product good for you". The second question is answered on the device by the rules engine, which is deterministic, dependency-free and offline-capable. That is a privacy boundary rather than a performance trick. The server returns a product, the phone holds your conditions, and the link between an account and a medical condition is never on the wire, so it is never in a log or a backup either. The app states it during onboarding, in the flow, before you have typed anything:
Your conditions, allergies and shopping preferences stay on your device. We never send them to our servers.
What the response does carry, on every single reply including the 404s, is an x-taxonomy-version header. The device's copy of our ingredient knowledge gets stale when curation lands, and the version is just the latest update timestamp of the curated data, so the app discovers it is behind as a byproduct of a scan it was making anyway, with no polling and no extra round trip.
What the server is allowed to remember
One number per barcode per calendar month.
Curation has to be ranked somehow: with a long tail of ingredients to score, the only sane order is "whatever people are actually scanning". That needs counting, and counting is exactly where a food app can quietly become a surveillance product, so the record is deliberately the weakest thing that still answers the question. A sorted set per month, scored by lookups. No user id, no device, no session, no timestamp finer than the month. Two months are kept and the keys expire on their own, and that count is what ranks the curation queue. There is nothing in it to join against, so it cannot be turned back into anybody's shopping history, and the per-user lookup is never persisted anywhere at all.
It is also written after the response goes out:
// Hit or miss, and never a user id. Scheduled with `after` rather than left
// as a dangling promise: on a warm cache hit the response leaves in
// milliseconds and a serverless instance can be frozen before the increment
// lands, which would under-count exactly the most popular barcodes, the
// inverse of what the live weight is meant to measure.
after(() => countScan(barcode));
That comment is the second bug, not the first. The first was counting in the request path at all, which made the app's core loop wait on a metric. Moving it to after() fixed the latency and introduced an under-count that only hit the hottest barcodes, because those are the ones served fast enough for the instance to freeze first.
The ordering that the whole thing hangs on
Four network-shaped things happen before this route can answer: authentication, two rate-limit checks, the taxonomy version, and the cache read. None of them needs another's output, so they all start together rather than in a queue. We wrote about that change on its own when we made it.
The part worth repeating here is not the concurrency, it is the discipline it demands. Starting the cache read before authentication means the route is holding product data for a request that might turn out to be unauthenticated. So every non-success path throws it away explicitly:
const user = await userPromise;
if (!user) {
cachePromise?.catch(() => {});
taxonomyHeadersPromise.catch(() => {});
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
A 401 must not carry product data, a 429 must not either, and a request that is both unauthenticated and malformed has to keep answering 401 rather than 400. Speculative work bought concurrency; it was not allowed to buy a change in which error wins. The .catch(() => {}) on an abandoned promise is not superstition either: both of those helpers currently swallow their own failures, but a promise started and then dropped needs a rejection handler regardless, or a future edit to either one becomes an unhandled rejection discovered nowhere near this file.
The honest cost
No fallback means the catalog is smaller than a federated one, and some scans end in "photograph this" rather than an answer. That is a real cost paid by a real user standing in a supermarket aisle.
What it buys is that every answer we do give is one we can stand behind and fix, no scan is disclosed to a third party, and the only thing the server learns is that some barcode somewhere got a little more popular this month.
See the engine without installing anything
The same rules engine that runs on the phone also renders our public ingredient pages, server side, for queries like is onion low FODMAP or does onion cause reflux. Those pages are not hand-written answers: they ask the engine and print what it says, which is also why they cannot drift away from the app. There are a few hundred of them, indexed at munchable.app/answers.
The app itself runs in a browser at app.munchable.app, and the whole onboarding, including the line quoted above, works before you create an account.
Top comments (0)