1. Insight
Insight
The problem this article addresses and why it matters.
The barrel file is where bundle size goes to die
Every JavaScript developer has shipped a project where the entry bundle is 47KB larger than the sum of what the user's first screen actually needs. The culprit is almost always the same: a lib/index.ts (or utils/index.ts, or components/index.ts) that re-exports everything in the directory, and an import statement in app.tsx that pulls one helper from it. Webpack, Rollup, esbuild, and Vite all advertise tree-shaking. Tree-shaking does work — most of the time. The cases where it silently doesn't are the ones that quietly inflate every production build until someone notices the Lighthouse score has dropped.
The bundler can only tree-shake an export when three conditions hold simultaneously: the export must be in an ES module (not CJS), the export must be free of top-level side effects, and the consuming code must use a static import (not require() or dynamic import()). The webpack docs cover this in their tree-shaking guide and the sideEffects field section of the spec. Most teams have read those docs. Most teams still have "sideEffects": true (or no sideEffects field at all, which webpack treats as true) in their package.json because writing the correct sideEffects array is tedious and the tooling for verifying it doesn't exist by default.
Why a static analyser, not a webpack plugin
You can wire webpack-bundle-analyzer into your build and see the bundle composition. That tells you what is in the bundle. It doesn't tell you what could be removed if you fixed sideEffects, refactored a barrel file, or replaced a side-effecting import with a tree-shakeable one. It's diagnosis without a treatment.
The tree_shaking_analyzer works statically on the source. It parses the AST, walks every export, traces side effects (top-level function calls, IIFEs, console.log statements, Math.random() invocations, anything observable outside the export's own scope), and produces a per-export verdict: tree-shakeable, side-effecting, or dead. With the optional entryExports parameter it goes further: marking exports unreachable from a named entry as dead-code candidates.
What this article delivers
End-to-end walkthrough of analysing a barrel file, choosing the right sideEffects field for package.json, generating a slim replacement barrel that only re-exports the live + tree-shakeable surface, and reading the dependency graph output. We'll cover the case where a single side-effect import poisons an entire chunk (the most common production cause of failed tree-shaking) and the strategies for recovering bundle size without touching the upstream library.
2. Intent
Intent
What you will be able to do after reading.
By the end of this article you will be able to:
- Run a static analysis on any ESM or CJS module and identify every export's tree-shakeability + side-effect status
- Read the bundle-size impact estimate per dead export to prioritise which to remove first
- Generate the correct
package.jsonsideEffectsfield for a library you maintain - Use entry-simulation mode to mark exports unreachable from your actual entry point as dead candidates
- Output a slim replacement barrel file that drops in as a direct substitute for the bloated original
- Read the Mermaid dependency graph to understand which imports keep which exports alive
The Examples section walks through a real barrel file before and after analysis.
3. Examples
Examples
Annotated code and worked scenarios.
Before / after: analysing a barrel file
Start with this barrel file at src/utils/index.ts:
Before:
export { formatDate, parseDate, addDays } from './dates';
export { Logger } from './logger';
export { fetchWithRetry } from './http';
export { debounce, throttle } from './timing';
export * from './deprecated-stuff';
// At module top level — this is the silent killer
import './polyfills';
import { register } from './analytics';
register();A consumer imports a single helper: import { addDays } from '@/utils';. With this barrel as the entry, the bundler retains:
dates.ts(becauseaddDayslives there) — required, OKpolyfills.ts(top-level import with side effects) — bundledanalytics.ts(top-levelregister()call) — bundled- everything in
deprecated-stuff.ts(theexport *keeps it alive unless the bundler can prove every export is unused — most can't, in practice)
After running the analyser:
treeShakingAnalyzer({
code,
format: 'esm',
entryExports: ['addDays'],
estimateBundleSize: true,
generateBarrel: true,
generateSideEffects: true,
outputGraph: true,
});
// exports: [
// { name: 'formatDate', treeshakeable: true, reachable: false, deadCode: true, estimatedKB: 3.2 },
// { name: 'parseDate', treeshakeable: true, reachable: false, deadCode: true, estimatedKB: 2.1 },
// { name: 'addDays', treeshakeable: true, reachable: true, deadCode: false, estimatedKB: 0.4 },
// { name: 'Logger', treeshakeable: false, sideEffects: true, reachable: false, deadCode: true, estimatedKB: 12.8 },
// { name: 'fetchWithRetry', treeshakeable: true, reachable: false, deadCode: true, estimatedKB: 5.6 },
// { name: 'debounce', treeshakeable: true, reachable: false, deadCode: true, estimatedKB: 1.8 },
// { name: 'throttle', treeshakeable: true, reachable: false, deadCode: true, estimatedKB: 1.4 },
// ],
// bundleSizeReport: {
// totalDeadKB: 26.9,
// totalLiveKB: 0.4,
// savingsPercent: 98,
// summary: "26.9KB of dead code across 6 exports + side-effecting polyfills/analytics imports.",
// }98% of what's in the chunk is dead code. The Logger export has sideEffects: true because it instantiates a singleton at module evaluation — even if you don't use Logger, the bundler can't drop it without that sideEffects flag in package.json.
Before / after: generated sideEffects field
The generateSideEffects: true flag produces the exact configuration for package.json:
{
"sideEffects": [
"./src/polyfills.ts",
"./src/analytics.ts"
]
}sideEffectsField: {
json: '{"sideEffects": ["./src/polyfills.ts", "./src/analytics.ts"]}',
explanation: `
polyfills.ts: has top-level side effect — preserved as side-effect-free=false
analytics.ts: invokes register() at module evaluation — preserved
All other files: pure exports, safe to mark as side-effect-free
`,
}Drop the sideEffects JSON into package.json and the bundler now knows it can drop the entire Logger, fetchWithRetry, debounce, throttle, formatDate, and parseDate chains when they're not imported.
Before / after: generated slim barrel
generateBarrel: true produces the replacement file:
// Generated by tree_shaking_analyzer
// Replaces src/utils/index.ts to drop dead + side-effecting exports
export { addDays } from './dates';
// Removed (dead or side-effecting):
// formatDate, parseDate (dead from this entry)
// Logger (side-effecting top-level singleton)
// fetchWithRetry, debounce, throttle (dead from this entry)
// ./deprecated-stuff (no live re-export)
// Side effects preserved by direct import (do NOT remove these):
// import './polyfills';
// import { register } from './analytics'; register();Diff this against the original; ship the slim version; observe the bundle size drop.
When humans use this
A developer auditing bundle size finds a 30KB chunk they don't recognise. They run tree_shaking_analyzer against the chunk's entry barrel with entryExports set to what their app actually imports. The output tells them which 90% to remove and how. The Mermaid dependency graph in outputGraph: true lets them see at a glance which imports keep which exports alive — useful when the dead-code chain is deep (A imports B imports C imports D keeps D alive even though nothing uses D directly).
When agents use this
Two production patterns:
- Refactor agent removing a deprecated module. Agent is asked to "remove the deprecated logger from this codebase". It runs the analyser with
entryExports: ['everything-the-app-actually-uses']to identify which exports are dead candidates, then walks the dead exports and proposes deletion PRs with the analyser's output as the justification. The agent doesn't have to interpret "is this dead?" — the analyser gives a deterministic answer. - Library-maintenance agent enforcing sideEffects. A scheduled agent runs against the project on every PR that touches the
src/tree. It re-generates thesideEffectsfield via the analyser, compares againstpackage.json, and posts a PR comment if they diverge. This catches the case where a new import quietly breaks tree-shaking — the cause of nearly every silent bundle-size regression in shipping JS libraries.
Edge cases
Re-exports through multiple barrel layers
export * from './a'; where ./a itself does export * from './b'; chain through correctly. The analyser walks the re-export graph to the leaves. If a leaf module has a side effect, the analyser flags the whole chain — even if only one export in the leaf is reached.
Dynamic imports
import('./module') returns a Promise and is opaque to static analysis. The analyser does not treat the dynamic-import target as reachable from the calling export. If your code uses dynamic imports for code-splitting, supply entryExports for each split entry separately and union the results.
CommonJS
The analyser supports format: 'cjs' for CommonJS modules. Tree-shaking on CJS is structurally weaker — most bundlers can't reliably tree-shake CJS because require() returns a value at runtime. The analyser surfaces this with a top-level warning: "CJS format — tree-shaking results are advisory." For the most accurate results, convert the module to ESM first.
Class exports vs function exports
A class export is tree-shakeable if and only if its constructor does not invoke top-level side effects. A class that initialises a singleton in its constructor (a common pattern) is flagged sideEffects: true because instantiating the class has effects observable outside the class itself.
4. Documentation
Documentation
Reference signatures, edge cases, and lookup tables.
Input parameters
Field | Type | Required | Default | Description |
|---|---|---|---|---|
|
| ✓ | — | The module source code |
|
| ✓ | — | Module format. |
|
| ✗ | — | Names of exports actually used by the consumer. Marks unreachable exports as dead |
|
| ✗ |
| Output a slim barrel file with only tree-shakeable + reachable exports |
|
| ✗ |
| Include estimated KB per export based on AST size |
|
| ✗ |
| Output the recommended |
|
| ✗ |
| Output a Mermaid dependency graph |
Output shape
{
exports: Array<{
name: string;
sideEffects: boolean;
treeshakeable: boolean;
reachable?: boolean; // only when entryExports provided
deadCode?: boolean; // only when entryExports provided
estimatedKB?: number; // only when estimateBundleSize: true
}>;
sideEffects: boolean; // module-level — true if ANY export has side effects
recommendations: string[];
deadExports?: string[];
barrelFile?: string;
bundleSizeReport?: {
totalDeadKB: number;
totalLiveKB: number;
savingsPercent: number;
summary: string;
};
sideEffectsField?: {
json: string; // ready-to-paste package.json snippet
explanation: string;
};
dependencyGraph?: string; // Mermaid diagram source
}Recommendations the analyser produces
Trigger | Recommendation |
|---|---|
Top-level | "Remove or move into a function. Top-level logging blocks tree-shaking." |
Top-level singleton instantiation | "Defer construction with a lazy initialiser ( |
| "Replace with named exports to allow partial tree-shaking of the source module." |
Class with side-effecting constructor | "Split into a data class (pure) and an explicit |
Error codes
Code | When it fires | Recovery |
|---|---|---|
|
| Provide non-empty source |
|
| Analyse one barrel at a time; the tool isn't built for full-codebase scans |
| AST parser failed (syntax error in source) | Fix the syntax error first; the analyser needs valid JS/TS |
| TypeScript with unusual decorators or experimental features | Compile to plain JS first, or use the recommended TypeScript subset |
When NOT to use this tool
If you're optimising a fully-bundled output file (already minified, already tree-shaken by webpack), this analyser doesn't help — the AST it works on is the source, not the artefact. For bundle inspection, use webpack-bundle-analyzer or rollup-plugin-visualizer. The two tools are complementary: tree_shaking_analyzer audits the source to find why tree-shaking didn't kick in; bundle analysers tell you what made it to the output.
The bundle-size estimates are estimates based on AST size — they don't account for downstream bundler effects (minification, scope-hoisting, dynamic-import boundary placement). Treat the numbers as relative priorities, not absolute byte counts. The savings percentage is more reliable than the individual KB figures.
Performance notes
Typical analysis: 5-50ms depending on module size. The AST parse dominates for files over 10KB. The optional outputGraph adds 2-5ms regardless of module size. The tool is deterministic — same input always produces the same output — so REST results are Edge-Cache eligible. The Mermaid output is text, not an image; on the web UI it renders as an interactive graph, in MCP responses it's returned as a Mermaid code block for the agent to render or reason about structurally.