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.

sideEffects false lets the bundler drop unused modules Without sideEffects the bundler assumes each module may have a side effect and keeps them all; with sideEffects false it may drop any module whose exports are unused, and with an array it keeps only the listed files like CSS while dropping the rest. Give the bundler permission to prune no sideEffectsassume all impurekeep everything sideEffects: falseprune unused exportssmallest consumer sideEffects: ["*.css"]keep CSSprune the rest
Figure: the field ranges from "keep everything" (default) to "prune everything unused" (false), with the array as the safe middle.

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.

Without sideEffects the whole library is kept In the sample library a consumer imports only module a, but with no sideEffects field the bundler keeps module b and its CSS import too because it must assume every module might have a side effect. import a only no sideEffects fieldassume all impure b + CSS kept importing one module still ships the rest
Figure: with no declaration, importing one export drags the whole library along.

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

  1. Confirm the module survives despite being unused. Import only a in a test app, build, and check the output still contains b’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 quick grep -c for a string unique to b in 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, so b will always appear there and prove nothing. Confirm the fix the same way you confirmed the symptom — same grep, expecting zero.
  2. 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', a window.__foo = …, a customElements.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.
  3. 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: false is 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 and false is a latent bug waiting for the first stylesheet.
Does the module do something on import? If a module only defines and exports values it is pure and can be pruned when unused, but if importing it runs code such as loading CSS, registering a polyfill or mutating a global it has a side effect and must be listed so it is kept. runs code on import? no → prunable (false) yes → list it (keep)
Figure: the single question that decides the field — does importing the module do anything?

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"
  }
}
Declaration plus ESM output are both required The sideEffects field gives the bundler permission to prune, but the library must also ship ESM output so the bundler can statically analyze which exports are unused; a CJS-only build defeats the field. sideEffects declaredpermission to prunefalse or array ESM outputstatic analysis worksCJS defeats it both halves — permission and a shape the bundler can read
Figure: 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
Unused export absent, listed CSS still emitted when used A correct declaration leaves the unused export b absent from the consumer's bundle while any CSS the consumer actually imports is still emitted, confirming pruning removes only genuinely unreferenced code. unused b: absentpruned used CSS: emittedkept correctly pruning removes unused, keeps what is referenced
Figure: correct behaviour is unused code gone and referenced side effects preserved.

Gotchas & edge cases

Four sideEffects edge cases A blanket false on a package with CSS imports drops the styles; a relative path in the array must match how the file is referenced; CJS output defeats the field; and a re-export barrel can reintroduce a side effect if it pulls in an impure module. false but has CSS→ styles silently dropped path mismatch in array→ match the referenced path CJS output→ ship ESM to tree-shake barrel re-export→ keep barrels pure
Figure: a blanket false on a package with CSS is the classic mistake — the styles vanish from consumers.
  • false with CSS imports. A blanket false tells 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 from false to 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) or src/polyfills.js (wrong base) fails where ./src/polyfills.js or **/polyfills.js matches. 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: your exports map resolved the bundler to the .cjs build, so require-shaped code reached the bundler and static analysis never engaged. Fix: ensure the import condition points at real ESM and that your ESM output uses import/export, not a transpiled require shim. Confirm by inspecting node_modules/my-lib/dist/index.mjs in a consumer install — it must contain export statements, not module.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 false still ships modules a consumer never touched. Root cause: the barrel export *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.