DEV Community

Zayd Mulani
Zayd Mulani

Posted on

I built a tool that finds shrinkflation in your own grocery orders, and it makes zero network requests

Everyone knows shrinkflation is real. Almost nobody can see their own numbers.

The tools I found fall into three buckets: crowdsourced lists of products that shrank, unit price badges that show today's price per ounce, and one finance tool aimed at analysts. I couldn't find anything that reads your order history and tells you which of the things you keep rebuying got smaller. (I could not search the Chrome Web Store directly, so I can't promise it doesn't exist. If you know one, tell me in the comments.)

So I built Shrinkwatch. Paste your grocery orders into the demo, or use the Chrome extension, and it flags repeat items that shrank and shows what the change cost you per year.

Numbers in this post come from the built-in sample data (8 flagged items, about 10% less food, about $148 a year). They are not real shopper data.

I built it with Claude Code, which is relevant to a few of the stories below.

Step 1: parsing sizes is the whole game

Retailers write sizes like 32 oz, 6 x 12 fl oz, 2 pk, 96 count, 1/2 gallon, 500g. Before you can compare anything you need a parser that turns all of that into a base unit plus a dimension: mass, volume, or count.

The dimension matters because a "count" going from 12 to 10 is shrinkflation, but you never want to compare a mass to a volume. The parser returns something like this (simplified sketch, not the exact repo code):

type Size = { value: number; dimension: "mass" | "volume" | "count" };

// "6 x 12 fl oz" -> { value: 72 * 29.5735, dimension: "volume" }  (in ml)
// "2 pk"         -> { value: 2, dimension: "count" }
Enter fullscreen mode Exit fullscreen mode

Multipacks (6 x 12 fl oz) are where most bugs live, so that is where most of the tests live.

Step 2: deciding two purchases are the same item

If the retailer gives you a product ID (an ASIN, a Walmart item id, an Instacart product id), use it. When it doesn't, you fall back to the normalized name with the size tokens stripped, plus fuzzy token matching.

The trap is variants. Cereal sold in 12 oz and 18 oz boxes at the same time is not shrinkflation, it is two products. So the matcher never merges different sizes that were available at the same time. It only treats a smaller size as a shrink when it replaces the earlier one in the person's own purchase sequence.

Step 3: the detector

For each matched item, sort purchases by date and flag when the size dropped by 2 percent or more. Then compute:

  • old and new price per unit
  • the hidden price increase (what you pay per unit now versus before)
  • a confidence level: high when the product ID matches, medium when the match was fuzzy
  • a "stealth" label when the sticker price didn't move at all

From purchase frequency it estimates the extra dollars per year, and sums everything into one headline number. Users can dismiss a flag as "not the same product", and that choice persists.

"Nothing leaves your browser" is a claim, so I made it a test

Privacy copy is cheap. I wanted the claim to break the build if it stopped being true.

Three layers:

  1. A test that fails the build if fetch, XMLHttpRequest, sendBeacon, or WebSocket appear anywhere in the extension source.
  2. A strict Content Security Policy on the demo site.
  3. A headless test that deliberately tries a fetch to another host and asserts the CSP blocks it. On the live site I also checked that the only host the page talks to is its own.

The extension also asks for host access only to the retailers it supports, and stores everything in chrome.storage or IndexedDB.

Testing an extension against sites I can't log into

This was the awkward part. I can't put my real accounts into an automated build, so the retailer adapters (Instacart, Walmart, Amazon, Target, Kroger) were developed against realistic fixture pages I wrote myself.

To keep that honest:

  • The adapters don't rely on brittle class names. They use generic heuristics (price patterns, product links, title text) with small per-site hints.
  • A "selector health" view shows exactly what the extension captured on a page, so breakage is visible instead of silent.
  • Pasting order text works as a fallback and uses the same core code.
  • The README says plainly that adapters are best effort until verified on live pages. I'm saying it here too.

For the extension itself I used Playwright to load the built extension in headless Chromium in its own profile and click through capture, dismiss, paste import, and the popup. One of my first bugs was my own: a "block all requests" route handler was also aborting the extension's own files. Routing only http and https fixed it.

The Lighthouse rabbit hole

The demo is a static page, so I figured performance would be easy. Mobile scored 80 at first. Here is what actually moved it:

  • Prerender the hero. The big headline number was rendered by JavaScript, which made it a late largest paint. It is now in the HTML at build time.
  • Memoize name tokenization. Profiling showed the name tokenizer dominating. The same product names repeat hundreds of times in the sample orders (723 in my profile), so caching it removed a lot of work with no logic change.
  • Stop using Intl for money. The first call to Intl.NumberFormat was slow enough to show up as a long task on a throttled phone. Plain formatting did the job.
  • Load the script with async, not defer. With defer, the script ran inside the same task as the first layout, so the profiler counted the layout as blocking time.
  • Fonts. Cumulative layout shift came from web fonts swapping in. font-display: optional fixed the shift but meant some first-time visitors never saw the real fonts, so I ended on swap with local fallback fonts sized to match the web font's width using size-adjust. I also subset the hero fonts, cutting font weight from roughly 280KB to roughly 97KB.
  • content-visibility: auto on below-the-fold sections, and rendering the long receipt only when it nears the viewport.

On the live site, mobile ended at 97 performance and 98 accessibility, desktop at 100, with accessibility and best practices at 100. One lesson: local Lighthouse numbers on my machine were noisy enough that I stopped trusting them and measured on the deployed page instead.

Not looking like every other AI-built site

My first version worked, and looked exactly like what it was. So I had the design redone against a written brief: warm paper background, ink-black text, one accent color reserved for money and size lost, no gradients, and a pack drawing that shows the old size next to the new one. Fonts are self-hosted (Newsreader, Libre Franklin, IBM Plex Mono, all OFL), which also keeps the zero-third-party-requests promise true.

What I'd like from you

  • Try the demo with your own orders and tell me what it misses. If you use the extension, the selector health view output is the most useful thing you can send me.
  • If an adapter breaks on your account, open an issue. Adding a retailer is documented in the README.
  • If you know a tool that already does this, I'd genuinely like to see it.

What's the worst shrinkflation you've caught?

Top comments (0)