Three kinds of dead weight
"Tree shaking" gets used as a catch-all, but the cleanup splits into three buckets that pay off in completely different currencies. Sorting every finding into one of these first is what kept the pass honest.
- Shipped bundle. Code that reaches the browser. This is the only bucket that moves first-load JS. Removing an unused dependency that was actually imported lands here.
- Deploy weight. Files that ride along on every deployment but never load — anything unreferenced under
public/. Doesn't touch the bundle, but it's bytes on every build artifact. - Source hygiene. Exports nothing imports, dead components, redundant config. The bundler already tree-shakes unused exports out, so removing them changes nothing at runtime. The payoff is a codebase that doesn't lie about what's in use.
Most of this pass was bucket three (hygiene), a bit was bucket two (deploy weight), and exactly one change touched bucket one (the bundle). Being clear about which is which stops you from overselling a cleanup as a perf win it isn't.
The method: analyze, verify, remove, track
@next/bundle-analyzer for the visual treemap, depcheck for unused dependencies, ts-prune for dead exports, plus a manual sweep of public/ and the PostCSS config.- Every tool here is a heuristic. depcheck can't see a package used through a CLI or a CSS
@import; ts-prune can't trace framework-convention exports. So the rule was: a tool nominates, a grep confirms. Nothing got removed on a report alone. - Remove in small, single-purpose commits so a regression bisects to one decision, and re-run
tsc and the full test suite after each one.
What came out, and why
- autoprefixer. Tailwind v4 runs Lightning CSS internally, which already does vendor prefixing. A second autoprefixer pass in PostCSS was redundant work. Trade-off considered: none real — the prefixing still happens, just once instead of twice.
- Five starter SVGs. The
create-next-app leftovers (next.svg, vercel.svg, and friends). Everything in public/ ships whether or not anything points at it — grepped all five to zero references, then deleted. Deploy weight, not bundle. - Raw model sources. 256K of source
.glb files sat in public/models/raw/, shipping on every deploy but never loaded — only the optimized copies are served. Moved them to a non-served models-src/ instead of deleting, because they're the regeneration source for the models. - Dead exports. A batch of unused animation variants, calendar read helpers, and type aliases. Pure hygiene — the bundler was already dropping them. Verified each by grepping the whole repo (including co-located tests); every one appeared only in its own file.
- A WebGL dependency. Two orphaned v1-landing hero components, and
@shadergradient/react along with them — one component was its only importer. This was the single change that trimmed the actual shipped bundle.
The judgment calls a report can't make
Two findings looked identical to their tools — both flagged as "unused" — but needed opposite decisions. This is the part worth slowing down for.
- gltf-transform — flagged, but kept. depcheck called it an unused devDep. It isn't: it's a CLI-only model asset-prep toolchain that strips Draco compression from GLBs for a real CSP reason, documented in the architecture notes. depcheck simply can't see usage that never appears as an
import. Removing it would have broken a pipeline to satisfy a false positive. Kept it, and added it to the ignore list. - v1 hero components — flagged, and removed. Genuinely orphaned, but tangled up with a feature the project values:
page.tsx keeps retired landing versions reachable through a version switch. The tension: keeping history vs. carrying a whole WebGL dependency for code nothing reaches. Pro of keeping — preserves the artifact. Con — the blocking dead-code check would need a permanent suppression on code that is, in fact, dead. Suppressing dead code to pass a dead-code check defeats the check, so they were removed. The story still lives in the ui-redesign write-up, so no history was actually lost.
Automating it: a blocking check, and its tax
A one-time cleanup rots the moment the next feature lands, so the two fast checks now run on every push as a blocking pnpm deadcheck step in CI.
- Blocking vs. advisory. Advisory checks get ignored until they're noise. Blocking means a stray unused import can't merge — but the cost is maintaining an ignore list, or the build cries wolf and someone disables it. We took the upkeep in exchange for catching rot at review time.
- The false-positive tax. Both tools have blind spots, so both need a curated allow-list: CLI tools and CSS-only packages for depcheck, App Router convention exports and re-export barrels for ts-prune. Those live in
.depcheckrc.json and .ts-prunerc.json. - The "used in module" wrinkle. ts-prune's own
--error flag fails on exports used only inside their own file — which isn't dead code, just a stray export keyword, and there are dozens. A small wrapper script filters those so the build fails only on exports nothing imports at all. - What stayed manual. The treemap and build-size diffing are exploratory and slow, so they're not in CI. Only the fast, deterministic checks got automated — a check you run is worth more than one you skip because it's slow.
The takeaway
- Name the currency before you celebrate a deletion. "I removed an unused export" is tidier code, not a faster page. "I dropped a dependency an import pulled in" is the one that moves bytes.
- A heuristic tool nominates; you confirm. Every "unused" flag is a question, not an answer — the gltf-transform and v1 cases looked the same to the tool and needed opposite calls.
- Automate the checks that are fast and deterministic; leave the slow, exploratory ones as a manual ritual. And never suppress a real finding just to make a green checkmark — that's how a check quietly stops meaning anything.
What I'd do now, and what I did
- Three distinct kinds of dead weight named separately, because they need different removals and lumping them together is how the analysis stalls.
- A method rather than a one-off: analyse, verify, remove, track — with the tracking being the part that usually gets skipped.
- The judgement calls recorded, including what was deliberately left in.
Where it could go further
- Nothing prevents regrowth. The tracking was manual and a dependency added tomorrow gets no scrutiny.
- The analysis is whole-bundle, so a single heavy route hides in the total.
Next on this
- A size budget in CI, which is what turns this from a periodic effort into a standing constraint.