Tree Shaking with Claude Code: Practical Bundle Cleanup
Improve tree shaking with Claude Code using ESM, sideEffects, bundle measurement, pitfalls, and runnable examples.
What tree shaking really means
Tree shaking is the production-build step that removes unused JavaScript or TypeScript exports from the final bundle. The name sounds abstract, but in day-to-day work it means one simple thing: do not ship code the current page does not need.
Bundlers cannot read your intent.
They inspect import and export syntax, the sideEffects field in package.json, CommonJS transforms, and top-level code that runs as soon as a module is imported.
That is why teams see confusing failures such as “the unused helper stayed in the bundle” or “adding sideEffects: false removed my CSS.”
Claude Code helps most when you give it a measurable workflow, not a vague request to “make the app lighter.” Ask it to capture the current bundle size, identify ESM and CommonJS boundaries, inventory side-effectful files, change one area at a time, and verify the build result. This article uses a small runnable sample and the same review checklist Masa uses when cleaning up Vite, React, and Astro projects.
The working model looks like this:
flowchart LR
A["source files"] --> B["ESM import/export graph"]
B --> C["bundler tree shaking"]
C --> D["minified production bundle"]
B --> E["side effects kept"]
E --> D
D --> F["measure bytes and gzip"]
Start from official behavior, not guesses
Tree shaking behavior differs by bundler. Use official docs as your baseline before changing production code.
| Topic | Official link | What to check in practice |
|---|---|---|
| webpack | Tree Shaking | sideEffects, ESM syntax, production builds |
| webpack option | optimization.sideEffects | how webpack reads package sideEffects flags |
| Rollup/Vite | Rollup treeshake | avoid broad moduleSideEffects changes |
| Rollup details | treeshake.moduleSideEffects | keep modules that run setup code |
| esbuild | Tree shaking | ESM analysis and metafile-based measurement |
The important point is that tree shaking is not magic string deletion. It follows a static ESM dependency graph and keeps code when removing it could change runtime behavior. CommonJS-heavy modules, default objects full of utilities, namespace imports, and files that import CSS or polyfills at the top level often survive longer than expected.
A better Claude Code prompt
Start with investigation and measurement, not edits.
Dropping a global sideEffects: false into a large app can hide visual and runtime regressions.
Investigate why tree shaking is weak in this repository's production bundle.
First report the current build size, major chunks, heavy dependencies,
CommonJS dependencies, and barrel exports in a table.
For every proposed change, include risk, expected size impact, and verification commands.
Assume CSS, polyfills, analytics, and global setup files must not be removed.
When you are ready for a change, narrow the scope.
Only work on src/utils and src/components/index.ts for this pass.
Convert default object exports to named exports and update importing files.
After the change, run npm run build and the bundle-size measurement.
If this changes a public API, keep a compatibility re-export.
That framing makes Claude Code optimize around “what stayed correct” rather than only “what was removed.”
Copy-paste runnable measurement sample
Tree shaking should be measured, not guessed. This small esbuild project compares a default object export with named exports.
mkdir tree-shaking-lab
cd tree-shaking-lab
npm init -y
npm install --save-dev esbuild
mkdir src scripts
Use this package.json.
{
"name": "tree-shaking-lab",
"version": "1.0.0",
"type": "module",
"private": true,
"sideEffects": false,
"scripts": {
"measure": "node scripts/measure-tree-shaking.mjs"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
}
The weak version hides many helpers inside one object.
// src/bad-utils.ts
const utils = {
formatUsd(amount: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
}).format(amount);
},
heavyReport(rows: number[]): string {
const body = rows.map((row) => `row:${row}`).join("\n");
return `report\n${body}\n${"=".repeat(4000)}`;
},
debugOnly(): string {
return "debug:" + "x".repeat(4000);
}
};
export default utils;
The stronger version exports each helper separately.
// src/good-utils.ts
export function formatUsd(amount: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
}).format(amount);
}
export function heavyReport(rows: number[]): string {
const body = rows.map((row) => `row:${row}`).join("\n");
return `report\n${body}\n${"=".repeat(4000)}`;
}
export function debugOnly(): string {
return "debug:" + "x".repeat(4000);
}
Create two entry files.
// src/bad-entry.ts
import utils from "./bad-utils";
console.log(utils.formatUsd(1200));
// src/good-entry.ts
import { formatUsd } from "./good-utils";
console.log(formatUsd(1200));
Add the measurement script.
// scripts/measure-tree-shaking.mjs
import { gzipSync } from "node:zlib";
import { build } from "esbuild";
async function bundle(entryPoint) {
const result = await build({
entryPoints: [entryPoint],
bundle: true,
minify: true,
format: "esm",
treeShaking: true,
write: false,
metafile: true
});
const code = result.outputFiles[0].text;
return {
entryPoint,
bytes: Buffer.byteLength(code),
gzipBytes: gzipSync(code).byteLength,
inputs: Object.keys(result.metafile.inputs)
};
}
const rows = await Promise.all([
bundle("src/bad-entry.ts"),
bundle("src/good-entry.ts")
]);
console.table(rows);
Run it.
npm run measure
The exact bytes are less important than the lesson: the same visible feature can produce different bundle output. In a real app, add chunk names, gzip size, Brotli size, and Lighthouse Total Blocking Time. Pair this workflow with the bundle analysis guide when you need to see which dependency stayed in the graph.
Use case 1: split utility modules
The fastest win is often a crowded utils/index.ts or helpers.ts.
Date formatting, currency formatting, CSV export, Markdown rendering, and debug helpers should not all live in one object.
Ask Claude Code to make the change in small steps.
Split src/utils by purpose.
Replace imports with named imports and only re-export the public helpers from index.ts.
If any top-level Date.now, console, localStorage, or fetch calls exist,
move them inside functions.
A cleaner shape looks like this.
// src/utils/formatDate.ts
export function formatDate(date: Date, locale = "en-US"): string {
return new Intl.DateTimeFormat(locale).format(date);
}
// src/utils/index.ts
export { formatDate } from "./formatDate";
export { formatUsd } from "./formatUsd";
// src/pages/invoice.ts
import { formatUsd } from "../utils/formatUsd";
export function invoiceLabel(total: number): string {
return `Total: ${formatUsd(total)}`;
}
Barrel files are not automatically bad.
They become risky when they run setup code, chain broad export * from statements, or force unrelated modules into the graph.
For application code, prefer direct imports; for library consumers, keep a thin public barrel if compatibility requires it.
Use case 2: internal UI libraries
Internal UI packages often expose import { Button } from "@acme/ui" while evaluating Modal, DatePicker, Chart, icon sets, CSS, and theme setup behind the scenes.
Named exports alone may not be enough if every component shares one heavy entry file.
Split the package into subpath entries.
{
"name": "@acme/ui",
"type": "module",
"sideEffects": [
"**/*.css",
"./src/setup-theme.ts"
],
"exports": {
".": "./dist/index.js",
"./button": "./dist/button.js",
"./modal": "./dist/modal.js"
}
}
Then import only the needed entry.
import { Button } from "@acme/ui/button";
Be careful with sideEffects: false.
It tells the bundler that importing files from the package does not run necessary setup.
That is a module-level concept, not the same thing as React’s useEffect.
CSS files, custom element registration, polyfills, and theme setup should be listed as side-effectful when they must run.
Use case 3: lazy-load admin-only dependencies
Markdown processors, PDF generators, charts, and rich text editors are often unnecessary on the first public page load. Use tree shaking to remove unused exports, then use code splitting to keep heavy features out of the initial chunk.
// src/features/admin/loadMarkdownPreview.ts
export async function renderMarkdown(markdown: string): Promise<string> {
const [{ unified }, remarkParse, remarkHtml] = await Promise.all([
import("unified"),
import("remark-parse"),
import("remark-html")
]);
const file = await unified()
.use(remarkParse.default)
.use(remarkHtml.default)
.process(markdown);
return String(file);
}
Dynamic import is not a replacement for tree shaking. It moves code into a later chunk; it does not guarantee that the later chunk is small. Measure “removed from initial bundle” and “unused code removed inside the lazy chunk” as separate outcomes.
Use case 4: publish a tree-shakable npm package
If you publish a package, give consumers an ESM shape their bundlers can analyze.
Avoid exposing only a CommonJS main entry when your users build frontend code.
{
"name": "@masa/formatters",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./currency": {
"types": "./dist/currency.d.ts",
"import": "./dist/currency.js"
}
}
}
Only use sideEffects: false when the package is truly side-effect free.
If it imports CSS, registers globals, installs polyfills, or starts analytics, list those files in the sideEffects array instead.
Pitfalls and failure cases
Most failures are predictable. Have Claude Code review them in a table before merging.
| Pitfall | Symptom | Fix |
|---|---|---|
| Babel or TypeScript emits CommonJS too early | unused exports remain | keep ESM until the bundler step |
sideEffects: false is too broad | CSS or polyfills disappear | list side-effectful files explicitly |
| default object export | unused helpers stay with object creation | split into named exports |
| barrel file runs top-level setup | importing one component is expensive | keep barrels as re-export-only files |
| measuring dev builds | size changes look wrong | compare production, minified, gzip output |
global moduleSideEffects: false | setup code is dropped | validate per package or per file |
| namespace imports | analysis becomes conservative | import named bindings directly |
The dangerous failures are subtle visual ones. A test that only checks DOM existence may pass while a CSS import has disappeared. Treat bundle cleanup like performance optimization: verify build output, key screens, and user-visible behavior.
Put bundle budgets in CI
A one-time cleanup will regress on the next dependency update. Add a small budget check so every PR sees the cost.
// scripts/check-bundle-budget.mjs
import { statSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { readFileSync } from "node:fs";
const file = "dist/assets/index.js";
const maxGzipBytes = 160 * 1024;
const raw = readFileSync(file);
const gzipBytes = gzipSync(raw).byteLength;
if (gzipBytes > maxGzipBytes) {
console.error(`Bundle budget exceeded: ${gzipBytes} > ${maxGzipBytes}`);
process.exit(1);
}
console.log({
file,
bytes: statSync(file).size,
gzipBytes
});
Run it after a production build.
npm run build
node scripts/check-bundle-budget.mjs
Do not set the first budget to a fantasy target. Start near today’s gzip size with a little room, then require a short explanation when a PR increases it. If the app still feels slow after bundle cleanup, also inspect images, fonts, API latency, and hydration with the speed optimization guide.
Claude Code review checklist
After implementation, use this prompt as a review gate.
Review this tree-shaking PR.
1. Did unused exports disappear from the production bundle?
2. Were CSS, polyfills, and registration files preserved?
3. Was ESM kept until the bundler could analyze it?
4. Did direct imports break any public API compatibility?
5. What are the build, test, key-screen, and bundle-budget results?
Report file names and command evidence for each answer.
This turns the task from a mechanical refactor into a publishable quality check.
In Masa’s client work, the sideEffects change is never considered done until login, billing, and admin screens are opened and checked for missing styling or setup behavior.
Monetization angle
Tree shaking is not only a technical cleanup. Faster first loads reduce friction before article views, product pages, signup forms, and consulting contact forms. For a technical media site like ClaudeCodeLab, a heavy code-example page or slow landing page weakens the revenue path.
ClaudeCodeLab can audit Vite, Next.js, Astro, and internal UI-library bundles, then turn findings into tree-shaking fixes, code splitting, and CI bundle budgets.
For a focused consultation, bring package.json, build config, key routes, and a recent bundle report.
Summary
Tree shaking works when ESM, accurate sideEffects, contained setup code, and continuous measurement line up.
Claude Code is useful when you give it a narrow measured task: inspect, split, update imports, verify, and review the failure cases.
I ran the minimal sample from this article through npm run measure locally and confirmed that the bad and good entries produce different output sizes.
In real projects, the numbers depend on your dependencies, so measure your own production build and document which side effects must remain before publishing the change.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Receipt Pattern: Record Scope, Proof, and Rollback
A permission receipt pattern for Claude Code: allowed actions, approval boundaries, proof commands, rollback, and revenue CTA checks.
Safe Agent Harness Design for Claude Code and Codex: Permissions, Checks, and Rollback
Build a practical agent harness for Claude Code and Codex with policy, planning, verification, and recovery layers.
Claude Code Subagents: A Practical Guide to Safe Agent Delegation
Claude Code subagent guide for safe parallel article and code work: delegation rules, prompts, pitfalls, and checks.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.