Nine files of headroom: what a 1,000-file deploy limit taught me about srcset and dead assets
I build a browser-side customizer for printed sneakers. You upload artwork, it gets sliced across 66 leather panels, and you see it on the actual shoe: bukefootwear.com. The geometry is covered in the first post, the mag
I build a browser-side customizer for printed sneakers. You upload artwork, it gets sliced across 66 leather panels, and you see it on the actual shoe: bukefootwear.com. The geometry is covered in the first post, the magnification math in the second, the 44-second render in the third.
This one is about the part nobody writes about: the deploy.
Our release is a folder of static files pushed to Cloudflare Pages through a direct upload. That path caps a deployment at 1,000 files, and it fails at upload time, not build time. We found out where we stood by counting:
$ find cloudflare-release -type f | wc -l
1068
$ find cloudflare-release/assets -type f | wc -l
991
991 assets, 9 files of headroom, and a product roadmap that wanted to add more. That is not a performance problem. It is a packaging problem, and it forced a question I had been avoiding: how much of what we ship is never requested?
The answer was "more than half," and the two traps I hit while removing it are what this post is about.
Where the files come from
The customizer needs factory photos and alpha masks. Per model: 5 camera angles, one base photo each, and 29 panel masks per angle. Multiply by 8 models and you get a few thousand candidate images before anyone writes a line of CSS.
Most of that mass is not the 66 panel textures. It is higher-resolution originals, validation renders, calibration frames, and three generations of mask sets that were superseded but never deleted. Deleting them by hand is how you break a page you forgot about — nobody remembers which calibration image is still referenced from a comparison view.
So instead of guessing, measure.
The scanner: keep only what is reachable
Our pages are static HTML plus a handful of JS and CSS files, and all local assets live under assets/. That is enough to define reachability: walk every .html, .js, .css file, find anything that looks like an assets/... path, and stat it.
const sourceFiles = (await walkFiles(output)).filter((file) => {
const rel = relativePosix(file);
return !rel.startsWith('assets/') && ['.html', '.js', '.css'].includes(extname(file).toLowerCase());
});
const keep = new Set();
const assetRoot = resolve(output, 'assets');
for (const sourceFile of sourceFiles) {
const content = await readFile(sourceFile, 'utf8');
for (const match of content.matchAll(/assets\/[A-Za-z0-9_./-]+/g)) {
const candidate = resolve(output, match[0].split(/[?#]/, 1)[0]);
if (candidate !== assetRoot && !candidate.startsWith(assetRoot + sep)) continue;
try {
const details = await stat(candidate);
if (details.isFile()) keep.add(candidate);
else if (details.isDirectory()) for (const child of await walkFiles(candidate)) keep.add(child);
} catch {
if (/\.(?:png|jpe?g|webp|svg|ico|woff2?)$/i.test(match[0])) {
errors.push(`Referenced asset is missing: ${match[0]}`);
}
}
}
}
for (const assetFile of await walkFiles(assetRoot)) {
if (!keep.has(assetFile)) await rm(assetFile);
}
Two details that matter more than the code:
Directories are kept whole. If a reference resolves to a directory, everything inside it survives. That is deliberate: some assets are loaded by naming convention (angle-3.webp) inside a loop, and I would rather keep an unused folder than ship a broken angle.
A missing reference is a build failure. The catch branch does not log a warning; it pushes an error and fails the build. A silently 404ing webp is exactly the class of bug that reaches production, because nobody opens the network tab looking for the image that didn't load.
The first run removed 300+ files and dropped the release to a size we could keep shipping. It also produced a list of every orphan, which is a much better code review artifact than "I deleted some stuff."
Trap 1: dynamic paths are invisible to a text scanner
The scanner's whole premise is that paths appear as literal text. Ours didn't, in one place.
The product page has a thumbnail strip where each 76px tile should load a small image, not the full-size one. The original code built the path at runtime:
const thumb = src.replace('/gallery/', '/gallery/thumb/').replace('.webp', '.jpg');
That is correct at runtime and completely invisible to matchAll. The scanner sees no assets/factory/XB2/gallery/thumb/01.jpg anywhere, deletes every thumb file, and the strip starts requesting 404s. The page still renders. The build still passes. You only find out from a console full of errors, or from a customer.
The fix is to make the paths literal. The build step now generates a map as plain strings:
// generated, do not hand-edit
const GALLERY_STRIP_THUMBS = {
"assets/factory/DF1/gallery/01.webp": "assets/factory/DF1/gallery/thumb/01.jpg",
// ...one line per tile
};
and the runtime does a lookup with a fallback, so a missing entry degrades to the full image instead of a broken tile:
const thumbOf = (src) => GALLERY_STRIP_THUMBS[src] || GALLERY_STRIP_THUMBS_ALT[src] || src;
The general rule I took away: any asset path you construct at runtime is an asset the packager cannot see. Either resolve it through a generated literal manifest, or accept that your deploy step will delete it at some point. There is no third option, because "remember to add it to the exclusion list" is not a mechanism.
I also added a second guard, a stale-reference check: every key in the manifest must exist on disk, every value must be a real file, and the build fails otherwise. That catches the other direction — a manifest that grows out of sync after a directory gets regenerated.
Trap 2: the srcset that saved zero bytes
With the file count under control, I went after bytes. The gallery page was shipping 876 KB of images on a mid-range Android at DPR 3. The cards are 260 CSS px wide on mobile, so I generated a 720px variant and added it to the srcset:
<img srcset="assets/featured/champagne-weave/thumb.webp 560w,
assets/featured/champagne-weave/campaign-720.webp 720w,
assets/featured/champagne-weave/campaign.webp 1100w"
sizes="(max-width:760px) 260px, 545px">
Saved bytes: zero. The page still pulled the 1100w file on mobile. Here's why.
A card is 260 CSS px. At DPR 3 that is 780 device pixels. The candidates are 560w, 720w, 1100w. 720 is not enough for 780, so the browser correctly picks 1100. I had added a variant that was strictly worse than an existing one: bigger than 560, still too small.
The rule is a single line of arithmetic, and it is not optional:
needed_px = css_px * devicePixelRatio
For the hero image at 337 CSS px, DPR 3 needs 1,011 — so a 720w candidate is dead weight there too, and the browser jumps to 1400w. For the card at 260 CSS px you need 780, so the useful rungs are ~560 (DPR 2) and ~800 (DPR 3).
Once I sized candidates against css_px × DPR instead of picking round numbers, the gallery went from 876 KB to 507 KB (−42%) on mobile, with the LCP image itself dropping from 283 KB to 176 KB. Same layout, same markup, no change to how it looks. The only difference is that every candidate became reachable by some real device.
Two things get confused here, so to be explicit: sizes describes the layout width, and the browser multiplies it by DPR internally. If your sizes lies — says 545px when the container is actually 337 — every downstream decision inherits that lie, because the chosen candidate is computed from it.
The product page had the same disease with worse symptoms: eight product photo sets at 377–1159 KB each. They are now 196–356 KB (−86% on the worst one), and the 76px thumbnail grid finally loads thumbnail-sized files.
Trap 3: layout shift is a packaging problem too
The same release process exposed a third issue that I don't think is separate from the other two.
The customizer page had a CLS of 0.80 on desktop. The cause was mundane: a grid of cut panels that started empty in HTML and got appended to by JavaScript, one panel at a time. The container grew by 376px after first paint and pushed everything below it down.
The fix was not a CSS trick, it was ordering: move the grid into a section that already exists in the static markup, at the end of its column, so growth happens below the fold instead of above it. Desktop CLS went 0.80 → 0.016, and at 390px wide it is now 0.
One caution if you go measuring this: CLS is flaky in ways that will make you chase ghosts. The same page, same URL, four consecutive runs gave me 0 / 0.1311 / 0 / 0 — with a warm cache, the JS wins the race against first paint and there is nothing to shift. A single run is not a measurement. And measure production, not localhost: identical code gave 0.0017 locally and 0.0342 on the CDN, because locally the first paint is the final state and online it isn't.
The checklist
If you ship a static site with a lot of images, these are the four rules I now apply before every deploy:
- Count files, not just bytes. We fail the build over 1,000. A file budget is as real as a byte budget, and it is the one that stops the deploy outright.
- Derive the release from references, and fail on missing ones. Deleting unreferenced assets is safe; deleting assets whose references you couldn't parse is how you ship 404s.
- No runtime-constructed paths. Generate a literal manifest, or the packager will eventually eat your files.
-
Size every srcset candidate against
css_px × DPR. Otherwise you are shipping variants no device can ever select.
None of this is clever. It is just the difference between a build that tells you when it is wrong and one that quietly ships.
We are at 991 files with 9 to spare, which means the next thing I build has to justify its files before it justifies its features. If you want to see what all this packaging is protecting, the thing itself is at bukefootwear.com — upload a drawing and watch it land on a shoe.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.