A single "sideEffects": false declaration is meant to be a universal signal, but Rollup, esbuild, and webpack do not all interpret its edge cases — glob patterns, CSS imports, and export * re-exports — identically. A package that ships flawlessly tree-shaken under one bundler can silently leak a module under another, and the divergence usually surfaces only after a consumer reports an unexpectedly large bundle. This guide catalogs where the three tools disagree when applying the sideEffects field, building on the shared benchmark harness from Comparing Bundler Tree-Shaking Output.


Root Cause

sideEffects is a package.json-level hint, not a language feature — there is no ECMAScript specification for it. Each bundler implements its own glob-matching engine, its own rules for what counts as “the same module” when re-exports are involved, and its own boundary between files it treats as JavaScript modules (subject to sideEffects) versus files handled by a separate loader pipeline (CSS, JSON, images). Because the field’s semantics were established by convention rather than standard, the three tools converge on the common cases and diverge on the edges: negated glob patterns, nested export * chains, and non-JS assets.


Minimal Reproduction

The following package declares sideEffects with a glob array intended to preserve a CSS import while allowing everything else to be pruned:

{
  "name": "widget-lib",
  "version": "1.0.0",
  "type": "module",
  "sideEffects": ["**/*.css", "./src/polyfills.js"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    }
  }
}
// src/index.ts
export * from "./button";
export * from "./theme";
import "./polyfills.js"; // intended to always run
import "./button.css";   // intended to always run

// src/button.ts — pure, no side effects
export function Button(label: string): string {
  return `<button>${label}</button>`;
}

// src/theme.ts — pure, no side effects
export function applyTheme(name: string): string {
  return `theme-${name}`;
}
// consumer/entry.ts — imports only Button
import { Button } from "widget-lib";
console.log(Button("Save"));

Because of the leading ./ in the second glob entry, one of these three bundlers fails to match polyfills.js against the pattern and prunes it — silently dropping code the author intended to always run.


Where the Three Bundlers Disagree

sideEffects interpretation differences Comparison of Rollup and esbuild's glob matching and export-star tracing against webpack's more conservative handling of the same sideEffects declarations, including CSS import boundaries. Rollup / esbuild webpack Traces export * through pure chains Glob matched from manifest directory CSS handled as a normal ESM side effect Precise but strict on path syntax Conservative on mixed export * chains CSS routed through a separate loader More bailouts, fewer silent drops Safer default, larger output

Step-by-Step Fix

1. Normalize glob paths to remove ambiguity

Drop leading ./ from every sideEffects array entry — all three bundlers resolve entries relative to the manifest directory already, and the leading segment is a common source of match failures in older Rollup and esbuild versions:

  "sideEffects": [
    "**/*.css",
-   "./src/polyfills.js"
+   "src/polyfills.js"
  ]

2. Split CSS exemptions from JS-side-effect exemptions

Treat the two as logically separate concerns even though they share one array, since webpack applies its CSS loader pipeline independently of the JS sideEffects check:

{
  "sideEffects": [
    "**/*.css",
    "**/*.scss",
    "src/polyfills.js"
  ]
}

HAZARD PREVENTION

Symptom: Component styles are missing at runtime in a webpack-bundled consumer, but the same package works fine under Rollup.

Root cause: css-loader and mini-css-extract-plugin process .css imports through a separate pipeline from webpack’s JS-side usedExports analysis. If the glob pattern in sideEffects does not match the CSS loader’s resolved path exactly (for example, a monorepo symlink changes the effective path), webpack can still drop the JS-side import "./button.css" statement as dead even though the loader would have handled it correctly.

Fix: Test the built consumer bundle for the actual injected <style> tag or extracted CSS file, not just the presence of the import statement in source — a passing grep on your source is not proof the side effect survived bundling.

3. Verify export * chains resolve identically under all three bundlers

# Build the widget-lib fixture with all three, per the harness in the parent guide
npx rollup consumer/entry.ts --file out/rollup.mjs --format es --plugin @rollup/plugin-node-resolve
npx esbuild consumer/entry.ts --bundle --format=esm --outfile=out/esbuild.mjs
npx webpack --mode production --entry ./consumer/entry.ts --output-path ./out --output-filename webpack.mjs --experiments-output-module

grep -c "applyTheme" out/rollup.mjs out/esbuild.mjs out/webpack.mjs

Expected result: 0 for all three files — applyTheme is unused and should be absent regardless of the export * barrel in src/index.ts.

HAZARD PREVENTION

Symptom: applyTheme appears in the webpack output but not in Rollup’s or esbuild’s.

Root cause: webpack’s usedExports analysis for export * from re-exports is more conservative than Rollup’s or esbuild’s when the module graph is not confirmed fully ESM — a single interop shim anywhere upstream can force webpack to retain the entire re-exported namespace defensively.

Fix: Replace export * from "./theme" with an explicit named re-export — export { applyTheme } from "./theme" — so webpack’s export tracking has an exact list rather than needing to prove the complete namespace itself. See Eliminating Barrel File Anti-Patterns for the full refactor pattern.

4. Re-run the byte-count comparison to confirm parity

for f in out/rollup.mjs out/esbuild.mjs out/webpack.mjs; do
  echo "$f  gzip=$(gzip -c "$f" | wc -c)"
done

If the three numbers are now within a small, explainable margin (module wrapper overhead only, per Rollup vs esbuild vs webpack Bundle Size), the sideEffects edge cases have been resolved.


Verification

# Confirm applyTheme and the polyfill's marker are handled identically everywhere
for f in out/rollup.mjs out/esbuild.mjs out/webpack.mjs; do
  echo "$f:"
  grep -q "applyTheme" "$f" && echo "  FAIL: applyTheme present" || echo "  OK: applyTheme absent"
  grep -q "POLYFILL_MARKER" "$f" && echo "  OK: polyfill present" || echo "  FAIL: polyfill missing"
done

Expected: every file reports OK: applyTheme absent and OK: polyfill present.


Edge Cases / Gotchas

  • Negated glob patterns are not supported the same way everywhere. !src/keep-this.js style negation inside a sideEffects array is not part of the informal specification any bundler guarantees; avoid relying on negation and list positive exemptions only.
  • Monorepo symlinks change the effective resolved path that glob patterns match against. A package symlinked via a workspace protocol may resolve to a path outside the expected src/ prefix, causing the same glob to match under npm but miss under pnpm’s stricter linking.
  • JSON and image imports are rarely covered by sideEffects glob authors, but they can still trigger bundler-specific loader side effects (asset copying, hashing) independent of the JS-level flag — verify build output, not just source greps, whenever a non-JS asset type is involved.
  • esbuild’s --tree-shaking flag interacts with sideEffects only in bundle mode. Running esbuild in transform-only mode (no --bundle) never reads package.json at all, so a comparison run without --bundle will silently include everything.
  • Vite’s dev server and its Rollup-based production build can disagree on sideEffects edge cases because the dev server serves ESM directly without bundling, while the production build applies Rollup’s rules — always validate against the production build, not vite dev output.

Frequently Asked Questions

Does esbuild read the sideEffects field at all?

Yes, esbuild honors sideEffects in bundle mode with tree-shaking enabled, including array-form glob exemptions. The behavior is close to Rollup’s, but esbuild’s glob matching uses its own minimatch-like implementation, so double-star patterns that Rollup accepts can occasionally fail to match under esbuild — always verify with a real build rather than assuming parity.

Why does webpack still bundle a CSS file listed in sideEffects as false?

sideEffects: false at the package root only applies to files the bundler treats as JavaScript modules through its normal resolution. A CSS import processed by css-loader is loaded through a separate loader pipeline, and webpack’s usedExports analysis does not evaluate it the same way; you must exempt the specific glob (e.g. **/*.css) in the sideEffects array or the file’s side effect (style injection) can still be dropped incorrectly on the JS side while css-loader processes it independently.

Do relative and absolute glob patterns behave the same in the sideEffects array?

No. All three bundlers resolve sideEffects globs relative to the package.json location, not the current working directory or the importing module. A pattern like src/polyfills.js only matches if the file sits at exactly that path from the manifest; a common mistake is writing ./src/polyfills.js with a leading dot, which some bundler versions treat as a literal path segment and fail to match.

Does a barrel file with export * ever count as side-effect-free?

Rollup and esbuild can mark export * as side-effect-free if every re-exported module is itself side-effect-free, tracing through the whole chain. Webpack is more conservative: prior to full concatenateModules success, a barrel using export * is more likely to be treated as fully retained because webpack cannot always prove the complete re-exported set statically ahead of time in mixed CJS/ESM graphs.

Can I set sideEffects differently for the ESM and CJS builds of the same package?

No — sideEffects is a single package.json-level field and cannot vary per exports condition. If your CJS build has effectively different side-effect behavior than your ESM build (for example because a bundler-only transform strips a polyfill from the ESM path), keep the field conservative enough to be correct for both, or restructure so both builds share the same side-effect profile.



↑ Back to Comparing Bundler Tree-Shaking Output