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.
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.jsonFor 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.logKeep 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 -60This 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.jsonIf 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 -20If 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:
- Edit
node_modules/large-library/package.jsondirectly. Add thesideEffectsfield using the most restrictive glob list that covers all genuine side-effect files:
{
"sideEffects": [
"**/*.css",
"**/init.js",
"**/polyfills/*.js",
"**/register-provider.js"
]
}- Generate the patch file:
# Create a persistent patch for the sideEffects declaration
npx patch-package large-library- Commit
patches/large-library+x.y.z.patchto your repository. Add thepostinstallscript topackage.jsonso 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 --compareA 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.jsonModules 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-limitEdge 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.
Related
- Configuring sideEffects for Optimal Tree-Shaking — parent page covering the full
sideEffectsconfiguration contract for library authors and application bundlers - Refactoring Barrel Files to Reduce Bundle Bloat — barrel file patterns that interact with
sideEffectsand compound the retention problem - Converting CJS Libraries to ESM for Better Bundling — the upstream fix when a package’s CommonJS format prevents the
sideEffectsdeclaration from taking effect - Fixing Tree-Shaking Failures with Webpack 5 — adjacent failure mode where ESM conversion itself introduces tree-shaking regressions