{"slug": "how-i-cut-a-2-1-mb-javascript-bundle-to-890-kb-with-claude-code", "title": "How I Cut a 2.1 MB JavaScript Bundle to 890 KB With Claude Code", "summary": "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.", "body_md": "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. 🚀\n\nOur dashboard app had grown for three years. Nobody deliberately made it heavy — it just accumulated. The numbers as of the day I started:\n\n`package.json`\n\nSupport 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.\n\nHere'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.\n\nMy first attempt was the naive one. I opened Claude Code (v2.x, on Node.js 22.x) and typed:\n\n\"Analyze this project and reduce the JavaScript bundle size.\"\n\nThe 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.\n\nThat failure is the whole lesson: **an agent with no ground truth will give you the median blog post.**\n\nBefore 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.\n\n```\n// package.json\n{\n  \"scripts\": {\n    \"build:stats\": \"vite build --mode production && node scripts/bundle-report.mjs\",\n    \"size\": \"node scripts/bundle-report.mjs --summary\"\n  }\n}\n```\n\nThe 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\":\n\n``` js\n// scripts/bundle-report.mjs (abridged)\nimport { readFileSync, writeFileSync, readdirSync } from 'node:fs'\nimport { join } from 'node:path'\n\nconst DIST = 'dist/assets'\nconst rows = []\n\nfor (const file of readdirSync(DIST).filter((f) => f.endsWith('.js.map'))) {\n  const map = JSON.parse(readFileSync(join(DIST, file), 'utf8'))\n  const totals = new Map()\n\n  map.sources.forEach((source, i) => {\n    const bytes = map.sourcesContent?.[i]?.length ?? 0\n    // collapse to package granularity: node_modules/foo/bar -> foo\n    const pkg = source.includes('node_modules')\n      ? source.split('node_modules/')[1].split('/').slice(0, 1)[0]\n      : 'app'\n    totals.set(pkg, (totals.get(pkg) ?? 0) + bytes)\n  })\n\n  for (const [pkg, bytes] of totals) rows.push({ chunk: file, pkg, bytes })\n}\n\nrows.sort((a, b) => b.bytes - a.bytes)\nwriteFileSync('bundle-report.json', JSON.stringify(rows, null, 2))\nconsole.table(rows.slice(0, 20))\n```\n\nNow my prompt could be specific:\n\n\"Read\n\n`bundle-report.json`\n\n. For each of the top 10 packages by bytes, find every import site in`src/`\n\nand 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.\"\n\nThe 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`\n\n.\"\n\nThe 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.\n\nSo I put the loop in writing and made it non-negotiable:\n\n``` php\nflowchart LR\n    A[Measure: npm run build:stats] --> B[Pick ONE candidate]\n    B --> C[Apply the change]\n    C --> D[Re-measure + run tests]\n    D -->|Smaller & green| E[Commit with before/after in message]\n    D -->|Regressed or red| F[Revert immediately]\n    E --> A\n    F --> A\n```\n\nIn `CLAUDE.md`\n\nI wrote it as a hard rule for this task:\n\n```\n## Bundle work protocol\n1. Run `npm run size` and record the number BEFORE touching anything.\n2. Change exactly ONE thing.\n3. Run `npm run size` and `npm test`. Put both numbers in the commit message.\n4. If bytes went up, or any test fails, `git revert` and move on. Do not \"fix forward\".\n5. Never change more than one dependency per commit.\n```\n\nThis 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.\n\nFour fixes accounted for 87% of the savings. None of them were clever.\n\n**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`\n\n, and deleted the dependency.\n\n``` python\n// before\nimport moment from 'moment'\nconst label = moment(ts).format('MMM D, YYYY')\n\n// after — 0 KB, built into the runtime\nconst fmt = new Intl.DateTimeFormat('en-US', {\n  month: 'short', day: 'numeric', year: 'numeric',\n})\nconst label = fmt.format(new Date(ts))\n```\n\n**2. Barrel-file imports pulling in an entire icon set (−418 KB).** This one is my favourite because it looks completely harmless:\n\n```\n// this pulls the barrel, and our bundler couldn't tree-shake it\n// because the package ships CommonJS with side effects\nimport { ChevronDown, Search, User } from '@acme/icons'\n\n// after: 3 icons instead of 1,100\nimport ChevronDown from '@acme/icons/chevron-down'\nimport Search from '@acme/icons/search'\nimport User from '@acme/icons/user'\n```\n\nThe 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.\n\n**3. A charting library loaded on every route (−284 KB).** Charts appeared on one page out of nineteen. One dynamic import fixed it:\n\n``` js\nconst RevenueChart = lazy(() => import('./RevenueChart'))\n\n// in the route\n<Suspense fallback={<ChartSkeleton />}>\n  <RevenueChart data={data} />\n</Suspense>\n```\n\n**4. Polyfills for browsers we stopped supporting in 2023 (−156 KB).** Our browserslist config still said `ie 11`\n\n. Nobody had touched it. Deleting one line in `.browserslistrc`\n\nremoved a pile of transpiler helpers and regenerator runtime.\n\nBundle 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.\n\nAn ESLint rule that makes the barrel-import mistake impossible to repeat:\n\n```\n// eslint.config.js\nexport default [{\n  rules: {\n    'no-restricted-imports': ['error', {\n      paths: [\n        { name: '@acme/icons', message: 'Import the single icon: @acme/icons/<name>' },\n        { name: 'moment', message: 'Use Intl.DateTimeFormat instead.' },\n      ],\n    }],\n  },\n}]\n```\n\nAnd a size budget that fails the build in CI:\n\n```\n- name: Check bundle budget\n  run: |\n    npm run build:stats\n    node -e '\n      const max = 950 * 1024;\n      const size = require(\"./bundle-report.json\")\n        .filter(r => r.chunk.includes(\"index\"))\n        .reduce((a, r) => a + r.bytes, 0);\n      if (size > max) {\n        console.error(`Bundle ${Math.round(size/1024)}KB exceeds ${max/1024}KB budget`);\n        process.exit(1);\n      }\n      console.log(`Bundle OK: ${Math.round(size/1024)}KB`);\n    '\n```\n\nFinal numbers after four sessions:\n\n| Metric | Before | After |\n|---|---|---|\n| Initial JS | 2,148 KB | 890 KB |\n| Gzipped | 612 KB | 241 KB |\n| Time to Interactive (Moto G4) | 8.4s | 3.1s |\n| Lighthouse performance | 41 | 88 |\n\n**1. Measurement is the prompt.** The gap between \"reduce my bundle size\" and \"read `bundle-report.json`\n\nand 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.\n\n**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.\n\n**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.\n\n**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.\n\n**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. ⚠️\n\nTwo things I'm working on now:\n\nIf 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.\n\n**If this was useful:**\n\nWhat's the dumbest thing that was inflating your bundle? Mine was a 1,100-icon barrel file behind three chevrons. 💡", "url": "https://wpnews.pro/news/how-i-cut-a-2-1-mb-javascript-bundle-to-890-kb-with-claude-code", "canonical_source": "https://dev.to/yureki_lab/how-i-cut-a-21-mb-javascript-bundle-to-890-kb-with-claude-code-2a0p", "published_at": "2026-08-24 14:32:36+00:00", "updated_at": "2026-08-24 14:43:31.197878+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "ai-tools"], "entities": ["Claude Code", "Lighthouse", "Vite", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/how-i-cut-a-2-1-mb-javascript-bundle-to-890-kb-with-claude-code", "markdown": "https://wpnews.pro/news/how-i-cut-a-2-1-mb-javascript-bundle-to-890-kb-with-claude-code.md", "text": "https://wpnews.pro/news/how-i-cut-a-2-1-mb-javascript-bundle-to-890-kb-with-claude-code.txt", "jsonld": "https://wpnews.pro/news/how-i-cut-a-2-1-mb-javascript-bundle-to-890-kb-with-claude-code.jsonld"}}