Without a sideEffects declaration in your package.json, bundlers must assume every file in your package could mutate global state, register event listeners, or inject CSS — so they keep every reachable module even when the consumer only imports one function. The result is dead code that survives even aggressive tree-shaking passes. On Webpack 5 and Rollup 3+, adding "sideEffects": false to a pure utility library can cut consumer bundles by 30–70% for partial imports. Getting the field wrong in the other direction — declaring modules pure when they are not — silently drops stylesheets and breaks runtime initialization at build time, with no error thrown.

Prerequisites

How Bundlers Use the sideEffects Field

sideEffects processing pipeline across Webpack, Rollup/Vite, and esbuild Three bundler lanes showing where each reads sideEffects (or ignores it) during module graph construction, purity analysis, and dead code elimination. Webpack 5 Rollup / Vite esbuild 1. Module graph construction (follow import/export edges) 2. optimization.sideEffects reads package.json ✓ 3. DCE + scope hoisting prunes flagged modules sideEffects: respected ✓ 1. Module graph construction (follow import/export edges) 2. treeshake pass reads package.json ✓ 3. Output generation unused exports removed sideEffects: respected ✓ 1. Module graph construction (follow import/export edges) 2. --tree-shaking=true ignores package.json ✗ 3. Pure annotations only /*#__PURE__*/ required sideEffects: ignored ✗

Bundlers rely on static analysis to determine whether a module can be pruned. In ESM, import and export statements are statically analyzable, so tools can build deterministic dependency graphs. CJS (require()) evaluates dynamically at runtime, so static guarantees are impossible — when a consumer resolves to the require path in your dual-format package, sideEffects: false is ignored by most bundlers.

Canonical Configuration Block

This is the complete package.json fragment for a typical dual-format library that ships JavaScript modules alongside CSS:

{
  "name": "@scope/my-library",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/esm/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.js"
    },
    "./utils": {
      "types": "./dist/esm/utils.d.ts",
      "import": "./dist/esm/utils.js",
      "require": "./dist/cjs/utils.cjs",
      "default": "./dist/esm/utils.js"
    },
    "./styles.css": "./dist/styles.css"
  },
  "sideEffects": [
    "./dist/styles/*.css",
    "./dist/runtime/polyfills.js"
  ]
}

Key decisions encoded here:

  • "type": "module" makes .js files default to ESM; CJS files use .cjs.
  • The exports field maps every public entry point with types first so TypeScript resolves declarations before runtime paths.
  • sideEffects is a scoped array rather than false, because CSS imports are true side effects. Every file not listed here is treated as pure.

Step-by-Step Implementation

Step 1: Audit Your Package for True Side Effects

Before writing a single byte to package.json, identify every file that has observable behavior at import time:

# Find files that write to globalThis or window
grep -rE "(globalThis|window)\.[a-zA-Z]+ =" ./src --include="*.ts" -l

# Find files that call document.createElement or similar DOM APIs at module scope
grep -rE "^(document|window|navigator)\." ./src --include="*.ts" -l

# Find CSS imports at module scope
grep -rE "^import ['\"].*\.css['\"]" ./src --include="*.ts" -l

Expected output if your library is purely functional:

(no output — zero files with module-scope side effects)

If you see files listed, each one must either appear in the sideEffects array or be refactored to lazy-initialize behind a function call.

Step 2: Choose Boolean False or a Glob Array

The field accepts two shapes:

"sideEffects": false — marks every file in the package as pure. Use this only when:

  • No CSS is imported anywhere in the package
  • No polyfills or global augmentations run on import
  • No Object.defineProperty(globalThis, ...) or Symbol.hasInstance assignments exist at module scope

"sideEffects": ["./dist/**/*.css", "./dist/runtime/polyfills.js"] — preserves exactly the listed files. Everything else is treated as pure. Paths are resolved relative to the package root using minimatch syntax:

{
  "sideEffects": [
    "./dist/styles/*.css",
    "./dist/runtime/polyfills.js",
    "./dist/injectors/*.js"
  ]
}

HAZARD PREVENTION: Never use ["*.css"] without a directory prefix. This glob matches only root-level CSS files in most bundlers’ minimatch implementations — CSS in ./dist/styles/ is silently dropped. Always prefix with ./dist/ or ./src/ matching your actual build output directory.

Step 3: Wire the ESM Entry via the exports Field

sideEffects: false only benefits consumers whose bundler resolves to your ESM build. Consumers using "moduleResolution": "node" in TypeScript without a bundler that understands exports may fall back to the main field, which often points to CJS:

{
  "exports": {
    ".": {
      "types": "./dist/esm/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.js"
    }
  },
  "main": "./dist/cjs/index.cjs"
}

Document for consumers that the CJS path cannot be tree-shaken. Encourage "moduleResolution": "bundler" or "node16" in their tsconfig.json so their toolchain prioritizes the import condition. The browser vs Node.js module resolution differences matter here: Webpack and Vite read exports conditions while some older CJS-only pipelines do not.

Step 4: Add /*#__PURE__*/ Annotations for esbuild

esbuild ignores the sideEffects field entirely. Its dead code elimination relies solely on pure annotations and the --tree-shaking=true flag. IIFE-based patterns and class expressions that wrap function calls need explicit annotations:

// Before — esbuild keeps this even if createRegistry is unused
export const registry = createRegistry({
  timeout: 5000
});

// After — /*#__PURE__*/ tells esbuild the call has no side effects
export const registry = /*#__PURE__*/ createRegistry({
  timeout: 5000
});

Your bundler (Rollup, Vite) typically inserts these automatically when it compiles your library. Check the compiled output in ./dist/esm/ to confirm:

grep -c "__PURE__" ./dist/esm/index.js

If the count is zero and your library contains factory patterns, configure @rollup/plugin-replace or add /* @__PURE__ */ in source.

Step 5: Validate with publint and Consumer Build Tests

HAZARD PREVENTION: A common mistake is validating sideEffects only in your own repo’s build. The real test is whether a consumer’s bundler drops unused code. Misconfigured globs may pass npm publish but silently fail downstream.

Run publint to catch structural problems before publishing:

npx publint

Expected output for a correctly configured package:

✓ "exports" field is valid
✓ "sideEffects" field is valid
✓ No issues found

Then build a minimal consumer test to confirm DCE is working:

// consumer-test/index.ts — imports only one export
import { formatDate } from "@scope/my-library";
console.log(formatDate(new Date()));
npx rollup consumer-test/index.ts \
  --format esm \
  --external "@scope/my-library" \
  --file consumer-test/dist/out.js \
  --treeshake

# Measure the output size before and after adding sideEffects: false
wc -c consumer-test/dist/out.js

Hazard Call-Outs

HAZARD PREVENTION — CSS silently dropped: Setting "sideEffects": false when your package includes CSS-in-JS or imports .css files at module scope causes bundlers to strip those imports entirely. Consumer apps lose all styles with no build error. Fix: change to "sideEffects": ["./dist/**/*.css"] and rebuild. For a complete treatment of asset patterns see Configuring sideEffects for CSS and Asset Imports.

HAZARD PREVENTION — Barrel file re-exports defeat purity: If your package re-exports everything from a single index.ts barrel, bundlers must parse the full module even with sideEffects: false. Any module in the re-export chain that has a top-level initialization call becomes a purity boundary. Fix: eliminate or scope your barrel files. See Eliminating Barrel File Anti-Patterns for structural refactoring strategies.

HAZARD PREVENTION — Glob path mismatch after build: Glob patterns in sideEffects are matched against the files as they exist inside the published package tarball, not your source tree. If your build tool changes the output path (e.g. Rollup flattens src/styles/theme.css to dist/theme.css), a glob of "./src/styles/*.css" will never match. Always run npm pack --dry-run and confirm the file paths before setting your globs.

HAZARD PREVENTION — esbuild consumers get no benefit: Libraries consumed via Vite’s optimizeDeps (which uses esbuild internally for pre-bundling) ignore sideEffects. Add /*#__PURE__*/ to factory call sites in your compiled output and document this limitation in your package README.

Tooling Validation

Run these commands against your built package before publishing:

# 1. Check package.json structure and exports resolution
npx publint

# 2. Validate TypeScript types are reachable
npx attw --pack .

# 3. Enforce size budgets to catch regressions
npx size-limit

Configure size-limit in package.json to lock in the DCE savings:

{
  "size-limit": [
    {
      "path": "dist/esm/index.js",
      "limit": "12 kB",
      "gzip": true,
      "import": "{ formatDate }"
    },
    {
      "path": "dist/esm/index.js",
      "limit": "2 kB",
      "gzip": true,
      "import": "{ formatDate }",
      "running": false
    }
  ]
}

Add a prepublishOnly guard so the size budget runs before every publish:

{
  "scripts": {
    "validate:sideeffects": "node scripts/validate-sideeffects.js",
    "prepublishOnly": "npm run validate:sideeffects && npm run build && npx size-limit"
  }
}

CI Enforcement

Lock the sideEffects contract in pull request checks:

name: Bundle Size & Side-Effects Audit
on:
  pull_request:
    paths:
      - "src/**"
      - "package.json"
      - "rollup.config.*"

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm run build
      - name: Validate sideEffects globs
        run: node scripts/validate-sideeffects.js
      - name: Enforce size budgets
        run: npx size-limit
      - name: publint check
        run: npx publint

Compatibility Matrix

Bundler / Tool Reads sideEffects DCE mechanism Fallback when ignored
Webpack 5 Yes — optimization.sideEffects: true by default in production Module graph pruning pass None needed
Rollup 3+ Yes — treeshake.moduleSideEffects Export usage analysis None needed
Vite 4+ (prod) Yes — delegates to Rollup Rollup treeshake pass None needed
Vite (dep pre-bundle) No — esbuild ignores it --tree-shaking=true /*#__PURE__*/ annotations
esbuild (direct) No — ignores package.json --tree-shaking=true /*#__PURE__*/ annotations
Parcel 2 Yes — reads field Module scope analysis None needed
Node.js (CJS require) N/A — no tree-shaking Runtime evaluation N/A
TypeScript compiler N/A — type-only output Not applicable N/A

Node.js 12+ supports ESM natively, but the runtime never prunes unused exports — that is always a bundler-time operation. For the Node.js-specific resolution mechanics that determine which condition (import vs require) your bundler follows, see the browser vs Node.js module resolution breakdown.

Auditing Which Files Really Have Side Effects

"sideEffects": false is an assertion about every file in the package, and it is checked by nobody. Getting it wrong does not produce an error — it produces missing behaviour in a consumer’s production build, usually a polyfill that never ran or a style that never got injected, and usually only in the minified build where nobody was looking. So the value of the declaration depends entirely on the rigour of the audit behind it.

A module has a side effect if importing it changes anything observable beyond defining its own exports. In practice that means five patterns, and each is greppable.

Five shapes of module-level side effect Global assignment, prototype patching, CSS or asset imports, registration calls into a shared registry, and immediately invoked initialisation are the five patterns that make a module unsafe to declare side-effect-free. If a file does any of these, it is not side-effect-free global mutation globalThis.X = … window.addEventListener grep: globalThis|window\. prototype patching Array.prototype.at = … polyfills of any kind grep: \.prototype\. asset imports import "./styles.css" font and image side loads grep: import ".*\.(css|scss)" registration calls registry.add(thing) customElements.define(…) the sneakiest category eager initialisation const conn = connect() IIFEs at module scope defer it into a function

The registration category is the one that survives audits, because the call looks like ordinary code and the effect is on an object the module legitimately imports. A component library whose button.ts calls customElements.define("x-button", Button) at module scope has made that file side-effectful, and declaring the package side-effect-free means a bundler may drop the definition in a consumer who imports Button only for its type.

A first-pass audit is mechanical:

# candidate side-effect files — review each hit, do not trust the list blindly
grep -rn --include='*.ts' -E \
  '^\s*(globalThis|window|document|process)\.|\.prototype\.|^\s*import\s+["'"'"'].*\.(css|scss|less)["'"'"']|customElements\.define|^\s*\(function' src/

Anything that appears becomes either an exemption in the sideEffects array or a refactor. The refactor is almost always better, because an exemption is permanent while the underlying problem — work happening at import time that the consumer did not ask for — usually has a cheap fix: export a register() function and let the consumer call it.

Verifying the Declaration Instead of Trusting It

The audit tells you what you believe. A test tells you whether the belief survives a real build. The check is small enough to run in CI on every commit, and it is the only thing standing between a wrong sideEffects value and a silent production bug.

Give every side-effectful file a unique marker string, then assert its presence or absence in a bundled consumer:

// src/polyfills.ts
globalThis.__MY_LIB_POLYFILLS__ = true;   // marker: MY_LIB_POLYFILL_MARKER
# a consumer that imports one pure symbol must NOT contain the polyfill marker
cat > probe.ts <<'TS'
import { formatDate } from "@scope/my-library";
console.log(formatDate(new Date()));
TS
npx esbuild probe.ts --bundle --format=esm --minify --outfile=probe.out.mjs

if grep -q "MY_LIB_POLYFILL_MARKER" probe.out.mjs; then
  echo "FAIL: polyfill retained in a consumer that never imported it"
  exit 1
fi
echo "OK: pure import stayed pure"

# a consumer that DOES import the polyfill entry must contain it
cat > probe2.ts <<'TS'
import "@scope/my-library/polyfills";
TS
npx esbuild probe2.ts --bundle --format=esm --minify --outfile=probe2.out.mjs
grep -q "MY_LIB_POLYFILL_MARKER" probe2.out.mjs \
  && echo "OK: explicit polyfill import survived" \
  || { echo "FAIL: sideEffects dropped a genuine side effect"; exit 1; }

Both directions matter, and teams usually only write the first, which is why the failure that reaches production is nearly always the second one: a declaration that is too aggressive rather than too cautious. An over-cautious sideEffects array costs a consumer a few kilobytes they will never notice; an over-aggressive one removes behaviour they were relying on, in the one build configuration nobody tests locally. When in doubt, exempt the file and measure the cost before deciding it was worth removing. The second assertion — that a genuine side effect survives when explicitly requested — is what catches an over-broad "sideEffects": false that quietly strips the polyfill file even from consumers who imported it on purpose.

Run the same pair against the bundler your consumers use, not just esbuild, because the semantics diverge exactly here: webpack’s usedExports analysis and Rollup’s purity tracing disagree on several of the patterns above, as catalogued in sideEffects Field Edge Cases Across Bundlers.

HAZARD PREVENTION

Symptom: The package works in every test, and a consumer reports that component styles are missing only in their production build.

Root cause: "sideEffects": false was set on a package whose components import their own CSS. Development builds skip the elimination pass, so the problem is invisible until minification runs.

Fix: Replace the blanket false with an array that exempts every asset type the package imports — ["**/*.css", "**/*.scss", "src/polyfills.ts"] — and add a production-mode assertion to CI so the next regression fails the build rather than a consumer’s page.


Choosing Between false, an Array, and Omitting the Field

The three possible values are not points on a scale — they express different claims, and picking between them is a short decision.

Which sideEffects value to declare Declare false when every file is pure. Declare an array when some files import styles or run polyfills. Omit the field only when you cannot audit the package, since omission tells bundlers to keep everything. Three claims, not three settings "sideEffects": false claim: every file is pure use when: no styles, no polyfills, no registration best elimination available worst failure if wrong an array of globs claim: all pure except these use when: CSS imports or a polyfill entry exists the usual right answer for component libraries field omitted claim: none — assume impure use when: the package is not yet audited safe, and costs consumers every unused module

The array form is under-used relative to how often it is the correct answer. A component library with per-component styles is not a pure package, and declaring it one is the mistake; declaring the styles and leaving everything else eliminable gets nearly all of the benefit with none of the risk.


Sub-Topics


Tree-Shaking & Bundle Optimization