Our screenshots changed every night, and the compressor was not the reason

Every template in the catalog gets its store images from a script. It builds the site, opens it in a headless browser, and writes one screenshot per page per viewport. The store reads those files directly, so they are product assets and they live in the repository.

For a while, running that script twice in a row produced 22 changed files the second time. Nothing in the templates had changed. The build was the same, the pages were the same, and git still reported a diff.

Why this is worse than noise

A dirty working tree after a build is easy to shrug at. The cost is not the noise, it is what the noise does to a question you need to be able to ask.

“Is the tree clean” is how you find out whether you are about to commit something you did not mean to commit. If the answer is always no, the question stops carrying information, and a real change becomes something you have to go looking for inside a pile of fake ones. That happened once here. A genuine edit sat unnoticed among 22 rewritten images for most of an evening.

The first diagnosis, which was wrong

The obvious explanation is that WebP encoding is not deterministic. Encoders make timing-dependent decisions, and two runs on the same input can produce different bytes.

We measured it. Decoding both versions of one image and comparing pixel values gave differences of 0.01 to 0.07 of a gray level. That is exactly what encoder noise looks like: present, tiny, meaningless. The conclusion followed easily, and it was wrong.

The measurement was real. The sample was one file.

On a fresh render of a different page, 27 percent of the bytes differed and the largest single pixel difference was 231 gray levels out of 255. Nothing in a compressor does that. A difference that size means the two images do not show the same thing.

What was actually happening

Two of the templates ship a small script that reads the visitor’s clock. One puts an open or closed badge in the header, the other rewrites today’s hours on the page. Both are correct behavior. A guest looking at a restaurant site at 19:00 should be told whether the kitchen is open, and that answer cannot come from a static build.

The screenshot pipeline was rendering those pages after the script had run. So the image contained whatever the clock said at the moment of capture, and two runs an hour apart captured two different pages.

There was a second problem hiding inside the first. A capture taken at three in the morning showed the café closed. That is accurate and useless: the cover image of a storefront listing should not show a business at its least appealing hour.

Three fixes, in order

A fixed clock. The browser context now has a pinned Date and a fixed time zone injected before any page script runs: Wednesday at 13:10. The day and hour are not arbitrary. They are the one window in which all three templates are open. An earlier attempt at 10:20 showed the restaurant closed.

No render when nothing changed. Each template gets a fingerprint covering its source and the capture script itself. If the fingerprint matches and the output files are still on disk, the browser never opens. The second condition matters as much as the first: a matching fingerprint with deleted files would skip the work and leave the store with broken images.

No write when the pixels match. Even when a page is re-rendered, the new image is compared against the file already there, pixel by pixel, and an identical result is not written. The comparison has to be on pixels rather than bytes, because byte comparison never matched. That was the original symptom.

Forced through a full re-render, all 202 images now come out pixel-identical and nothing is written. Unforced, all three templates are skipped and no image is produced. The build no longer touches the tree.

The part worth keeping

The fix is ordinary. The mistake in the middle is the useful part.

A number that came from a real measurement got treated as a fact about the whole system, and it was a fact about one pair of files. The sample size never got written down next to the conclusion, so nothing in the reasoning showed that it was one.

There is also a Windows trap in the comparison code, for anyone doing the same thing. Opening an image with sharp(path) keeps a handle on the file, and writing to that same path afterwards fails with an unhelpful error. Read the bytes first and decode from memory.