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.

Sub-Topics


Tree-Shaking & Bundle Optimization