The sideEffects Contract: Tuning Rollup Tree-Shaking for Library Builds
Tree-shaking in Rollup depends on a precise sideEffects contract. Learn how ESM boundaries, named exports, and visualizer audits keep library bundles lean.
18 Jan 2026, 07:56 UTC

When One Import Pulls In the Whole Belt
You publish a utility library with dozens of small functions. A consumer imports a single formatter, yet their production bundle balloons. The culprit is rarely Rollup's tree-shaking engine itself; it is usually a mismatch between how your package declares its side effects and what Rollup's static analyzer can safely remove.
Tree-shaking in Rollup is not a plugin you add. It is a core behavior driven by the static structure of ES Modules (ESM). Because import and export statements must appear at the top level, Rollup can build a dependency graph without executing your code. That graph lets it drop exports that are never referenced. But the analyzer can only prune what it can prove is safe.
Static Analysis and Its Boundaries
ESM gives Rollup a reliable map of what depends on what. CommonJS (CJS) does not. A require() call can sit inside a conditional, a loop, or a computed path, so Rollup cannot guarantee a CJS module is unused. The result is conservative inclusion: the entire module stays.
Dynamic import() expressions introduce a similar boundary. Rollup treats them as async chunk boundaries rather than static dependencies, which limits how much it can prune from the imported module. If your library uses dynamic imports for lazy loading, expect less aggressive dead-code elimination in those paths.
The sideEffects Contract
Rollup's static analysis has a blind spot: side effects. A module that patches Array.prototype, assigns to window, or registers a polyfill is doing work beyond exporting values. If no import references that module, Rollup may delete it entirely—unless your package.json tells it not to.
The sideEffects field is that signal. Setting it to false declares that no module in your package has observable side effects, allowing maximum pruning. If that is too broad, provide an array of glob patterns or file paths that must be preserved.
Be precise. Listing false for a package that patches globals will produce a smaller bundle that fails at runtime. Conversely, omitting the field or listing every file disables most tree-shaking.
Worked Example: Declaring the Contract
Suppose your library exports pure utilities but also ships a file that installs a global polyfill. You want the utilities shaken when unused, but the polyfill must survive even if no one imports it directly.
{
"name": "acme-utils",
"version": "2.1.0",
"type": "module",
"sideEffects": [
"./src/polyfill-globals.js",
"**/*.css"
]
}
This tells Rollup: everything is side-effect free except the polyfill and CSS side-effect imports. A consumer importing only formatDate will not drag in the rest of your utility surface.
Export style matters too. A default object export like export default { a, b, c } is opaque to the analyzer; Rollup generally cannot drop individual methods from it. Prefer named exports:
// Prefer this
export function a() {}
export function b() {}
// Over this
export default { a, b };
If you must keep an object API, you can re-export its members as named exports to restore shakeability:
import utils from './utils.js';
export const { a, b } = utils;
Verifying the Output
Never trust the build log. Verify the artifact.
- Build for production. Run your standard production build (for example,
npm run buildorrollup -c) in the project root. Ensure sourcemaps are enabled so you can trace retained code. - Inspect the treemap. Add
rollup-plugin-visualizeras a dev dependency and include it in yourrollup.config.jsplugins array. Open the generatedstats.htmland look for modules you expected to be removed. Large rectangles for unused utilities indicate the analyzer kept them. - Search for unique strings. Open the output bundle and search for a distinctive function name or string literal from a module you believe was dropped. If it appears, tree-shaking did not reach that module.
- Test in a browser. Load the bundle in a real browser environment. A bundle that looks correct but throws because a polyfill or prototype patch was stripped means your
sideEffectsdeclaration was too aggressive.
Because plugin APIs and Rollup internals evolve, confirm the current rollup-plugin-visualizer configuration options against its latest documentation before relying on specific output formats.
Trade-offs and Limitations
- CJS dependencies. If your library depends on legacy CJS packages, Rollup will likely include them wholesale. Migrating internal code to ESM helps, but third-party CJS remains a hard boundary.
- Dynamic imports. Code loaded via
import()is split into separate chunks and is harder to tree-shake. Use it for genuinely lazy code, not as a general dependency mechanism. - Global mutations. Beyond prototypes, assignments to
window,globalThis, or third-party globals must be declared insideEffects. Missing them creates silent runtime failures. - Over-aggressive pruning. Automatic removal without auditing can strip code that runs at import time. The visualizer and string-search workflow above catches this before release.
Actionable Next Step
Audit your package.json today. If sideEffects is missing or set to false while your code patches globals, fix the array first. Then convert default object exports to named exports, run a production build with sourcemaps, and confirm with the visualizer that only the code your consumers import survives.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.