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- 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":
// 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 insrc/
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:
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.
// 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:
const RevenueChart = lazy(() => import('./RevenueChart'))
// in the route
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart data={data} />
</Suspense>
4. Polyfills for browsers we stopped supporting in 2023 (β156 KB). Our browserslist config still said ie 11
. Nobody had touched it. Deleting one line in .browserslistrc
removed a pile of transpiler helpers and regenerator runtime.
Bundle size is not a project, it's a ratchet. Every fix above will silently come back within two quarters unless something stops it. So the last session was spent on guardrails, not optimizations.
An ESLint rule that makes the barrel-import mistake impossible to repeat:
// eslint.config.js
export default [{
rules: {
'no-restricted-imports': ['error', {
paths: [
{ name: '@acme/icons', message: 'Import the single icon: @acme/icons/<name>' },
{ name: 'moment', message: 'Use Intl.DateTimeFormat instead.' },
],
}],
},
}]
And a size budget that fails the build in CI:
- name: Check bundle budget
run: |
npm run build:stats
node -e '
const max = 950 * 1024;
const size = require("./bundle-report.json")
.filter(r => r.chunk.includes("index"))
.reduce((a, r) => a + r.bytes, 0);
if (size > max) {
console.error(`Bundle ${Math.round(size/1024)}KB exceeds ${max/1024}KB budget`);
process.exit(1);
}
console.log(`Bundle OK: ${Math.round(size/1024)}KB`);
'
Final numbers after four sessions:
| Metric | Before | After |
|---|---|---|
| Initial JS | 2,148 KB | 890 KB |
| Gzipped | 612 KB | 241 KB |
| Time to Interactive (Moto G4) | 8.4s | 3.1s |
| Lighthouse performance | 41 | 88 |
1. Measurement is the prompt. The gap between "reduce my bundle size" and "read bundle-report.json
and find import sites for the top 10 packages" is the gap between a blog-post summary and an actual fix. If your agent is giving generic advice, the problem is almost never the model β it's that you haven't handed it data only your repo has.
2. Force one change per measurement. Batched optimizations are unattributable. When five changes ship together and something breaks, you've lost the ability to reason about cause. A protocol that costs a few extra build runs buys you a clean revert path, which is worth far more.
3. Agents are exceptional at boring breadth. Rewriting 84 import statements consistently is where an agent outperforms me by a wide margin β not because it's smarter, but because it doesn't get bored at file 12 and declare victory. Aim agents at tasks whose difficulty is volume, not insight.
4. If you don't ratchet it, it comes back. Every performance win decays. The lint rule and the CI budget took 40 minutes and are worth more than any single 300 KB fix, because they convert a one-time cleanup into a floor. Spend the last session of any cleanup project on the thing that prevents the regression.
5. "Confidently wrong" is a data problem, not a trust problem. My instinct after the first bad session was that the agent couldn't be trusted with perf work. It could β it just had nothing to work from. I now treat every confidently wrong answer as a missing-artifact bug on my side first. β οΈ
Two things I'm working on now:
If you're staring at a bundle that's grown past 1 MB: don't start by asking an AI to fix it. Start by making your build emit a file that says exactly where the bytes went, then point the agent at that file. The fixes are usually four boring one-liners hiding behind an afternoon of archaeology.
If this was useful:
What's the dumbest thing that was inflating your bundle? Mine was a 1,100-icon barrel file behind three chevrons. π‘