Implementing the sideEffects Flag Correctly
Configure package.json sideEffects as a boolean or glob array for dead code elimination while safely preserving CSS, polyfills, and runtime initialisation code.
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
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.jsfiles default to ESM; CJS files use.cjs.- The
exportsfield maps every public entry point withtypesfirst so TypeScript resolves declarations before runtime paths. sideEffectsis a scoped array rather thanfalse, 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, ...)orSymbol.hasInstanceassignments 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
sideEffectsonly in your own repo’s build. The real test is whether a consumer’s bundler drops unused code. Misconfigured globs may passnpm publishbut 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": falsewhen your package includes CSS-in-JS or imports.cssfiles 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.tsbarrel, bundlers must parse the full module even withsideEffects: 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
sideEffectsare 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 flattenssrc/styles/theme.csstodist/theme.css), a glob of"./src/styles/*.css"will never match. Always runnpm pack --dry-runand 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) ignoresideEffects. 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
- Configuring sideEffects for CSS and Asset Imports — glob patterns for stylesheets, fonts, and polyfill files that must survive DCE passes.
Related
- Eliminating Barrel File Anti-Patterns — how re-export aggregators create purity boundaries that
sideEffects: falsecannot overcome on its own. - Advanced Dead Code Elimination Techniques — scope hoisting, module concatenation, and
/*#__PURE__*/annotations for the DCE pass that runs after thesideEffectsfilter. - Optimizing Bundle Size for Frontend Libraries — end-to-end bundle size strategy including code splitting, dynamic imports, and size-limit budgets.
- Mastering the package.json
exportsField — theexportscondition map that determines which build artifact a consumer’s bundler resolves to. - Navigating the Dual-Package Hazard — why shipping both CJS and ESM can cause two copies of your package to load, and how to prevent it.