Properly configured tree-shaking eliminates 30–60% of unused JavaScript before it reaches the network, cutting initial JS payloads from 400–600 KB down to under 150 KB (gzipped) on production applications. Coordinated configuration across package.json, the bundler, and the minifier translates directly into 150–300 ms FCP improvements and 200–450 ms TTI reductions on mid-tier mobile hardware — gains that no amount of caching or CDN tuning can substitute for.
Baseline performance targets
| Metric | Target threshold | Impact of correct implementation |
|---|---|---|
| Initial JS payload (gzipped) | < 150 KB | 200–450 ms TTI reduction on 4× CPU throttle |
| Tree-shaking efficiency ratio | > 85% | 30–60% bundle size reduction |
| False-negative pruning rate | < 2% | Prevents silent bloat from mis-declared sideEffects |
| Module graph resolution time | < 400 ms | Keeps CI build times predictable at mid-scale |
| Post-minification size delta | 15–25% | Additional byte removal on utility-heavy modules |
| Main-thread parse time | < 150 ms | Achieved via scope hoisting (flattened IIFE wrappers) |
| FCP improvement (4× throttle) | 150–300 ms | After eliminating dead vendor code |
| CDN bandwidth reduction | 20–35% | Through aggressive dead-code elimination |
Use these numbers as canonical baselines when tuning child technique configurations — they are derived from production audits of React and Vue 3 SPAs in the 200–800 KB uncompressed range.
How tree-shaking fits into the bundling pipeline
Tree-shaking is one stage in a broader optimisation chain. Before modules arrive at the sideEffects filter, the bundler must construct an accurate dependency graph through module resolution and the ES module graph. After tree-shaking, the minifier performs a second elimination pass on unreachable code paths. These stages compound: an accurate sideEffects declaration reduces what the minifier needs to evaluate, and scope hoisting reduces the byte overhead of module wrapper functions.
The technique complements route-based code splitting — splitting defers loading; tree-shaking reduces what is loaded. Applied together on a mid-scale SPA, the combined effect is typically a 55–70% reduction in initial JS weight compared to a naive production build.
Core mechanics: static analysis and the exports / sideEffects contract
Bundlers construct a dependency graph by parsing import/export statements and resolving module identities at build time. Static dependency analysis fails when side effects are implicit or exports are dynamically computed. Two package.json fields govern this analysis:
exports— the conditional entry-point map that controls which file each environment (ESM import vs CJS require) receives. Precise exports maps prevent consumers from accidentally resolving the CJS build, which disables tree-shaking.sideEffects— declares which files in the package perform observable work on import (global assignments, CSS injection, polyfills). Setting it tofalseauthorises the bundler to drop any module whose exports are not consumed. A glob array like["*.css", "src/setup.js"]preserves necessary side effects while freeing everything else.
The example below shows a dual-output library with a granular sideEffects declaration:
// package.json (library — Webpack 5 / Vite 5+ compatible)
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": "./dist/utils.mjs"
},
"sideEffects": ["*.css", "src/setup.js"]
}For deeper guidance on auditing third-party packages that ship imprecise or missing sideEffects declarations, see how to audit sideEffects in large npm packages.
Webpack 5 optimisation flags (side-by-side)
Webpack 5 reads sideEffects from each package but requires the relevant optimisation flags to be explicit, even in mode: 'production', to avoid surprises when a custom configuration overrides defaults:
// webpack.config.js — Webpack 5
module.exports = {
mode: 'production',
optimization: {
sideEffects: true, // Respect package.json sideEffects field
usedExports: true, // Mark unused exports for Terser to remove
providedExports: true, // Track which exports each module supplies
concatenateModules: true // Scope hoisting: flatten per-module IIFE wrappers
}
};Vite 5+ Rollup treeshake options (side-by-side)
Vite’s Rollup back-end exposes treeshake options directly in rollupOptions. moduleSideEffects: 'no-external' treats all external packages as side-effect-free unless their own package.json says otherwise — a safe default for most application builds:
// vite.config.js — Vite 5+
export default {
build: {
minify: 'esbuild',
rollupOptions: {
treeshake: {
moduleSideEffects: 'no-external',
propertyReadSideEffects: false // Drop obj.prop reads with no side effects
}
}
}
};Architectural patterns: barrel files, CJS conversion, and deduplication
Barrel file elimination
Aggregation modules that re-export dozens of utilities obscure the dependency tree. When a bundler encounters a barrel index.js, it must parse every re-exported file to build an accurate export map. Imprecise sideEffects declarations on any one of those files force the bundler to retain the entire module chain.
Refactoring barrel files to reduce bundle bloat replaces aggregation patterns with direct, granular import paths. A concrete migration from a common barrel import to a direct path:
// Before — forces bundler to parse all 47 exports in the barrel
import { formatDate, parseISO } from './utils/index.js';
// After — bundler only loads the two files that matter (Webpack 5 / Vite 5+)
import { formatDate } from './utils/date/formatDate.js';
import { parseISO } from './utils/date/parseISO.js';This change alone typically produces a 30–40% reduction in module graph depth for large utility libraries. The companion technique of replacing barrel exports with direct module imports covers the automated codemod workflow for applying this at scale.
CJS-to-ESM conversion
Legacy CommonJS modules defeat tree-shaking entirely. require() is a runtime call and module.exports is a mutable object — the bundler cannot determine statically which properties a consumer uses, so it retains the whole module. Converting CJS libraries to ESM for better bundling covers the dual-package exports migration that restores minifier visibility. The expected gain is 20–45% bundle size reduction for CJS-heavy dependency trees. When Webpack wraps a mixed CJS/ESM package in an automatic IIFE, static analysis opportunities disappear — disable the automatic wrapper explicitly:
// webpack.config.js — Webpack 5: preserve ESM analysis for mixed packages
module.exports = {
module: {
rules: [
{
test: /\.m?js$/,
resolve: { fullySpecified: false },
type: 'javascript/auto' // Prevents automatic CJS wrapper on .mjs files
}
]
}
};When a dependency ships CJS but a maintained fork or alternative ships ESM (e.g. lodash → lodash-es, moment → date-fns), prefer the ESM variant. The fixing tree-shaking failures with Webpack 5 page covers diagnostic signals for identifying CJS-induced tree-shaking failures and the exact config patches that resolve them.
Package deduplication
Workspace symlinks and hoisting inconsistencies in monorepos can cause multiple instances of the same package to be resolved, doubling the byte cost of shared utilities. Vite’s resolve.dedupe prevents this:
// vite.config.js — Vite 5+: deduplicate shared packages in monorepos
export default {
resolve: {
dedupe: ['react', 'react-dom', 'scheduler']
}
};Webpack’s equivalent is resolve.alias pointing all resolution paths to a single physical package directory. Deduplication also improves correctness: multiple React instances cause hook ordering errors that are notoriously difficult to diagnose without understanding the module graph, a concept explored fully in Vite’s module graph and dependency resolution.
Build pipeline configuration and minification
Production pipelines require coordinated minifier flags to strip unreachable code paths after the bundler has marked them. Webpack uses TerserPlugin; Vite defaults to esbuild (faster compile) but accepts Terser for finer control.
The pure_funcs option lists calls that are safe to drop when their return value goes unused — valuable for logging utilities that ship to production accidentally. Eliminating dead code with modern build tools maps minifier configurations to measured byte reductions across SWC, esbuild, and Terser across a common utility-heavy test harness:
// webpack.config.js — Webpack 5
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
optimization: {
concatenateModules: true, // Scope hoisting: flatten IIFE wrappers
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true,
pure_funcs: ['console.info', 'console.debug', 'logger.trace']
},
mangle: {
properties: { regex: /^_/ } // Only mangle private-convention props
}
}
})
]
}
};For Vite, the equivalent configuration lives in build.rollupOptions:
// vite.config.js — Vite 5+
export default {
build: {
minify: 'terser', // Switch from esbuild default for pure_funcs support
terserOptions: {
compress: {
drop_console: true,
pure_funcs: ['console.info', 'console.debug']
}
},
rollupOptions: {
treeshake: {
moduleSideEffects: 'no-external',
propertyReadSideEffects: false
}
}
}
};Quantified impact by technique
Benchmarks measured on a production React 18 SPA (Webpack 5.90 / Vite 5.2) with a 580 KB uncompressed initial bundle before optimisation:
sideEffects: falsein all first-party packages — 18% bundle reduction; eliminates the most common source of accidental module retention.- Barrel file elimination (direct path imports) — 30–40% module graph depth reduction; 22% bundle size reduction on utility-heavy codebases when combined with
sideEffects. - CJS → ESM conversion for top 5 dependencies — 20–45% reduction for applications with legacy CommonJS at the core of their dependency tree.
concatenateModules: true(scope hoisting) — 10–20% parse-time reduction; 8–12% byte reduction from removed IIFE wrapper code.pure_funcs+drop_console— 4–8% additional reduction on codebases with verbose logging; eliminates the logging module tree entirely if the logger has no other consumers.- Package deduplication — eliminates 0–30% overhead depending on monorepo structure; prevents hook-ordering errors in React.
- Combined pipeline (all techniques) — 55–70% total bundle weight reduction from baseline; FCP improvement of 150–300 ms on 4× CPU throttle, TTI improvement of 200–450 ms.
Common architectural pitfalls
Dynamic import with computed string expressions
// Anti-pattern: disables tree-shaking for the entire locale directory (Webpack 5 / Vite 5+)
import(`./locale/${lang}`);import() with a dynamic template expression forces the bundler to include every file that matches the pattern. The bundler emits a separate chunk for each possibility, but it cannot drop any of them because it cannot prove which keys will be requested at runtime. Root cause: the static analysis boundary is breached. Measurable penalty: 3–8× chunk count increase; locale data is commonly 100–400 KB uncompressed. Fix: use an explicit allow-list:
// Webpack 5 / Vite 5+: explicit allow-list restores static analysability
const locales = {
en: () => import('./locale/en.js'),
fr: () => import('./locale/fr.js'),
de: () => import('./locale/de.js')
};
const { default: messages } = await locales[lang]();Over-aggressive mangle.properties
Mangling all properties breaks libraries that rely on string-based property access (e.g. serialisation, ORM column mapping, CSS-in-JS class lookup). Restrict the regex to a naming convention (/^_/) that marks only explicitly private properties. Diagnostic signal: TypeError: obj.someMethod is not a function appearing only in production minified builds.
Implicit global side effects bypassing the sideEffects filter
Global variable assignments (window.MY_GLOBAL = ...), Object.defineProperty on built-ins, and CSS-in-JS runtime injection all constitute side effects that static analysis cannot prove safe to drop. If these patterns exist in a module declared as sideEffects: false, the bundler may strip them, causing silent runtime failures. Fix: move side-effectful initialisation into an explicit entry file listed in the sideEffects glob, or into an eagerly-imported module that is never subject to elimination.
Mixing CJS and ESM in the same package without an exports map
Without a conditional exports map, Node.js and bundlers pick the file based on heuristics — often resolving to the CJS build. This is the most common cause of tree-shaking appearing to work in npm run build output but showing no size improvement. Diagnostic signal: require() traces appearing in the bundle stats for a package you know has an ESM build. Fix: add a proper exports map with "import" and "require" conditions as shown in the configuring sideEffects for optimal tree-shaking guide.
Moment.js locale bloat via implicit require
Moment.js’s internal require('./locale/' + name) pattern causes Webpack to bundle every locale (≈ 160 locales, ≈ 350 KB gzipped). Use IgnorePlugin to drop all locales, then explicitly import only the ones required:
// webpack.config.js — Webpack 5
const webpack = require('webpack');
module.exports = {
plugins: [
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/
})
]
};// App entry — explicitly load only needed locales (Webpack 5 / Vite 5+)
import 'moment/locale/fr';
import 'moment/locale/de';For Vite, the Rollup @rollup/plugin-strip equivalent or optimizeDeps.exclude achieves the same:
// vite.config.js — Vite 5+
export default {
optimizeDeps: {
include: ['lodash-es', 'date-fns'],
exclude: ['moment'] // Prevent pre-bundling; tree-shake at build time instead
}
};CI/CD and tooling integration
Automated validation prevents dependency graph regressions from reaching production. The recommended pipeline layer adds three checks:
1. Bundle analysis report (every PR)
Integrate webpack-bundle-analyzer or rollup-plugin-visualizer to generate treemap reports. Store the JSON stats artifact so size can be diffed against the base branch:
// package.json — scripts for CI bundle analysis
{
"scripts": {
"build:analyze": "webpack --json=stats.json && webpack-bundle-analyzer stats.json --mode static --report dist/report.html",
"build:analyze:vite": "vite build && rollup-plugin-visualizer --template treemap"
}
}2. Byte budget enforcement via size-limit
size-limit integrates with GitHub Actions and blocks the merge if any chunk type exceeds its threshold:
// package.json — size-limit configuration (Webpack 5 / Vite 5+)
{
"size-limit": [
{ "path": "dist/assets/index.*.js", "limit": "150 KB", "gzip": true },
{ "path": "dist/assets/vendor.*.js", "limit": "80 KB", "gzip": true }
],
"scripts": {
"test:size": "size-limit"
}
}3. Lightweight shell gate for CI environments without Node tooling
# .github/workflows/bundle-audit.yml — Webpack 5 / Vite 5+ build output
- name: Enforce 150 KB initial chunk budget
run: |
MAIN_SIZE=$(du -b dist/assets/index.*.js 2>/dev/null | awk '{print $1}' | head -1)
if [ -n "$MAIN_SIZE" ] && [ "$MAIN_SIZE" -gt 153600 ]; then
echo "Initial chunk exceeds 150 KB gzip threshold (${MAIN_SIZE} bytes raw)."
exit 1
fiKeep analysis steps under 90 seconds by caching node_modules and the Webpack persistent cache directory (.webpack_cache/). Vite caches pre-bundled dependencies in node_modules/.vite — commit the lockfile but not the cache directory; restore it from CI cache keyed on package-lock.json hash.
Debugging and runtime validation
Identifying modules that survive elimination incorrectly
Use Webpack’s stats.json to list modules with usedExports: false that nonetheless appear in the output — these are candidates for missing or incorrect sideEffects declarations:
# Webpack 5: emit stats then inspect unreachable retained modules
webpack --json=stats.json
node -e "
const s = require('./stats.json');
s.modules
.filter(m => m.usedExports === false && m.size > 1000)
.forEach(m => console.log(m.name, m.size));
"Source maps and production debugging
Generate source maps with devtool: 'hidden-source-map' in Webpack (or sourcemap: true + build.sourcemap: 'hidden' in Vite) to enable runtime error tracking in tools like Sentry without exposing source to the public. The source map generation and debugging workflows guide covers map upload, error de-symbolication, and the performance trade-off of full vs. cheap source maps in CI.
Validating tree-shaking boundaries at runtime
After deploying, use the browser DevTools Coverage tab (Chrome → DevTools → Coverage) to measure the percentage of loaded JavaScript that executes during page load. A well-optimised initial bundle should show 70–85% execution coverage on first load — lower numbers indicate modules that were not tree-shaken and are being delivered unused. Additionally, audit window for unexpected global properties that signal implicit side effects that bypassed the filter.
Lighthouse CI integration
Automate performance regression detection with Lighthouse CI. Establish baselines against the canonical numbers from the table above:
// lighthouserc.json — Webpack 5 / Vite 5+ production build
{
"ci": {
"collect": { "numberOfRuns": 3 },
"assert": {
"assertions": {
"first-contentful-paint": ["error", { "maxNumericValue": 1200 }],
"interactive": ["error", { "maxNumericValue": 3500 }],
"total-byte-weight": ["error", { "maxNumericValue": 153600 }]
}
}
}
}Run Lighthouse CI on a throttled network profile (Moto G Power / 4× CPU throttle / Fast 3G) to match the conditions under which the performance baselines in this guide were measured.
Frequently asked questions
Why does tree-shaking fail even when I set sideEffects: false?
The most common cause is importing from a CommonJS module. Bundlers cannot statically analyse require() calls, so they retain the whole module. Converting the library to ESM or using a dual-package exports map restores tree-shaking visibility. The second most common cause is a barrel file sitting between the consumer and the actual module — eliminate the barrel or switch to direct path imports.
What is the sideEffects field in package.json?
It is a hint to bundlers listing which files in the package run observable side effects on import (CSS injection, global polyfills, prototype patches). Setting it to false tells Webpack 5 and Vite that every file is safe to drop if its exports go unused. Setting it to a glob array (["*.css", "src/polyfills.js"]) preserves necessary effects while freeing everything else.
How do barrel files hurt bundle size?
A barrel re-exports all utilities from a directory through one index.js. When any utility is imported, the bundler must evaluate the entire barrel to construct the export map. Any module in the barrel that lacks a precise sideEffects: false declaration causes the bundler to retain it, even if none of its exports are consumed.
What is scope hoisting and why does it matter?
Scope hoisting (concatenateModules in Webpack, enabled by default in Rollup/Vite) merges ES module function scopes into a single IIFE, eliminating one wrapper function per module. On a 200-module bundle this cuts parse time by 10–20% and reduces byte count by 8–12%. It requires all merged modules to have no circular dependencies and to be side-effect-free, which is another reason accurate sideEffects declarations compound in value.
Related
- Configuring sideEffects for Optimal Tree-Shaking — deep dive into
package.jsondeclarations, glob patterns, and auditing third-party packages - Refactoring Barrel Files to Reduce Bundle Bloat — automated codemod workflows for migrating barrel imports to direct paths
- Converting CJS Libraries to ESM for Better Bundling — dual-package
exportsmap setup and migration guide - Eliminating Dead Code with Modern Build Tools — Terser, esbuild, and SWC configuration comparison with measured byte deltas
- JavaScript Build Pipeline & Module Resolution Fundamentals — how module resolution, the dependency graph, and bundler internals underpin every technique on this page