Munchable scans a food label and tells you whether it fits a digestive condition you have. The conditions you pick never leave your phone, and the verdict is computed on the device, so the lookup our server answers is deliberately condition-blind: it is handed a barcode and nothing about who is asking or why.
That is the right design for health data and it has a consequence nobody warns you about. It means we have almost no telemetry. No funnel, no cohorts, no "users who scanned X also scanned Y". And we still have to answer one question every week, because the whole product depends on it: which products should we improve next?
The question curation actually asks
Our catalog is bigger than our curation capacity, and it always will be. Rows differ in how well we understand them: some have ingredient text we can map onto every ingredient in our taxonomy, some contain a word the engine has never seen, and an ingredient the engine cannot name is an ingredient it cannot score.
Working through that backlog alphabetically would be absurd. The backlog has to be ordered by how likely a real person is to point a camera at the row tomorrow.
The honest version of that signal is what people scan. The tempting way to collect it is an event per scan with a user id and a timestamp on it, which is exactly the thing the architecture exists to avoid holding.
A barcode and a number, per calendar month
What we keep instead is one sorted set per calendar month in Redis. The member is a barcode, the score is how many times it was looked up, and that is the entire record:
scans:m:2026-09 -> { "50184453": 412, "7622210449283": 311, ... }
No account identifier. No device identifier. No IP address. No timestamp finer than the month in the key. Two months are kept, and the key expires on its own.
The property that matters is not that we promise not to look. It is that the information required to reverse this into anybody's history was never written down. Point a subpoena at it and it answers "this barcode was looked up 412 times in September", for everyone on earth, together.
The counting itself is nine lines:
export async function countScan(barcode: string): Promise<void> {
if (!redis || !barcode) return;
try {
const key = monthKey();
const n = await redis.zincrby(key, 1, barcode);
if (n === 1) await redis.expire(key, KEY_TTL_S);
} catch {
// Counting is advisory. A scan that could not be counted is still a scan.
}
}
Two details in there are load bearing.
ZINCRBY returns the member's new score, so n === 1 means this is the first lookup of this barcode this month. That is the cheapest possible test for "the key may be new", and it keeps the expiry call off the hot path for every other scan without an extra round trip to ask whether a TTL exists.
The catch swallows everything. Counting is advisory, and a person standing in an aisle must never wait on it or be failed by it. The lookup route makes that structural rather than hopeful by scheduling it after the response has already gone out:
after(() => countScan(barcode));
What reads it
Two operator-run jobs read these keys: the one that mines ingredient words the engine cannot name, and the one that reviews rule coverage. Both start the same way.
const liveWeights = await run.step('scan-weights', () => readScanWeights());
readScanWeights sums this month and last into a plain Map<string, number>, highest scores first, and the tail past a cap is dropped because it is noise for ranking. The map is then passed into a read-only aggregation that groups ingredient tags across products and weights each group by seed popularity plus those live lookups. The output is a ranked list of words we do not understand yet, ordered by how much it would matter if we did.
The fallback is the part I like. If Redis is unavailable, readScanWeights returns an empty map and curation ranks by the seed popularity numbers alone, which are frozen at import time. The job does not fail, it just gets duller: it goes back to guessing from a snapshot of the past instead of knowing about this month. Degrading to a worse ordering is a perfectly good outcome for a background job. Not running at all is not.
Two months is a deliberate window too. Curation runs often enough that older counts add little, and a counter that accumulates forever is both a growing key and a slowly worsening claim in a privacy notice.
The notice and the code have to say the same thing
There is a rule on this project that any claim the privacy policy makes must be a claim the code structurally enforces, not one a reviewer has to remember. The counter is described there in plain English, and you can go and read it:
We do count how often each barcode is looked up, because that is how we decide which products to improve next. That counter is a barcode and a number in a list for the calendar month, with no account identifier, no device identifier, no IP address and no timestamp finer than the month itself. It expires automatically after two months.
That paragraph is why the file's header comment opens with a hard rule rather than a description, and why the field list in the code is the same list as in the notice. If someone later adds a user id to make a dashboard nicer, they are not introducing a logging change. They are making a published document false.
Look at it yourself
- The paragraph above is live in the privacy notice at munchable.app/privacy, under the lookup section, next to the part about why the verdict is computed on the device. The same page lists Upstash as the processor that holds those monthly barcode counts.
- What the ordering produces is public. Every ingredient our engine can name has a page per condition, so munchable.app/answers is a readable index of the curation backlog seen from the finished end, and a single page like is inulin low FODMAP is one entry in it with the rule-set answer at the top.
- The per-condition view of the same data is at munchable.app/conditions, for example low FODMAP for IBS, which lists the ingredients that condition's rule set actually flags.
What I would tell anybody starting a privacy-first product: you will need some signal to prioritise work, and the useful shape of it is usually much smaller than the event stream you would have built by default. Ours is a barcode and a number. It fits in a sentence in a privacy policy, which is the test I now apply before adding any field to it.
Top comments (0)