Auditing Rollup Bundles to Remove Unused ES Module Exports
A practical audit for Rollup bundles: identify exports that survive tree shaking, decide between moduleSideEffects and /*@__PURE__*/ annotations, and verify the result without breaking side-effect code.
03 Dec 2025, 15:55 UTC

The Problem: Unused Exports Surviving the Bundle
You import a single helper from a local module, but the production bundle still contains every other export from that file. Rollup performs tree shaking through static analysis of ES module import and export statements, yet it conservatively retains any module it believes might produce side effects. The practical takeaway: before changing global configuration, confirm which specific exports remain in the output, then apply the narrowest assumption that removes them without deleting code that runs on import.
Prerequisites and Version Assumptions
- Node.js and npm installed, with a project initialized via
npm init -y. - Rollup installed as a dev dependency:
npm i -D rollup. Behavior described here reflects general Rollup tree-shaking semantics; exact option handling can vary by major version, so confirm details against the documentation for your installed release. - Source files authored as ES modules using
importandexport. CommonJSrequire()andmodule.exportsare evaluated at runtime and resist static analysis. - A command-line search tool such as
grepavailable in your shell.
Step 1 — Create a Baseline Module and Entry Point
Start with a small ES module that exports multiple named functions, and an entry point that uses only one of them.
// src/format.js
export const formatDate = (d) => d.toISOString();
export const formatCurrency = (n) => `$${n}`;
// src/entry.js
import { formatDate } from './format.js';
console.log(formatDate(new Date()));
Because formatCurrency is never imported, it is a candidate for removal. If your bundle already contains similar dead code, use that real module instead of this example.
Step 2 — Build and Search the Output
Create a minimal configuration that emits an ES module bundle. Run the build from the project root where rollup.config.js lives:
// rollup.config.js
export default {
input: 'src/entry.js',
output: {
file: 'dist/bundle.js',
format: 'esm'
},
treeshake: {
moduleSideEffects: false
}
};
# Run from the project root; no elevated permissions required
npx rollup -c
After the build completes, search the output file for the unused symbol. From the project root:
grep -n "formatCurrency" dist/bundle.js
If the string is absent, static analysis removed the export. If it is present, Rollup retained the module or the function for a reason described below.
Step 3 — Diagnose Why an Export Remained
When an unused export survives, the cause is usually one of the following. Match your situation before adjusting configuration.
| Situation | Effect on tree shaking | Practical implication |
|---|---|---|
Module uses CommonJS (require, module.exports) | Dependency graph is not statically analyzable | Prefer an ESM build of the library; if impossible, expect coarser pruning via @rollup/plugin-commonjs |
Top-level global mutation (e.g., window.x = 1) | Module is marked as having side effects | The entire module is retained; refactor to move mutation inside an invoked function if safe |
Dynamic import(path) | Target is unknown at build time | The module is kept; consider explicit code-splitting if the goal is lazy loading |
| Complex object mutation or computed access | Analyzer cannot prove safety | Rollup errs on the side of retention; simplify the pattern or annotate specific calls |
Step 4 — Choose the Narrowest Intervention
If the dead code is in your own ES module and you are confident no top-level code runs on import, moduleSideEffects: false tells Rollup to assume modules are side-effect free unless proven otherwise. This is aggressive: it can remove CSS imports, polyfills, or registration calls that execute merely by being imported.
When only a specific call is suspect—such as a constructor or factory whose return value is discarded—use a pure annotation instead of changing the global assumption:
// src/entry.js
import { formatDate, formatCurrency } from './format.js';
// If the return value is unused and the call is truly pure,
// Rollup can drop the call entirely.
const unused = /*@__PURE__*/ formatCurrency(99);
console.log(formatDate(new Date()));
Use this annotation only when you are certain the function has no observable side effects. Mislabeling an impure call as pure will silently delete required behavior.
Expected Checks
- String search: Re-run
grep -n "formatCurrency" dist/bundle.jsafter each configuration change. Physical absence of the symbol is stronger evidence than file size, which minification can obscure. - Size comparison: Temporarily set
treeshake: { moduleSideEffects: true }(or remove the property), rebuild, and compare the byte size ofdist/bundle.js. A smaller file withfalseindicates removed code, but does not prove the removed code was the intended target. - Bundle visualizer: Add
rollup-plugin-visualizerto thepluginsarray, rebuild, and open the generated HTML report. Confirm that the unused module or export does not appear as a contributing chunk. Treat the report as a map, not a pass/fail test.
Limitations and Risks
- CommonJS boundaries: Modules transformed by
@rollup/plugin-commonjsoften lose granular ES-module structure. Rollup may treat the transformed module as a single unit, preventing function-level removal. - Output format does not create tree-shakeability: ESM and SystemJS outputs preserve the shaken module graph; IIFE and UMD wrap the already-pruned result for browser globals. The format affects packaging, not what the analyzer can remove.
- Version sensitivity: The exact set of recognized side-effect patterns and the handling of
moduleSideEffectscan differ between Rollup releases. If behavior does not match expectations, check the changelog and configuration reference for your installed version before assuming a bug. - No automated correctness guarantee: Tree shaking is a build-time optimization. Removing code that a runtime path actually needs will not be caught by the bundler; it surfaces as a runtime error.
Recovery Options
Changing moduleSideEffects alters build output. If the application fails because a needed polyfill, style import, or registration side effect was removed, revert the assumption in rollup.config.js:
// Change this:
treeshake: {
moduleSideEffects: false
}
// To this:
treeshake: {
moduleSideEffects: true
}
// Or delete the treeshake property entirely to restore default conservative behavior.
Rebuild with npx rollup -c and retest the application. If only one module was the problem, a more targeted fix is to keep the global assumption conservative and annotate only the specific pure calls you control.
Verification Summary
There is no single command that proves tree shaking is both aggressive and safe. The reliable workflow is: search the bundle for the unused symbol, compare sizes across configurations, inspect a visualizer report for unexpected modules, and run the application. If the unused symbol is gone and the application behaves correctly, the configuration is working for that case. If the symbol remains, use the diagnostic table to identify the structural blocker before changing options again.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.