Configuring sideEffects for Optimal Tree-Shaking
Without a correct sideEffects declaration in package.json, bundlers cannot determine which modules are safe to drop. They fall back to the worst-case assumption — retain everything — bloating production payloads by 8–12% and preventing the static graph pruning that scope hoisting depends on. Worse, an incorrect declaration (marking a module pure when it registers globals or injects CSS) produces silent runtime failures that only surface in production.
This page covers how to author the sideEffects contract precisely, configure Webpack 5 and Vite 5+ to act on it, map framework-specific edge cases, and verify that pruning is actually happening before a change ships.
Architectural context
sideEffects configuration sits at the intersection of module authoring and bundler static analysis, and it is one of the most impactful levers in the broader Advanced Tree-Shaking & Dependency Optimization strategy. Getting it right unlocks scope hoisting, reduces vendor chunk count, and stabilises long-term cache hashes — gains that compound across every release rather than being a one-time win.
The technique depends on ESM static structure. CommonJS require() calls compute the dependency path at runtime, which breaks purity inference entirely. If your library or a dependency is still shipping CJS, converting CJS libraries to ESM for better bundling must happen first — otherwise no sideEffects declaration can help the bundler prune safely.
How bundlers use the purity contract
During the build phase, the bundler traverses the module graph from every entry point outward, constructing an Abstract Syntax Tree (AST) for each reachable file. When sideEffects is absent, the bundler marks every node as “may have side effects” and retains all reachable modules unconditionally. With an explicit declaration, the optimizer can mark pure modules for elimination before scope hoisting runs, shrinking the graph that the minifier then processes.
The diagram below shows the two code paths through the optimizer:
Bundler-specific configuration
Webpack 5
Webpack 5 reads sideEffects from package.json automatically in production mode, but explicit optimizer flags guarantee the behavior regardless of mode:
// webpack.config.js — Webpack 5
module.exports = {
mode: 'production',
optimization: {
sideEffects: true, // Respect package.json sideEffects field
usedExports: true, // Mark unused exports for minifier removal
concatenateModules: true, // Scope hoisting — requires sideEffects pruning first
moduleIds: 'deterministic', // Stable chunk hashes across builds
chunkIds: 'deterministic'
},
stats: {
modules: true,
reasons: true,
optimizationBailout: true // Shows which modules were kept and why
}
};The optimizationBailout stat is the single most useful diagnostic signal: it logs every module the optimizer declined to remove and the exact reason. When a module you expected to be pruned still appears in the output, check this log first.
Vite 5+
Vite’s Rollup engine applies sideEffects declarations from dependencies automatically. The treeshake options in rollupOptions let you override the default heuristics:
// vite.config.ts — Vite 5+
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
treeshake: {
// Treat external packages as pure unless their package.json says otherwise
moduleSideEffects: 'no-external',
// Disallow reads of unknown properties from triggering retention
propertyReadSideEffects: false,
// Remove calls to pure annotation functions (e.g. /*#__PURE__*/ wrappers)
tryCatchDeoptimization: false
},
output: {
chunkFileNames: 'assets/[name]-[hash].js'
}
}
}
});moduleSideEffects: 'no-external' is the recommended default for application builds: external packages that have incorrect or missing sideEffects declarations are treated as pure, and only your first-party code receives the conservative treatment. Flip to true if a third-party package relies on its import side effects (rare but real).
Authoring the sideEffects declaration
Pure utility libraries
For a library that contains only stateless transformations — math helpers, string utilities, validators — the declaration is unconditional:
{
"name": "@scope/utilities",
"version": "2.1.0",
"sideEffects": false
}Every file in this package can now be eliminated if its exports are unused. No exclusions needed.
Libraries with initialization files or styles
Component libraries, polyfill packages, and runtime integrations typically need a selective list. List exact paths or globs for every file that must execute when imported:
{
"name": "@scope/components",
"version": "3.0.0",
"sideEffects": [
"*.css",
"./dist/themes/*.css",
"./lib/polyfills/*.js",
"./lib/init-runtime.js",
"./lib/register-custom-elements.js"
]
}Critical omission types. Files that perform any of these actions at module load time must appear in the array or the bundler will silently drop them:
- Prototype patches and global augmentations (
Array.prototype.flat,Promise.allSettledpolyfills) customElements.define()calls- CSS-in-JS runtime installation (
emotion,styled-componentsglobal style injection) - Framework plugin registration (
app.use(plugin)called at import time) window.*ordocument.*assignments at the top level
Conditional exports alignment
When a library ships separate ESM and CJS builds via the exports map, the sideEffects globs must match the paths that each entry resolves to:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./components/*": {
"import": "./dist/components/*.mjs",
"types": "./dist/components/*.d.ts"
}
},
"sideEffects": [
"./dist/**/*.css",
"./dist/polyfills/*.mjs"
]
}If the glob patterns reference .js paths but the resolved entry points are .mjs, the bundler may skip the side-effect check entirely and revert to the conservative path.
Framework integration
React component libraries
React libraries that bundle their own styles or register context providers at import time need careful glob mapping. A common pattern is to split style imports into a dedicated entry:
// React consumer — Vite 5+ app
// Import component JS (declared pure — tree-shakeable)
import { Button, Dialog } from '@scope/components';
// Import styles separately (always a side effect — never tree-shaken)
import '@scope/components/dist/themes/default.css';Lazy-loaded routes that import from large component libraries benefit from this split because the CSS is loaded once globally while the component JS is deferred with React.lazy():
// Lazy-loaded route using React.lazy + Suspense
import React, { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./routes/Dashboard'));
export function App() {
return (
<Suspense fallback={<div>Loading…</div>}>
<Dashboard />
</Suspense>
);
}When Dashboard is pruned during tree-shaking (because it is not statically imported), any component library imports it makes also become candidates for pruning — but only if those library modules are declared pure in their sideEffects field.
Vue 3 component libraries
Vue 3 SFCs compile to pure JS functions and do not inject global styles by default. The risk area is plugins registered via app.use() at the top of the entry file rather than inside a lazy-loaded route:
// vite.config.ts — Vite 5+: mark Vue SFC compiler output as pure
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
treeshake: {
moduleSideEffects: (id) => {
// Retain plugin registration files; treat component files as pure
return id.includes('plugin') || id.includes('install');
}
}
}
}
});The moduleSideEffects function form lets you write per-file logic when a simple glob is insufficient.
Quantified impact metrics
Across typical library builds and application dependency graphs, correct sideEffects configuration produces measurable improvements before and after:
- Payload reduction: 8–12% — pure utility modules and unused component code dropped from vendor chunks in large monorepo apps.
- Dependency graph edges: 15–30% fewer — strict
sideEffectsglobs prevent the bundler from drawing edges to initialization-only files that downstream consumers never call. - Build time: ~18% faster cold compilation — smaller module graphs mean fewer AST traversal passes and less scope hoisting work. Incremental builds see a smaller gain (~5–8%) since the graph is already partially resolved.
- Cache invalidation rate: ~40% lower — fewer modules in each chunk means chunk hashes change less often across releases, extending CDN cache lifetimes.
- Chunk count variance: stabilised — removing spurious cross-chunk dependencies reduces the non-deterministic splitting that causes downstream assets to shift hashes.
Common pitfalls
Pitfall 1: CSS imports silently dropped
Root cause. A component library sets "sideEffects": false globally but does not list its CSS imports. Webpack and Rollup treat import './Button.css' as a pure, unused module and drop it.
Diagnostic signal. Components render without styling. The Network panel shows no CSS requests for the affected components. optimizationBailout does not flag the CSS file — it was silently pruned.
Corrective action. Add "*.css" (and any path-specific CSS globs) to the sideEffects array. Rebuild and confirm the CSS appears in the Network panel.
Pitfall 2: CJS interop defeats purity inference
Root cause. A dependency distributes only a CJS build. Even with "sideEffects": false, the bundler cannot statically analyze dynamic require() calls and retains the entire file.
Diagnostic signal. optimizationBailout logs "CommonJS or AMD dependencies" as the reason a module was retained. The module appears in stats.json despite having no used exports in the current entry.
Corrective action. Follow the converting CJS libraries to ESM for better bundling migration. If you do not control the package, add an override in package.json to force the ESM build via imports/exports field, or use Vite’s optimizeDeps.include to pre-bundle the CJS package into a synthetic ESM wrapper.
Pitfall 3: Barrel files re-introduce retained modules
Root cause. An index.ts re-exports everything from every sub-module. Even with "sideEffects": false, a bundler that encounters export * from './utils' must resolve and parse ./utils, which may pull in modules the current consumer never needed.
Diagnostic signal. Bundle analyzer shows modules from a utility library appearing in chunks that only import one function from it.
Corrective action. Apply the refactoring barrel files to reduce bundle bloat technique: replace export * re-exports with named re-exports, or switch consumers to direct module imports instead of barrel files.
Pitfall 4: Incorrect glob scope in sideEffects array
Root cause. Globs are evaluated relative to the package root, not the dist/ directory. A pattern like "./lib/*.js" will not match "./dist/lib/*.js".
Diagnostic signal. A file you listed in sideEffects is still being dropped. Confirms by adding console.log at the module’s top level: if it never fires, the module was pruned.
Corrective action. Use explicit relative paths starting from the package root (./dist/lib/init.js) rather than partial globs. Run the sideEffects audit workflow to verify each glob against actual resolved paths.
Verification workflow
Confirming that sideEffects configuration is working requires comparing module graphs before and after, not just eyeballing the output file size.
Step 1: Generate bundle stats
Webpack 5 — add --json to the CLI or use the BundleAnalyzerPlugin:
// webpack.config.js — Webpack 5: emit stats for comparison
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
mode: 'production',
plugins: [
new BundleAnalyzerPlugin({ analyzerMode: 'static', reportFilename: 'report.html' })
]
};Vite 5+ — use rollup-plugin-visualizer:
// vite.config.ts — Vite 5+: emit treemap report
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
visualizer({ filename: 'dist/report.html', gzipSize: true, brotliSize: true })
]
});Step 2: Baseline comparison
Before changing sideEffects:
- Record total chunk count and each chunk’s gzip size.
- Note the module count in
stats.json(modulesarray length). - Screenshot or save the bundle analyzer treemap.
After adding or correcting sideEffects:
- Confirm module count decreased (15–30% is typical for large utility packages).
- Confirm chunk gzip sizes dropped — if they did not, check
optimizationBailoutlogs. - Run runtime smoke tests to catch any missing polyfill or global registration.
Step 3: CI size gate
A pull-request gate prevents regressions from accumulating silently:
# .github/workflows/bundle-check.yml — CI gate for bundle size
name: Bundle Integrity Gate
on: [pull_request]
jobs:
bundle-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Build production bundle
run: npm run build
- name: Check bundle size
run: npx size-limitAdd a .size-limit.json that establishes byte budgets per entry point. Any PR that expands a chunk beyond budget fails the gate, surfacing accidental side-effect re-introduction immediately.
Step 4: DevTools network validation
For component libraries and framework integrations, the final verification is in the browser:
- Open DevTools → Network → filter JS.
- Load a route that imports only one component from the library.
- Confirm that sibling component modules do not appear as separate network requests or as code within the main chunk (search for their function names in the Sources panel).
- Verify that CSS files declared in
sideEffectsare present in the Network panel — their absence means the glob was too broad or a polyfill was silently pruned.
FAQ
What happens if I omit sideEffects from package.json?
Bundlers assume every file may mutate global state and conservatively retain all imported modules, even unused ones. This typically adds 8–12% to production payload because pure utility files that are never called remain in the output.
Can sideEffects: false break my application?
Yes, if any file performs top-level work — registering globals, injecting CSS, patching prototypes, or initializing a runtime — and that file is imported only for its side effect. List those files explicitly in the sideEffects array rather than setting false unconditionally.
Does Vite respect the sideEffects field automatically?
Vite’s Rollup engine reads the sideEffects field from package.json of external dependencies. Setting treeshake.moduleSideEffects: 'no-external' in rollupOptions tells Rollup to treat all external packages as pure unless their package.json overrides it.
How do I handle CSS imports in a component library?
List every CSS import path as a glob in the sideEffects array: ["*.css", "./dist/themes/*.css"]. Omitting CSS files causes bundlers to drop the stylesheet imports, producing unstyled components at runtime.
Related
- Advanced Tree-Shaking & Dependency Optimization — parent strategy covering the full optimizer pipeline
- Eliminating Dead Code with Modern Build Tools — how minifiers and bundlers strip unreachable code after
sideEffectspruning - Converting CJS Libraries to ESM for Better Bundling — prerequisite for making
sideEffectsanalysis effective on CJS dependencies - Refactoring Barrel Files to Reduce Bundle Bloat — eliminate the barrel-file anti-pattern that re-introduces retained modules
- How to Audit sideEffects in Large npm Packages — systematic approach for verifying purity in dependencies you do not control