Marking a Package Side-Effect-Free with sideEffects
A bundler will only drop an unused module if it can prove importing it has no side effect — and by default it must assume every module might, so it keeps them all. The sideEffects field in package.json is how a library author gives that proof, and getting it wrong either bloats consumers or silently drops CSS. This guide sets it correctly. It applies the tree-shaking mechanics these guides cover to the library-author side; the parent overview is Core Concepts of Modern Bundling.
The reason the default is conservative is that a module’s top level can run arbitrary code the moment it is imported. import './register-worker.js' might attach an event listener; import './polyfill.js' might patch Array.prototype; import './theme.css' might inject a stylesheet. If the bundler removed any of those because their exports were unused, the program would change behaviour — a listener would never fire, a method would be missing, a page would render unstyled. A bundler cannot in general tell from static analysis whether a given top-level statement is observable, so the safe assumption is that it is. That single assumption is why an unmarked library ships in full even when a consumer imports one function from it: the bundler is not being lazy, it is refusing to guess.
sideEffects inverts that assumption for your package. Setting it tells every consuming bundler — Rollup, Vite, webpack, esbuild via its Rollup-compatible metadata — that the modules you name (or, with false, all of them) can be evaluated purely for their exports, and that if none of those exports are referenced the module may be deleted entirely, top-level statements included. This is the difference between a library whose consumers pay only for what they import and one where a single named import drags in the whole surface area. The field is small, but it sits directly on the boundary between your build and everyone else’s, so a mistake here is a mistake that ships to every downstream bundle at once. That is what makes it worth getting exactly right rather than copying "sideEffects": false from another package and hoping.
Where this fits the pipeline: sideEffects is consumed during the bundler’s module-graph construction and mark-and-sweep pass, before minification. The bundler resolves your package’s entry, walks its imports, and for each module checks the field to decide whether an unreferenced module is a candidate for removal. Minifiers like Terser or esbuild’s minifier then do a second, statement-level dead-code pass inside the modules that survive. The two stages are complementary: sideEffects decides which whole modules live or die; the minifier trims dead branches within the survivors. Neither can substitute for the other, which is why a package with perfect minification but no sideEffects field still over-ships.
Prerequisites & reproducible setup
# Rollup 4 / Vite 5.4.x consumer, a library you publish
mkdir my-lib && cd my-lib && npm init -y
mkdir src
printf "export const a = () => 'a';\n" > src/a.js
printf "export const b = () => 'b';\nimport './styles.css';\n" > src/b.js
printf ".x{color:red}\n" > src/styles.css
printf "export * from './a.js';\nexport * from './b.js';\n" > src/index.js
A consumer importing only a from this library should not ship b or its CSS side effect — but by default the bundler keeps both because nothing told it a is pure.
The sample is deliberately minimal but structurally faithful to a real library: a flat entry (index.js) that re-exports from leaf modules, one pure leaf (a.js), and one impure leaf (b.js) whose top level imports a stylesheet. This is the shape that most component libraries, icon sets, and utility packages actually ship — a barrel entry over many small modules, some of which touch CSS or globals. If you can make the bundler prune b here while keeping a, the same declaration scales to a package with two hundred modules. Reproduce it before changing anything: you want a failing baseline you can point a grep at, so that when you add the field you can prove the pruning happened rather than assuming it. Throughout this guide the versions pinned in the comments (Rollup 4, Vite 5.4.x) are the ones the behaviour was checked against; the field itself is stable across those, but the exact minified output names differ, which is why the verification step greps for source strings rather than symbol names.
How it works under the hood
When a bundler builds the module graph, each module gets a boolean the internals usually call moduleSideEffects. Rollup seeds that flag from three inputs, in priority order: an explicit treeshake.moduleSideEffects option in the consumer’s config, the resolving plugin’s resolveId return value (this is where @rollup/plugin-node-resolve reads your package.json), and finally the default of true. The node-resolve plugin is the piece that actually parses sideEffects: it takes the field, expands any globs against the package root, and for every module it resolves inside your package it sets moduleSideEffects to false unless the module’s path matches an array entry (or the field is the literal true). So sideEffects never reaches the bundler core directly — it is interpreted by the resolver and handed to the graph as a per-module boolean.
Glob matching is the part people get wrong, because it is path matching, not name matching. An entry like "**/*.css" is resolved relative to the directory containing package.json and matched against the absolute path of each resolved module. A leading ./ anchors to the package root; a bare *.css without a directory glob only matches CSS at the top level, which is why **/*.css is the safe form for nested files. Once a module is flagged pure, the mark phase treats it as a removal candidate: if the sweep finds no live reference to any of its exports, the whole module — including its top-level import './styles.css' — is dropped from the graph before it is ever written. That is exactly the behaviour you want for a pure module and exactly the disaster you must prevent for an impure one, and the only lever between the two outcomes is whether the path matched an array entry.
There is a subtlety worth internalising: a module flagged pure can still be kept if one of its exports is referenced, and when it is kept its side effects run. sideEffects does not mean “never has side effects”, it means “has no side effects worth preserving on its own account” — the effects are allowed to ride along only when the exports are wanted. This is why the array form is not about which files are special but about which files must survive even when nothing imports their exports. CSS is the canonical case: a stylesheet has no exports at all, so a pure flag would make it always prunable, and it would vanish the moment tree-shaking ran.
Diagnosis workflow
- Confirm the module survives despite being unused. Import only
ain a test app, build, and check the output still containsb’s code — that is the symptom. The tell is that a bundle importing a single function is roughly the size of the whole library; a quickgrep -cfor a string unique tobin the output either returns a non-zero count (the bug) or zero (already pruned). Do this against a production build, not the dev server: Vite’s dev server serves unbundled ESM and never runs the sweep, sobwill always appear there and prove nothing. Confirm the fix the same way you confirmed the symptom — samegrep, expecting zero. - Decide what actually has side effects. A module that imports CSS, registers a polyfill, or mutates a global on import does have a side effect and must not be pruned. The reliable test is to read each module’s top level and ask whether any statement is observable when the module is evaluated but none of its exports are used: a bare
import './x.css', awindow.__foo = …, acustomElements.define(...), a call that pushes onto a shared registry. Pure modules only declare — functions, classes, constants — and defer all work until something calls them. If you are unsure about a module, treat it as impure and list it; a falsely-kept module costs bytes, a falsely-pruned one costs correctness, and the second failure is far harder to notice in review. - Choose false vs an array. If nothing in the package has a side effect,
false. If only CSS (or a few files) do, list them so those are kept and everything else is prunable. The decision is not stylistic:falseis a promise about every current and future file in the package, so it only holds if you have discipline to keep it holding as the package grows. The array is a promise about a named set and degrades gracefully — a new pure module is prunable automatically, a new impure one simply needs adding to the list. For a package that ships any CSS at all, the array is the correct default andfalseis a latent bug waiting for the first stylesheet.
Annotated solution config
// package.json — the library's declaration. Two correct shapes.
// Shape A: nothing in the package has an import side effect.
{
"name": "my-lib",
"sideEffects": false
}
// Shape B: only CSS (and a polyfill entry) run code on import.
{
"name": "my-lib",
"sideEffects": [
"**/*.css",
"./src/polyfills.js"
]
}
The two shapes are not interchangeable and the choice is a design decision, not a preference. Shape A is the strongest possible signal and produces the smallest consumer bundles, but it is only true if you can guarantee no module in the package ever runs code on import — no CSS, no polyfills, no auto-registration, now or later. Shape B trades a little verbosity for a promise you can actually keep: it names the exact files that must survive pruning and lets everything else be dropped. Note that Shape B still allows every other module to be pruned exactly as aggressively as false would; the array does not make the package “less tree-shakable”, it only rescues the listed files from being deleted when their exports go unreferenced. A common and correct pattern for a component library is ["**/*.css", "./src/polyfills.js"] — CSS by glob, the one imperative entry by exact path — with every component module left implicitly pure.
// tsup.config.ts / rollup output — ship ESM so consumers can tree-shake.
// A CJS-only build defeats sideEffects because named exports cannot be
// statically analyzed the same way.
{
"format": ["esm"],
"treeshake": true
}
The ESM requirement is not a formality. Tree-shaking depends on the static, link-time nature of ES module import/export: the bundler can see, without executing anything, exactly which bindings a consumer references and which it does not. CommonJS require is a function call that returns an object, so a bundler cannot statically know whether pkg.a is used without evaluating pkg, and it will not risk deleting pkg.b on a guess. Setting sideEffects on a package that only ships CJS is therefore close to inert — the field grants permission the module format never lets the bundler act on. If your build emits both, make sure your package.json exports map points bundlers at the ESM condition ("import": "./dist/index.mjs") so the CJS build is reserved for require-based Node consumers and never becomes what a bundler sees.
// package.json — point bundlers at ESM and keep CJS for require() only.
// exports order matters: bundlers read "import"; Node's require reads "require".
{
"type": "module",
"sideEffects": ["**/*.css"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css"
}
}
sideEffects is necessary but not sufficient — the package must also ship analyzable ESM.Verification
# Consumer app — import one export, build, confirm the other is gone.
printf "import { a } from 'my-lib'; console.log(a());\n" > app.js
npx vite build # or: rollup -c
grep -c "=> 'b'" dist/assets/*.js # expect 0 — b was pruned
grep -c "color:red" dist/**/*.css # expect 0 if b's CSS was unused
With sideEffects set and the consumer importing only a, b’s code is absent from the output. If you used the array form, the CSS listed there is still emitted when actually imported, but nothing unreferenced survives.
Grep against source strings, not minified identifiers. After minification a and b may be renamed to single letters, so searching for a symbol name is unreliable; a unique source fragment like the arrow body => 'b' survives minification well enough to be a stable probe, and a CSS declaration like color:red survives into the emitted stylesheet verbatim. Two independent checks matter here: the first confirms the unused JavaScript module is gone, the second confirms you did not over-prune and delete CSS a consumer actually depends on. A green result on both is the only proof that the field is doing what you think — a bundle that got smaller could have shrunk for unrelated reasons, and a bundle that kept its CSS could have kept it by accident. Wire both greps into the check so the result is a boolean, not an eyeball.
For a repeatable gate, fail the build when the unused module leaks. This is the same probe as above, turned into an exit code so CI catches a regression the day someone adds an accidental top-level side effect to a supposedly pure module:
# CI guard — non-zero exit if the pruned module reappears in the bundle.
# Run after `vite build` (or `rollup -c`) in the consumer fixture.
if grep -rq "=> 'b'" dist/; then
echo "regression: unused module 'b' survived tree-shaking" >&2
exit 1
fi
Gotchas & edge cases
false on a package with CSS is the classic mistake — the styles vanish from consumers.falsewith CSS imports. A blanketfalsetells the bundler your CSS import is prunable, so styles silently disappear. Symptom: the component renders but is unstyled in a consumer that imports it, while your own demo app — which imports the CSS through a different path — looks fine. Root cause: the stylesheet has no exports, so a pure flag makes it unconditionally removable, and the sweep drops it. Fix: switch fromfalseto an array that lists**/*.css. Confirm by grepping the consumer’s emitted CSS for a known declaration; it should now be present. This is the single most common way the field ships a bug, precisely because the failure is visual and never throws.- Path form must match. Array entries are matched against the module path as referenced, so a wrong relative form means the file is not exempted and gets pruned. Symptom: you listed a polyfill in the array but it still vanishes from consumers. Root cause: the glob did not match —
polyfills.js(no anchor) orsrc/polyfills.js(wrong base) fails where./src/polyfills.jsor**/polyfills.jsmatches. Fix: anchor with./from the package root or use**/to match at any depth. Confirm by temporarily setting the field to just that one entry and checking the file survives a build that imports nothing from it. - CJS defeats it. Ship ESM; a CommonJS build cannot be statically tree-shaken the same way regardless of
sideEffects. Symptom: the field is set correctly, the module format looks fine locally, yet consumers still get the whole package. Root cause: yourexportsmap resolved the bundler to the.cjsbuild, sorequire-shaped code reached the bundler and static analysis never engaged. Fix: ensure theimportcondition points at real ESM and that your ESM output usesimport/export, not a transpiledrequireshim. Confirm by inspectingnode_modules/my-lib/dist/index.mjsin a consumer install — it must containexportstatements, notmodule.exports. - Impure barrels. A re-export barrel that pulls in a side-effectful module reintroduces the effect; keep barrels pure or list the impure file. Symptom: a package marked
falsestill ships modules a consumer never touched. Root cause: the barrelexport *s from an impure module, so importing anything through the barrel evaluates the impure module’s top level, and the bundler keeps it to preserve the effect. Fix: keep barrels as pure re-export lists and route impure initialisation through an explicit side-effect import the consumer opts into, or list the impure file in the array so its status is honest. Confirm by importing one leaf directly (bypassing the barrel) and checking the impure module no longer appears. See the dedicated treatment in the Related list below.
When false is the wrong choice
Reach for the array, not false, whenever the package contains anything that must run to be correct: CSS, a polyfill that patches built-ins, a module that calls customElements.define, a global store that self-registers, or any generated code you do not fully control. false is a package-wide, forever promise, and the cost of breaking it is a silent correctness bug in someone else’s build that you will hear about as a vague “styles are missing” report weeks later. The array costs a few lines and a moment’s thought per new impure file, and its failure mode is loud and local — the listed file is either matched or not, and you can grep for it. Use false only for genuinely pure packages: a set of pure functions, a types-only package, a tree of components that import their CSS through a build step that already lists it. When in doubt, the array is the position a reviewer can verify; false asks them to trust the whole package’s future.
How webpack and Rollup differ here
The field originated in webpack and Rollup adopted it, so the semantics are compatible but the plumbing differs. Webpack reads sideEffects natively in its own module factory and applies it during the optimization.sideEffects pass (on by default in production mode). Rollup has no built-in package.json reader; it relies on @rollup/plugin-node-resolve to translate the field into the moduleSideEffects flag on each resolved module, which means a Rollup or Vite setup that resolves your package through some other plugin might not honour the field at all. Both engines interpret false, true, and a string-array of globs the same way, and both treat the array as an allowlist of files to keep. The practical consequence: test your published package against both a webpack consumer and a Vite/Rollup consumer if your audience spans both, because a field that works in one toolchain’s resolver can be quietly ignored by a misconfigured other. esbuild reads the same field via its bundler and applies it during its own tree-shaking, so the declaration is portable, but esbuild’s pruning is less aggressive than Rollup’s on some re-export patterns, which is another reason to verify rather than assume.
Relationship to /*#__PURE__*/ annotations
sideEffects operates at module granularity; the /*#__PURE__*/ annotation operates at call granularity, and the two solve adjacent problems. When a module’s top level contains export const icon = createIcon('star'), the bundler cannot know whether createIcon has a side effect, so it keeps the call — and the module — even if icon is unused. Prefixing the call with /*#__PURE__*/ createIcon('star') tells the minifier the call is safe to drop when its result is unreferenced, which lets a module marked pure actually be pruned instead of being pinned alive by an unanalyzable initializer. Library build tools like tsup and Rollup can inject these annotations, and many component-generation setups depend on them. The rule of thumb: sideEffects gives the bundler permission to delete whole modules, and /*#__PURE__*/ gives the minifier permission to delete the top-level calls that would otherwise keep those modules from qualifying. A package that ships computed constants at module scope usually needs both to prune cleanly.
Related
- Tree-shaking mechanics and dead code elimination — the parent cluster on how pruning works.
- Eliminating barrel-file side effects in tree-shaking — the barrel problem this field interacts with.
- Bundle analysis and size budgeting — measuring the bytes correct pruning removes.