How to Audit sideEffects in Large npm Packages

Symptom you will see: After adding a single utility import such as import { formatDate } from 'large-library', your production bundle grows by 80–200 KB. The Network tab shows a chunk that contains dozens of modules you never imported. Webpack 5’s build output may include a line like WARNING: Module has no exports used — but it's retained because of sideEffects. This page shows you exactly how to trace that retention to its source and eliminate it.


sideEffects audit workflow A left-to-right flow diagram showing five stages: symptom detection, stats analysis, upstream inspection, patch or override, and CI verification. 1 2 3 4 5 Symptom Detect Bundle size spike, Network tab shows bloated chunk Stats Analysis jq filter on compilation- stats.json Upstream Inspection Check package.json sideEffects key Patch or Override patch-package or bundler rule override CI Verification size-limit --compare + integration test pass Target: ≥20% uncompressed size reduction per audited package

Root Cause: Why the Bundler Retains Modules You Never Use

This is a specific diagnostic scenario within the broader technique of configuring sideEffects for optimal tree-shaking. The mechanism is straightforward: when an upstream package.json omits the sideEffects field — or sets it to true — Webpack 5 and Vite 5+ treat every file in that package as potentially mutating global state. The static analyzer cannot safely remove any module because the consequence of dropping a file that registers a polyfill or patches Array.prototype would be a runtime crash.

The result is a conservative retention cascade. Importing formatDate from a large utility package pulls in the entire module graph of that package because the bundler cannot distinguish pure utility modules from initialization scripts. This is especially pronounced in packages that use legacy barrel file patterns — a single index.js that re-exports everything prevents the analyzer from pruning individual branches even when you only reference one export.

A secondary cause appears when the package ships in CommonJS format. Converting CJS libraries to ESM explains why require() calls prevent static analysis: the dependency graph cannot be determined until runtime, so the bundler must include everything.

Step 1 — Generate a Deterministic Build Manifest

Before you can isolate the offending modules, you need a machine-readable build artifact. For Webpack 5:

# Webpack 5 — write full compilation stats to JSON
npx webpack --mode=production --json=compilation-stats.json

For Vite 5+, the stats are embedded in the Rollup bundle output. Enable verbose chunk logging:

# Vite 5+ — generate build with verbose output
npx vite build --logLevel info 2>&1 | tee vite-build.log

Keep compilation-stats.json out of version control — add it to .gitignore because it can exceed 50 MB on large codebases.

Step 2 — Filter the Stats for Side-Effect-Retained Modules

With the Webpack 5 stats file in hand, a targeted jq query surfaces the exact modules the bundler retained due to side effects:

# Webpack 5 — find modules retained due to sideEffects: true or null
jq '
  .modules[]
  | select(.sideEffects == true or .sideEffects == null)
  | { path: .name, size: .size, reasons: [.reasons[].moduleName] }
' compilation-stats.json | head -60

This output shows module path, retained byte size, and which other modules imported it. Cross-reference the paths against your application’s actual import statements. Any entry under node_modules/<package>/ that you never explicitly import is a false-positive retention candidate.

To narrow further, filter for a specific suspected package:

# Webpack 5 — isolate a specific package's retained modules
jq '
  .modules[]
  | select(.name | test("node_modules/large-library"))
  | { path: .name, size: .size, sideEffects: .sideEffects, usedExports: .usedExports }
' compilation-stats.json

If usedExports is an empty array but sideEffects is true, the module is being retained purely because of the missing or over-broad declaration.

Step 3 — Inspect the Upstream Package Manifest

Once you have a suspect package, examine what its publisher declared:

# Check the sideEffects field in an installed package
cat node_modules/large-library/package.json | jq '{ name: .name, version: .version, sideEffects: .sideEffects }'

Possible outputs and what each means:

Result What the bundler does Your action
"sideEffects": false Treats all files as pure; prunes freely No action needed — investigate elsewhere
"sideEffects": true Retains all files unconditionally Override at bundler level (see Step 4a)
"sideEffects": null / field absent Same conservative behavior as true Apply patch (see Step 4b)
"sideEffects": ["*.css", "init.js"] Retains only matched files, prunes rest Verify the glob list covers real side effects

Before patching, confirm that the package actually contains true side effects. Scan for top-level global mutations in its compiled output:

# Search for prototype extensions and global assignments in the package source
grep -rn --include="*.js" \
  -E "(Object\.defineProperty\(prototype|window\.\w+\s*=|global\.\w+\s*=|Array\.prototype\.|Object\.prototype\.)" \
  node_modules/large-library/dist/ | head -20

If this returns results, those files are genuine side effects and must stay in the sideEffects glob list. Files that produce no matches are safe to declare as pure.

Also check for circular dependencies that force full-module inclusion:

# Detect circular dependencies in a package (requires madge)
npx madge --circular --extensions js,ts node_modules/large-library/src/

Step 4a — Bundler-Level Override (No Patch Required)

When you cannot modify the package (corporate restrictions, no patch tooling in CI), configure the bundler to override the retention decision:

Webpack 5 (webpack.config.js)

// Webpack 5 — override sideEffects for a specific package
module.exports = {
  optimization: {
    usedExports: true,
    sideEffects: true,       // Must be true to read the overrides below
    moduleIds: 'deterministic'
  },
  module: {
    rules: [
      {
        // Treat large-library as side-effect-free at the bundler level
        test: /node_modules[\\/]large-library[\\/]/,
        sideEffects: false
      }
    ]
  }
};

Vite 5+ (vite.config.ts)

// Vite 5+ — per-package moduleSideEffects override
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      treeshake: {
        // Return false (= no side effects) for large-library specifically
        moduleSideEffects: (id: string) => {
          if (id.includes('node_modules/large-library')) return false;
          return 'no-external'; // Conservative for everything else
        }
      }
    }
  }
});

Test this configuration in a staging build before merging. If the application throws at runtime, a required initialization file is being pruned — revert and use the glob approach in Step 4b.

Step 4b — patch-package Override (Persistent Fix)

A patch-package fix persists the correct sideEffects declaration across npm install runs, which is safer for CI reproducibility than bundler-level rules that can be accidentally removed:

  1. Edit node_modules/large-library/package.json directly. Add the sideEffects field using the most restrictive glob list that covers all genuine side-effect files:
{
  "sideEffects": [
    "**/*.css",
    "**/init.js",
    "**/polyfills/*.js",
    "**/register-provider.js"
  ]
}
  1. Generate the patch file:
# Create a persistent patch for the sideEffects declaration
npx patch-package large-library
  1. Commit patches/large-library+x.y.z.patch to your repository. Add the postinstall script to package.json so the patch applies after every install:
{
  "scripts": {
    "postinstall": "patch-package"
  }
}

The glob array controls precision. An overly broad "**/*.js" means pure utility files are retained unnecessarily; an overly narrow list risks pruning legitimate side effects. Use the grep scan from Step 3 to build an accurate list.

Step 5 — Verify the Fix

5.1 Measure Size Delta

# Compare bundle sizes against a committed baseline
npx size-limit --compare

A successful audit produces at least a 20% reduction in uncompressed JavaScript for the affected package. If the delta is smaller, check whether the bundler-level override in webpack.config.js is actually applying — confirm optimization.sideEffects: true is set, otherwise Webpack 5 ignores package.json declarations entirely.

5.2 Confirm Module Exclusion in Stats

Rebuild and re-run the stats query from Step 2:

# Webpack 5 — confirm large-library modules are no longer retained
npx webpack --mode=production --json=compilation-stats.json && \
jq '
  .modules[]
  | select(.name | test("node_modules/large-library"))
  | { path: .name, sideEffects: .sideEffects, usedExports: .usedExports }
' compilation-stats.json

Modules that were previously retained with usedExports: [] should now either be absent from the output (pruned entirely) or show sideEffects: false.

5.3 Run Integration Tests

The most common regression after a sideEffects override is a missing polyfill or unregistered context provider:

# Run integration tests focused on polyfill and initialization behavior
npm run test:integration -- --grep "polyfill|init|provider|register"

If tests fail, the pruned module contained a genuine initialization side effect. Add its path to the sideEffects glob array and regenerate the patch.

5.4 Gate Future Regressions in CI

Commit size budgets so that any future dependency update that reintroduces the inflation fails the PR check automatically:

{
  "size-limit": [
    {
      "path": "dist/main.*.js",
      "limit": "150 KB",
      "running": false
    }
  ]
}

Add the check to your CI pipeline:

# CI: bundle size gate on every pull request
- name: Check bundle size
  run: npx size-limit

Edge Cases and Gotchas

Gotcha 1 — The Package Exports CSS via JavaScript

Some component libraries inject stylesheets at import time using a pattern like import './styles.css' inside the module’s JavaScript entry. If you set sideEffects: false, the CSS import is also pruned, breaking the component’s visual output with no JavaScript error. The fix is an explicit glob entry:

{ "sideEffects": ["**/*.css", "**/inject-styles.js"] }

Vite 5+ handles CSS-in-JS side effects differently from Webpack 5 — Vite extracts CSS at build time and the sideEffects flag does not suppress the extraction. Test both bundlers separately if your project targets both.

Gotcha 2 — Sub-Path Imports Bypass the Override

When you import from a package sub-path — import { x } from 'large-library/utils' — a Webpack 5 module.rules regex targeting the package root may not match the resolved file path. Use a more specific regex that matches the package directory regardless of sub-path:

// Webpack 5 — match all sub-paths of the package
{ test: /node_modules[\\/]large-library[\\/]/, sideEffects: false }

Verify the match by running webpack --stats-modules and confirming the sideEffects column reads false for sub-path files.

Gotcha 3 — The Package Uses Dynamic require()

Packages that call require(someVariable) inside module bodies prevent static analysis. Webpack 5 will retain those modules regardless of the sideEffects declaration because the dependency graph cannot be resolved at build time. This is a structural CJS issue — the upstream package needs to be converted to ESM before tree-shaking can operate. As a short-term workaround, use IgnorePlugin in Webpack 5 to explicitly suppress the dynamic require paths you know are unreachable in your application.


FAQ

How do I know which npm package is causing bundle inflation from missing sideEffects?

Generate a Webpack 5 stats JSON with --json=compilation-stats.json, then query it with jq to find modules where sideEffects is true or null. Cross-reference those module paths against your import statements to identify which packages are retaining unnecessary code. The usedExports: [] field on a retained module is a reliable signal that the retention is driven by sideEffects rather than actual usage.

Is it safe to set sideEffects: false on a third-party package I don’t control?

Only after auditing its source for top-level polyfill registrations, prototype mutations, and global CSS injection. If those exist, use a glob array rather than false. Always run integration tests after applying the override to catch missing runtime initialization. A bundler-level override is safer to test first because it can be reverted in a single config edit.

What happens when a patch-package fix is applied but the bundler still retains the module?

The most common cause is that optimization.sideEffects in your webpack.config.js is false or absent, which causes Webpack 5 to ignore all sideEffects declarations entirely. Confirm it is set to true. A second cause is that the package resolves through an alias — verify the module path in the stats JSON matches the regex in your module.rules override.