Bundle Analysis skill for Claude Code

Cut the initial JS users actually download. Claude measures before cutting: compressed size of the initial route, a treemap of what is in it, and a classification of every big module as needed now, needed later, duplicated or dead. It then removes, lazy-loads, replaces or tree-shakes in that order, proves the gain with the same build command, and adds a dependency-free budget script to CI so size cannot creep back.

  • namebundle-analysis
  • size184 lines · ≈2,100 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Shrinks JavaScript and CSS bundles by measuring first — bundle analyzers for Vite, Rollup, webpack, Next.js, and esbuild, source-map-explorer, compressed size budgets — then fixing the biggest offenders with route-level code splitting, tree-shaking repairs (ESM, sideEffects, barrel files), dependency replacement, and modern browser targets. Use when the JS bundle is too large, initial load or TBT is slow, a dependency bloated the build, setting size budgets in CI, or asking why a library ends up in the main chunk.

Try asking

  • Our main.js is 900 KB — figure out what is in it and get it under 250 KB
  • Why does lodash end up in the main chunk even though we import one function?
  • Add a bundle size budget to CI that fails the PR if JS grows

Use it when

  • Initial JS is large or Total Blocking Time is high
  • A new dependency noticeably grew the build
  • Setting or enforcing bundle size budgets
  • Tree shaking or code splitting does not seem to work

Skip it when

  • The slow metric is LCP from images or server time — use core-web-vitals
  • You need Lighthouse score gates rather than byte budgets — use lighthouse-ci

How to check the result

The same production build shows a smaller compressed initial-route size, lazy chunks load only when expected, and scripts/check_bundle_budget.mjs passes in CI.

The common mistake it prevents

Marking a package sideEffects: false or lazy-loading code every user needs immediately, which drops CSS or polyfills or just moves the cost into interaction latency.

~/.claude/skills/bundle-analysis/SKILL.md

--- name: bundle-analysis description: Shrinks JavaScript and CSS bundles by measuring first — bundle analyzers for Vite, Rollup, webpack, Next.js, and esbuild,… license: MIT ---

Done well means: you know the compressed size of the initial JS per route, every large module in it is there on purpose, heavy features load on demand, the change is proven by before/after numbers from the same build, and a budget in CI stops the size creeping back.

Ground rules

  • Measure compressed (gzip or brotli) size of what the browser downloads for the initial route. Raw minified size misleads; total dist/ size includes lazy chunks nobody loads.
  • Work largest-first. Removing a 2 KB helper while a 300 KB charting library sits in the main chunk is noise.
  • One change per measurement. Record the build command and the numbers.
  • Do not trade bundle size for correctness: never drop polyfills the support matrix needs, never mark packages side-effect-free without checking.
  • Budgets reference: aim for ≤ 150–200 KB compressed JS on the critical path for content and commerce pages on mid-range mobile; app shells can justify more, but the number should be a decision, not an accident.

Workflow

- [ ] 1. Baseline: production build, record compressed sizes per entry/route
- [ ] 2. Visualize: generate a treemap and list the top 10 modules in initial chunks
- [ ] 3. Classify each: needed now / needed later / duplicated / not needed
- [ ] 4. Fix in order: remove > lazy-load > replace > tree-shake > target modern browsers
- [ ] 5. Rebuild and compare against the baseline
- [ ] 6. Add or tighten a CI budget

1. Baseline

node scripts/check_bundle_budget.mjs dist --compression brotli --top 15

The script walks a build directory, prints gzip or brotli sizes per file (largest first) and a total, and exits 1 when --max-total-kb, --max-file-kb, or a per-pattern --budget "assets/index-*.js=120" is exceeded. Use --json to store a baseline.

Framework build outputs also print sizes: next build shows per-route first-load JS; vite build prints gzip sizes per chunk.

2. Visualize

BundlerAnalyzer
Vite / Rolluprollup-plugin-visualizer in the config (visualizer({ open: true, gzipSize: true, brotliSize: true })), or npx vite-bundle-visualizer
webpackwebpack-bundle-analyzer plugin, or webpack --profile --json > stats.json then npx webpack-bundle-analyzer stats.json
Next.js@next/bundle-analyzer wrapped around next.config, run with ANALYZE=true next build (webpack builds); for Turbopack builds check the installed Next version’s analyzer docs
esbuild--metafile=meta.json, then upload to the esbuild bundle analyzer page or inspect with esbuild --analyze
Any (with source maps)npx source-map-explorer 'dist/assets/*.js'

Look for: one dependency dominating a chunk, the same library twice (two versions or CJS + ESM copies), locale or icon packs bundled whole, server-only code in client chunks, and polyfills for browsers you do not support.

Why is this module here? webpack: stats.json reasons; Vite/Rollup: the visualizer’s importer tree; any bundler: grep -rn "from 'heavy-lib'" src to find import sites.

3–4. Fix patterns, highest value first

FindingFix
Unused dependency or dead codeRemove it; find candidates with npx knip
Heavy feature not needed at first paint (editor, charts, maps, PDF, date pickers)Dynamic import() at the interaction or route boundary
Whole library imported for one functionNamed imports from ESM builds; or native API
Duplicate versionsnpm ls <pkg> / pnpm why <pkg>; dedupe with npm dedupe, overrides/resolutions
Moment.js, full lodash, big icon setsdate-fns or Intl/Temporal; lodash-es per-function or native; per-icon imports
Locale data bundledLoad only the active locale dynamically
CJS dependency defeating tree shakingPrefer a package with an ESM build; check its exports/module fields
Barrel file (index.ts re-exporting everything) pulling the world inImport from the specific module path; in Next.js use experimental.optimizePackageImports for listed libraries
Legacy polyfills and transpilationUpdate browserslist to the real support matrix; drop core-js entries not needed
Server code in client bundleMove behind server-only modules (import "server-only" in Next.js), split shared utils

Route and interaction splitting:

// React: route-level
import { lazy, Suspense } from "react";
const Reports = lazy(() => import("./routes/Reports"));

<Suspense fallback={<Spinner />}><Reports /></Suspense>

// Interaction-level: load on intent
async function openEditor() {
  const { createEditor } = await import("./editor");   // separate chunk
  createEditor(el);
}
button.addEventListener("pointerenter", () => import("./editor"), { once: true }); // prefetch on hover
// Next.js client component that should not be in first-load JS
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("../components/Chart"), { ssr: false, loading: () => <ChartSkeleton /> });

Tree shaking requirements:

  • The library ships ES modules and declares "sideEffects": false (or a list) in its package.json. For your own packages in a monorepo, add the field truthfully: files that register globals or import CSS are side effects.
  • Use named imports: import { debounce } from "lodash-es", not import _ from "lodash".
  • Avoid export * from chains in app code; they make side-effect analysis harder and slow dev servers.
  • Check the result in the treemap, not by assumption.

Manual chunking (build.rollupOptions.output.manualChunks in Vite, splitChunks in webpack) is a last resort for caching vendor code; it rarely reduces what the initial route downloads and can make it worse.

5. Compare

Rebuild with the same command and compare totals and the initial-route number:

node scripts/check_bundle_budget.mjs dist --compression brotli --json > after.json

Also confirm behavior: lazy chunks load when expected (DevTools Network, filter JS), and no new waterfall delays the main interaction (a lazy chunk needed immediately after load should be preloaded or kept in the main bundle).

6. Budgets in CI

node scripts/check_bundle_budget.mjs dist --compression gzip \
  --budget "assets/index-*.js=120" --budget "assets/vendor-*.js=90" --max-file-kb 150

Alternatives if the project already uses them: size-limit (with .size-limit.json, reports in PRs) or bundler-native warnings (build.chunkSizeWarningLimit in Vite, performance.maxEntrypointSize in webpack). Set budgets just above the current size after the cleanup, then ratchet down.

Choosing replacements

Before adding a dependency, check its cost with the package’s published size (e.g. pkg-size.dev or bundlephobia) and whether a platform API covers it:

NeedPlatform option
Date formattingIntl.DateTimeFormat, Intl.RelativeTimeFormat
Number/currencyIntl.NumberFormat
Deep clonestructuredClone
UUIDcrypto.randomUUID()
HTTP clientfetch
Class names joinerclsx (tiny) or template strings
Animation basicsCSS transitions, Web Animations API

Gotchas

  • sideEffects: false on a package that imports CSS or registers polyfills silently drops them in production; test the built app.
  • Dynamic import() with a fully variable path bundles every file the bundler can match; keep the path static or with a constrained template.
  • Source-map-based tools attribute inlined helpers oddly; cross-check with the bundler’s own stats.
  • Gzip numbers from your laptop may differ slightly from the CDN’s compression level; compare relatively, not to the byte.
  • Lazy-loading something every user needs on first interaction just moves the cost into INP. Preload it after first paint instead.
  • CSS counts too: unused framework CSS and large icon fonts block render.

Report template

**Build:** `pnpm build` (Vite 6), brotli sizes
**Initial route JS:** 412 KB -> 186 KB
**Changes:**
1. Charting lib (138 KB) moved behind dynamic import on /reports — loads on route entry
2. moment + locales (71 KB) replaced with Intl.DateTimeFormat
3. Duplicate react-dom copies removed via pnpm override (18 KB)
**Budget added:** index-*.js <= 200 KB brotli in CI
**Checked:** /reports chart renders, no new request waterfall on home

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/check_bundle_budget.mjs 112 lines · 5.4 KB
#!/usr/bin/env node
// Measure compressed sizes of built assets and enforce budgets. Node 20+, no dependencies.
//
// Usage:
//   node check_bundle_budget.mjs <build-dir> [--ext .js,.css] [--compression gzip|brotli]
//        [--max-total-kb N] [--max-file-kb N] [--budget "pattern=KB" ...] [--top 20] [--json]
//
// Examples:
//   node check_bundle_budget.mjs dist --max-total-kb 250 --max-file-kb 120
//   node check_bundle_budget.mjs .next/static/chunks --budget "main-*.js=60" --budget "pages/_app-*.js=90"
//
// Exit codes: 0 within budget, 1 budget exceeded, 2 usage error.

import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { gzipSync, brotliCompressSync, constants } from "node:zlib";

function usage(msg) {
  if (msg) console.error(`error: ${msg}`);
  console.error("usage: node check_bundle_budget.mjs <build-dir> [--ext .js,.css] [--compression gzip|brotli] " +
    "[--max-total-kb N] [--max-file-kb N] [--budget \"pattern=KB\" ...] [--top N] [--json]");
  process.exit(2);
}

const args = process.argv.slice(2);
if (args.length === 0 || args.includes("-h") || args.includes("--help")) usage();

const opts = { dir: null, ext: [".js", ".mjs", ".css"], compression: "gzip", maxTotal: null, maxFile: null, budgets: [], top: 20, json: false };
for (let i = 0; i < args.length; i++) {
  const a = args[i];
  const next = () => { const v = args[++i]; if (v === undefined) usage(`${a} needs a value`); return v; };
  if (a === "--ext") opts.ext = next().split(",").map((e) => (e.startsWith(".") ? e : `.${e}`));
  else if (a === "--compression") opts.compression = next();
  else if (a === "--max-total-kb") opts.maxTotal = Number(next());
  else if (a === "--max-file-kb") opts.maxFile = Number(next());
  else if (a === "--budget") {
    const raw = next(); const eq = raw.lastIndexOf("=");
    if (eq < 1) usage(`bad --budget ${raw}; expected pattern=KB`);
    opts.budgets.push({ pattern: raw.slice(0, eq), kb: Number(raw.slice(eq + 1)) });
  } else if (a === "--top") opts.top = Number(next());
  else if (a === "--json") opts.json = true;
  else if (a.startsWith("--")) usage(`unknown option ${a}`);
  else if (!opts.dir) opts.dir = a;
  else usage(`unexpected argument ${a}`);
}
if (!opts.dir) usage("build directory is required");
if (!["gzip", "brotli"].includes(opts.compression)) usage("--compression must be gzip or brotli");
for (const n of [opts.maxTotal, opts.maxFile, ...opts.budgets.map((b) => b.kb)]) {
  if (n !== null && !(n > 0)) usage("budgets must be positive numbers (KB)");
}
try { if (!statSync(opts.dir).isDirectory()) usage(`${opts.dir} is not a directory`); }
catch { usage(`${opts.dir} does not exist`); }

function globToRegExp(glob) {
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
  return new RegExp(`(^|/)${escaped.replace(/\u0000/g, ".*")}$`);
}

function walk(dir) {
  const out = [];
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const p = join(dir, entry.name);
    if (entry.isDirectory()) out.push(...walk(p));
    else if (entry.isFile() && opts.ext.some((e) => entry.name.endsWith(e)) && !entry.name.endsWith(".map")) out.push(p);
  }
  return out;
}

const compress = opts.compression === "gzip"
  ? (buf) => gzipSync(buf, { level: 9 }).length
  : (buf) => brotliCompressSync(buf, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }).length;

const files = walk(opts.dir).map((p) => {
  const buf = readFileSync(p);
  return { file: relative(opts.dir, p).split(sep).join("/"), raw: buf.length, compressed: compress(buf) };
}).sort((a, b) => b.compressed - a.compressed);

const kb = (n) => (n / 1024).toFixed(1);
const totalRaw = files.reduce((s, f) => s + f.raw, 0);
const totalCompressed = files.reduce((s, f) => s + f.compressed, 0);
const violations = [];

if (opts.maxTotal !== null && totalCompressed / 1024 > opts.maxTotal) {
  violations.push(`total ${kb(totalCompressed)} KB > ${opts.maxTotal} KB`);
}
for (const f of files) {
  if (opts.maxFile !== null && f.compressed / 1024 > opts.maxFile) violations.push(`${f.file} ${kb(f.compressed)} KB > ${opts.maxFile} KB (max-file)`);
}
for (const b of opts.budgets) {
  const rx = globToRegExp(b.pattern);
  const matched = files.filter((f) => rx.test(f.file));
  if (matched.length === 0) { violations.push(`budget pattern "${b.pattern}" matched no files`); continue; }
  const size = matched.reduce((s, f) => s + f.compressed, 0);
  if (size / 1024 > b.kb) violations.push(`"${b.pattern}" ${kb(size)} KB across ${matched.length} file(s) > ${b.kb} KB`);
}

if (opts.json) {
  console.log(JSON.stringify({ compression: opts.compression, totalRaw, totalCompressed, files, violations }, null, 2));
} else {
  console.log(`${files.length} file(s) in ${opts.dir} (${opts.ext.join(",")}), sizes in KB, ${opts.compression}`);
  console.log(`${"compressed".padStart(10)}  ${"raw".padStart(9)}  file`);
  for (const f of files.slice(0, opts.top)) console.log(`${kb(f.compressed).padStart(10)}  ${kb(f.raw).padStart(9)}  ${f.file}`);
  if (files.length > opts.top) console.log(`  ... ${files.length - opts.top} more`);
  console.log(`${kb(totalCompressed).padStart(10)}  ${kb(totalRaw).padStart(9)}  TOTAL`);
  if (violations.length) {
    console.log("\nBUDGET EXCEEDED:");
    for (const v of violations) console.log(`  - ${v}`);
  } else {
    console.log("\nWithin budget.");
  }
}
process.exit(violations.length ? 1 : 0);

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc