obfus.link
Analyzers

Dead exports: from detection to package.json sideEffects

Static AST analysis of any ESM or CJS module identifies dead exports, side-effecting top-level code, and the bundle-size impact of each finding. Generates a slim replacement barrel file and the correct package.json sideEffects field.

The Tree-Shaking Analyzer parses any ESM or CJS module and identifies which exports are tree-shakeable, which carry side effects, and which are dead from a given entry point. It estimates bundle-size impact per dead export and generates the correct package.json sideEffects field.

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.json sideEffects field 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 (because addDays lives there) — required, OK
  • polyfills.ts (top-level import with side effects) — bundled
  • analytics.ts (top-level register() call) — bundled
  • everything in deprecated-stuff.ts (the export * 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 the sideEffects field via the analyser, compares against package.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

code

string

The module source code

format

'esm' | 'cjs'

Module format. cjs produces advisory results

entryExports

string[]

Names of exports actually used by the consumer. Marks unreachable exports as dead

generateBarrel

boolean

false

Output a slim barrel file with only tree-shakeable + reachable exports

estimateBundleSize

boolean

false

Include estimated KB per export based on AST size

generateSideEffects

boolean

false

Output the recommended package.json sideEffects field

outputGraph

boolean

false

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 console.log

"Remove or move into a function. Top-level logging blocks tree-shaking."

Top-level singleton instantiation

"Defer construction with a lazy initialiser (let _instance; function get() { _instance ??= new X(); return _instance; })."

export * from with a side-effecting source

"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 initialize() function (side-effecting)."

Error codes

Code

When it fires

Recovery

INPUT_EMPTY

code empty

Provide non-empty source

INPUT_TOO_LARGE

code exceeds 500KB

Analyse one barrel at a time; the tool isn't built for full-codebase scans

PARSE_FAILED

AST parser failed (syntax error in source)

Fix the syntax error first; the analyser needs valid JS/TS

UNSUPPORTED_LANGUAGE

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.

Try it now

Tree-Shaking Analyzer

Find dead exports, estimate bundle savings, generate sideEffects field

FAQ

Frequently asked questions

Does this replace webpack-bundle-analyzer?

No, they're complementary. webpack-bundle-analyzer tells you what made it into the bundled output. This tool audits the source to find why tree-shaking didn't kick in. Use both for full visibility.

Why does my class export show sideEffects: true?

Almost always because the constructor instantiates a singleton or invokes side-effecting code at class evaluation. Split the class into a pure data class and an explicit initialise() function, and the analyser will mark the data class as tree-shakeable.

How accurate are the bundle-size estimates?

The KB figures are based on AST size and don't account for downstream effects like minification or scope-hoisting. Treat them as relative priorities, not absolute byte counts. The savings percentage is more reliable than individual KB values.

Can it analyse CommonJS modules?

Yes, with format: 'cjs'. Tree-shaking on CJS is structurally weaker because require() returns a runtime value — the analyser surfaces this with a top-level advisory. For the most accurate results, convert to ESM first.