How I Cut a 2.1 MB JavaScript Bundle to 890 KB With Claude Code A developer used Claude Code as a measurement-driven 'perf detective' to reduce a 2.1 MB JavaScript bundle to 890 KB, improving a Lighthouse score from 41 to a higher value. The approach involved feeding the agent real build artifacts, enforcing one change per measurement, and locking in gains with lint rules and a CI budget. The developer emphasized that agents need ground truth data to avoid generic advice. I had a 2.1 MB initial JavaScript bundle and a Lighthouse performance score of 41 on mid-range Android. I used Claude Code as a measurement-driven "perf detective" instead of asking it to "make the app faster," and got the bundle down to 890 KB in about four working sessions. The trick was giving the agent real build artifacts to read, forcing one change per measurement, and then locking the wins in with lint rules and a CI budget so they couldn't rot. 🚀 Our dashboard app had grown for three years. Nobody deliberately made it heavy — it just accumulated. The numbers as of the day I started: package.json Support tickets said things like "the page just sits there." Our analytics said 11% of sessions on mobile bounced before first interaction. That's the kind of number that finally gets bundle work prioritized. Here's why this task is miserable for a human, and interesting for an agent: bundle bloat is archaeology , not engineering. The actual fixes are usually trivial one-liners. Finding which one-liners, across hundreds of import sites and a dependency tree six levels deep, is the entire job. It's high-volume, low-creativity reading — exactly what I'd rather delegate. My first attempt was the naive one. I opened Claude Code v2.x, on Node.js 22.x and typed: "Analyze this project and reduce the JavaScript bundle size." The result was confidently wrong. It suggested lazy-loading three components that were already lazy-loaded, recommended I "consider tree-shaking" we had it on , and proposed swapping a library that accounted for 4 KB. It was pattern-matching on what bundle-size blog posts say, because I hadn't given it a single byte of data about my bundle. That failure is the whole lesson: an agent with no ground truth will give you the median blog post. Before asking for a single change, I made the build emit machine-readable stats and had the agent read those instead of guessing from source code. // package.json { "scripts": { "build:stats": "vite build --mode production && node scripts/bundle-report.mjs", "size": "node scripts/bundle-report.mjs --summary" } } The report script is boring on purpose — it walks the build output plus the generated source maps and emits a flat JSON file of "module → bytes contributed": js // scripts/bundle-report.mjs abridged import { readFileSync, writeFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' const DIST = 'dist/assets' const rows = for const file of readdirSync DIST .filter f = f.endsWith '.js.map' { const map = JSON.parse readFileSync join DIST, file , 'utf8' const totals = new Map map.sources.forEach source, i = { const bytes = map.sourcesContent?. i ?.length ?? 0 // collapse to package granularity: node modules/foo/bar - foo const pkg = source.includes 'node modules' ? source.split 'node modules/' 1 .split '/' .slice 0, 1 0 : 'app' totals.set pkg, totals.get pkg ?? 0 + bytes } for const pkg, bytes of totals rows.push { chunk: file, pkg, bytes } } rows.sort a, b = b.bytes - a.bytes writeFileSync 'bundle-report.json', JSON.stringify rows, null, 2 console.table rows.slice 0, 20 Now my prompt could be specific: "Read bundle-report.json . For each of the top 10 packages by bytes, find every import site in src/ and tell me: is this needed on first paint, or is it reachable only from a specific route? Answer in a table. Don't change any code yet." The difference was night and day. Instead of generic advice, I got a table with file paths and line numbers, and three entries flagged "imported at app root, used only on /reports ." The second failure mode I hit: when I let the agent batch five optimizations, the bundle dropped 300 KB and two charts silently stopped rendering . I couldn't tell which change did it without unwinding all five. So I put the loop in writing and made it non-negotiable: php flowchart LR A Measure: npm run build:stats -- B Pick ONE candidate B -- C Apply the change C -- D Re-measure + run tests D -- |Smaller & green| E Commit with before/after in message D -- |Regressed or red| F Revert immediately E -- A F -- A In CLAUDE.md I wrote it as a hard rule for this task: Bundle work protocol 1. Run npm run size and record the number BEFORE touching anything. 2. Change exactly ONE thing. 3. Run npm run size and npm test . Put both numbers in the commit message. 4. If bytes went up, or any test fails, git revert and move on. Do not "fix forward". 5. Never change more than one dependency per commit. This is the single highest-leverage thing I did. Every commit became a self-contained experiment with a recorded result, which meant a bad idea cost me one revert instead of an afternoon of bisecting. Four fixes accounted for 87% of the savings. None of them were clever. 1. A date library with every locale on Earth −312 KB . We used a legacy date library in exactly six places, all of them formatting a timestamp. The agent found all six call sites, rewrote them against the platform Intl.DateTimeFormat , and deleted the dependency. python // before import moment from 'moment' const label = moment ts .format 'MMM D, YYYY' // after — 0 KB, built into the runtime const fmt = new Intl.DateTimeFormat 'en-US', { month: 'short', day: 'numeric', year: 'numeric', } const label = fmt.format new Date ts 2. Barrel-file imports pulling in an entire icon set −418 KB . This one is my favourite because it looks completely harmless: // this pulls the barrel, and our bundler couldn't tree-shake it // because the package ships CommonJS with side effects import { ChevronDown, Search, User } from '@acme/icons' // after: 3 icons instead of 1,100 import ChevronDown from '@acme/icons/chevron-down' import Search from '@acme/icons/search' import User from '@acme/icons/user' The agent found 84 files doing this and rewrote them mechanically. This is the class of task where a coding agent genuinely beats me: I would have done twelve files, gotten bored, and shipped a partial fix. 3. A charting library loaded on every route −284 KB . Charts appeared on one page out of nineteen. One dynamic import fixed it: js const RevenueChart = lazy = import './RevenueChart' // in the route