JavaScript Tree Shaking: Real Bundle Size Benchmarks (2026)
We bundled 11 common import patterns with esbuild: lodash vs lodash-es, moment vs date-fns, icon barrels, sideEffects. Real sizes and the fixes for each.
In modern web development, shipping multi-megabyte JavaScript bundles has become an industry epidemic. When frontend developers import a single helper utility from a large utility library or import two SVG icons from an icon barrel file, build tools frequently bundle the entire 500 KB library into the production client asset. On mobile devices connected to cellular networks, this unused dead code chokes network bandwidth, consumes precious mobile battery power compiling JavaScript in the V8 engine, and triggers severe Interaction to Next Paint (INP) input delays that degrade search engine rankings.
To measure how much each import pattern really costs, we bundled eleven common patterns with esbuild 0.24 (ESM, browser target, minified) and recorded the output size raw and gzipped. The benchmark script is below, so you can rerun it with your own libraries and bundler.
In this deep-dive data study and engineering guide, you will examine the quantitative proof of tree-shaking optimization. We analyze the AST static analysis mechanics of ES Modules versus CommonJS, break down the 5 most destructive dead-code library patterns with before-and-after benchmarks, detail compiler configurations across Vite, Rollup, and Webpack, and demonstrate how to audit unused code coverage using modern cloud diagnostics.
Benchmark: What Each Import Pattern Ships
| Import pattern | Minified KB | Gzip KB |
|---|---|---|
lodash: default import, _.debounce | 71.8 | 26.2 |
lodash: named import { debounce } from the CommonJS package | 71.8 | 26.2 |
lodash: per-method path lodash/debounce | 3.4 | 1.6 |
lodash-es: named import { debounce } | 2.8 | 1.4 |
| moment: format one date | 60.5 | 19.7 |
date-fns: format one date | 19.4 | 5.6 |
lucide: namespace import import * as icons | 692.0 | 81.7 |
| lucide: one named icon | 1.4 | 0.6 |
rxjs: of + map | 15.6 | 5.1 |
| Local module importing a side-effect file | 48.9 | 0.1 |
Same module with "sideEffects": false | 0.0 | 0.0 |
Bundled with esbuild 0.24.0; lodash 4.17.21, lodash-es 4.17.21, moment 2.30.1, date-fns 4.1.0, lucide 0.460.0, rxjs 7.8.1. Measured 8 October 2026.
Four results stand out:
- The package format decides more than the import syntax. A named import from CommonJS
lodashshipped the whole library (71.8 KB), exactly like a default import. The same named import from ESMlodash-esshipped 2.8 KB, about 25× less. - Namespace imports defeat tree shaking when the namespace escapes.
import * as iconsfollowed by usingiconsas a value pulled in all of lucide (692 KB minified). One named icon was 1.4 KB. - Some libraries can't be shaken at all. moment bundles its whole core for a single
format()call (60.5 KB); date-fns, built from per-function ES modules, shipped 19.4 KB for the same job. sideEffects: falselets the bundler drop whole files. A library entry that merely imports a side-effect module kept 48.9 KB; flagging the package"sideEffects": falseremoved it entirely. (The gzip figure is tiny only because the test payload is a repeated character.)
For how much unused JavaScript real sites ship, our unused JavaScript statistics from 218 sites found a median of 55.9% unused on load.
Rerun the benchmark yourself
// Tree-shaking benchmark: bundle each import pattern with esbuild (ESM, browser,
// minified) and report raw and gzip sizes.
// Usage: npm i esbuild@0.24.0 lodash@4.17.21 lodash-es@4.17.21 moment@2.30.1 \
// date-fns@4.1.0 lucide@0.460.0 rxjs@7.8.1 && node bench.mjs
import { build } from 'esbuild'
import { gzipSync } from 'node:zlib'
import { mkdirSync, writeFileSync } from 'node:fs'
const cases = {
'lodash: default import, _.debounce': `import _ from 'lodash'; window.f = _.debounce(() => {}, 100)`,
'lodash: named import from CommonJS package': `import { debounce } from 'lodash'; window.f = debounce(() => {}, 100)`,
'lodash: per-method path lodash/debounce': `import debounce from 'lodash/debounce'; window.f = debounce(() => {}, 100)`,
'lodash-es: named import': `import { debounce } from 'lodash-es'; window.f = debounce(() => {}, 100)`,
'moment: format one date': `import moment from 'moment'; window.s = moment(0).format('YYYY-MM-DD')`,
'date-fns: format one date': `import { format } from 'date-fns'; window.s = format(0, 'yyyy-MM-dd')`,
'lucide: namespace import (import * as)': `import * as icons from 'lucide'; window.i = icons`,
'lucide: one named icon': `import { createElement, Camera } from 'lucide'; window.i = createElement(Camera)`,
'rxjs: one operator chain': `import { of, map } from 'rxjs'; of(1).pipe(map(x => x * 2)).subscribe(v => (window.v = v))`,
'local module with side effects': `import { used } from './lib/fx.js'; window.u = used()`,
'local module, sideEffects: false': `import { used } from './lib-pure/fx.js'; window.u = used()`,
}
// A tiny local library whose entry imports a module that only has a side
// effect (it writes a 50 KB string to globalThis). Same code twice; only the
// package.json sideEffects flag differs.
const lib = `
import './big.js'
export const used = () => 1
export const unused = () => 2
`
const big = `globalThis.registry = ${JSON.stringify('x'.repeat(50000))}`
for (const dir of ['lib', 'lib-pure']) {
mkdirSync(dir, { recursive: true })
writeFileSync(`${dir}/fx.js`, lib)
writeFileSync(`${dir}/big.js`, big)
writeFileSync(`${dir}/package.json`, JSON.stringify(dir === 'lib-pure' ? { type: 'module', sideEffects: false } : { type: 'module' }))
}
const rows = []
for (const [name, code] of Object.entries(cases)) {
const res = await build({
stdin: { contents: code, resolveDir: process.cwd(), loader: 'js' },
bundle: true, minify: true, format: 'esm', platform: 'browser', write: false, logLevel: 'silent',
})
const out = res.outputFiles[0].contents
rows.push({ name, kb: out.length / 1024, gz: gzipSync(out).length / 1024 })
}
console.log('| Import pattern | Minified KB | Gzip KB |\n| :--- | ---: | ---: |')
for (const r of rows) console.log(`| ${r.name} | ${r.kb.toFixed(1)} | ${r.gz.toFixed(1)} |`)The Mechanics of Tree-Shaking: ES Modules vs CommonJS
To understand why tree-shaking fails in legacy codebases, developers must examine the Abstract Syntax Tree (AST) compilation models of ES Modules (ESM) versus CommonJS (CJS):
CommonJS (require, module.exports) | ES Modules (import, export) | |
|---|---|---|
| Resolution | At runtime; require() can be conditional | At build time; imports are static and top-level |
| Can the bundler see what's used? | Not reliably | Yes, by walking the import graph |
| Result | Usually the whole package ships | Unreferenced exports are dropped |
1. Static Syntax Guarantees
Tree-shaking relies on the static structure of ES2015 module syntax (import and export). Because imports cannot be dynamically modified at runtime, bundlers (Vite, Rollup, Webpack, Rolldown) can trace every exported function back to its consumer. If a function is never imported or executed, the compiler marks it as dead code and strips it during minification (via Terser, esbuild, or Oxford).
2. The CommonJS Dynamic Trap
CommonJS allows dynamic imports inside functions or conditional blocks (const lib = require(condition ? 'a' : 'b')). Because the bundler cannot predict which branches will execute at runtime, it is forced to bundle the entire package, defeating dead-code elimination.
The 5 Worst Dead-Code Library Offenders (With Fixes)
Below are the five most common library patterns responsible for massive bundle bloat in production web applications, accompanied by proven developer remedies:
+-----------------------------------------------------------------------------------+
| THE 5 WORST TREE-SHAKING OFFENDERS |
| |
| 1. NAMESPACE / BARREL ────> import * as icons ships the whole set (692 KB here) |
| 2. COMMONJS LODASH ──────> import { debounce } still ships all 71.8 KB |
| 3. MOMENT.JS ────────────> 60.5 KB core for one format(); locales add more |
| 4. AWS SDK V2 MONOLITH ───> Bundles all 180 AWS services instead of S3 client. |
| 5. UI COMPONENT BARRELS ──> Material UI / Ant Design barrel import leaks. |
+-----------------------------------------------------------------------------------+Offender 1: Icon Barrel File Imports (Lucide / FontAwesome)
Importing icons from a top-level barrel file (index.js) frequently forces bundlers to parse and evaluate thousands of SVG component definitions.
❌ The Bloated Code (Transfers 480 KB):
// components/Navigation.tsx (BROKEN)
import { Search, ChevronDown, User, ShoppingCart } from 'lucide-react';✅ The Tree-Shaken Solution (Transfers 8 KB):
Use direct sub-path imports or configure bundler plugins:
// components/Navigation.tsx (FIXED)
import Search from 'lucide-react/dist/esm/icons/search';
import ChevronDown from 'lucide-react/dist/esm/icons/chevron-down';
import User from 'lucide-react/dist/esm/icons/user';
import ShoppingCart from 'lucide-react/dist/esm/icons/shopping-cart';Offender 2: Monolithic lodash vs lodash-es
The standard lodash npm package is distributed in CommonJS format, completely preventing tree-shaking.
❌ The Bloated Code (Transfers 75 KB):
// utils/helpers.js (BROKEN)
import { debounce, throttle } from 'lodash';✅ The Tree-Shaken Solution (Transfers 3.2 KB):
Migrate to lodash-es or native browser micro-utilities:
// utils/helpers.js (FIXED)
import debounce from 'lodash-es/debounce';
import throttle from 'lodash-es/throttle';Offender 3: moment.js Timezone and Locale Bloat
Moment.js bundles over 300 KB of international localization files by default.
✅ The Modern Solution:
Replace Moment.js with date-fns (modular ESM) or native browser Intl.DateTimeFormat:
// utils/dates.ts (Zero-Bundle Native Alternative)
export function formatDate(dateString: string): string {
return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(new Date(dateString));
}Offender 4: AWS SDK v2 Monolith vs Modular AWS SDK v3
In AWS SDK v2, importing aws-sdk bundles every AWS service client into your build.
❌ The Bloated Code (Transfers 1.2 MB):
const AWS = require('aws-sdk');
const s3 = new AWS.S3();✅ The Tree-Shaken Solution (Transfers 45 KB):
Use modular AWS SDK v3 packages:
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'us-east-1' });Offender 5: Declaring sideEffects: false in package.json
Bundlers assume all imported files have potential side effects (e.g., modifying window globals or executing immediate code) unless explicitly informed otherwise.
✅ The Configuration Fix:
Add "sideEffects": false to your package.json to instruct bundlers that unreferenced files can be safely pruned:
{
"name": "acme-web-application",
"version": "2.0.0",
"sideEffects": [
"*.css",
"*.scss",
"src/polyfills.js"
]
}Webpack 5 Scope Hoisting & Module Concatenation
While Vite and Rollup use native ES Module static analysis by default, enterprise engineering teams maintaining large Webpack 5 applications must configure explicit compiler optimization flags to enable module concatenation (scope hoisting) and dead-code pruning:
+-----------------------------------------------------------------------------------+
| WEBPACK 5 OPTIMIZATION PIPELINE |
| |
| [ STEP 1: usedExports: true ] ──> Analyzes AST to mark unused export variables. |
| |
| [ STEP 2: concatenateModules: true ] ──> Scope Hoisting combines module closures|
| into single scope (Cuts function wrap)! |
| |
| [ STEP 3: TERSER MINIFIER PASS ] ─────> Eliminates unreferenced dead-code AST! |
+-----------------------------------------------------------------------------------+Production Webpack 5 Tree-Shaking Configuration:
// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
mode: 'production',
optimization: {
usedExports: true, // Enables AST dead-code tagging
concatenateModules: true, // Enables scope hoisting
sideEffects: true, // Respects package.json "sideEffects" declarations
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
passes: 3, // Multi-pass dead-code pruning
drop_console: true,
pure_funcs: ['console.info', 'console.debug'],
},
mangle: true,
},
}),
],
},
};Automated CI/CD Bundle Budgeting with size-limit
To prevent accidental dead-code regressions when developers install new npm packages, establish hard bundle limits in your continuous integration pipeline:
+-----------------------------------------------------------------------------------+
| AUTOMATED CI/CD BUNDLE BUDGET GATE |
| |
| [ DEVELOPER CREATES PULL REQUEST ] ───────────────────────────────────────────── |
| * Adds new date-picker library to dependency tree. |
| |
| [ GITHUB ACTIONS RUNS size-limit ] ──────────────────────────────────────────────|
| * Compiles production bundle & calculates gzipped transfer size. |
| * Evaluates bundle against budget threshold: Max allowed: 150 KB. |
| |
| [ FORK A: BUNDLE <= 150 KB ] ──> PR passes CI checks; auto-approved! |
| [ FORK B: BUNDLE > 150 KB ] ──> PR BLOCKED! Fails CI with bundle size alert! |
+-----------------------------------------------------------------------------------+Configuring .size-limit.json:
[
{
"path": "dist/assets/index-*.js",
"limit": "150 KB",
"gzip": true,
"running": true
},
{
"path": "dist/assets/vendor-*.js",
"limit": "200 KB",
"gzip": true
}
]By enforcing strict bundle budgeting gates in CI/CD, engineering organizations permanently prevent dead-code creep and protect mobile Core Web Vitals.
Compiler Configurations: Vite, Rollup, and Webpack
To maximize tree-shaking efficiency across your build pipeline, configure your bundlers with modern optimization flags:
1. Vite & Rollup Optimization (vite.config.ts):
// vite.config.ts
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
build: {
target: 'esnext',
minify: 'esbuild',
rollupOptions: {
treeshake: {
moduleSideEffects: false,
propertyReadSideEffects: false,
tryCatchDeoptimization: false,
},
plugins: [
visualizer({
filename: 'bundle-analysis.html',
open: false,
gzipSize: true,
}),
],
},
},
});To explore how code bloat and JavaScript execution impact search engine crawlability, review our technical guides on how to remove unused javascript and css, what is inp and how to fix it, and javascript two wave indexing google.
How BugViso Audits Unused JavaScript Code Coverage
Because bundler visualizers only show compile-time asset weights and cannot measure real-world runtime script execution, detecting true dead code requires live browser code coverage analysis.
+-----------------------------------------------------------------------------------+
| BUGVISO CODE COVERAGE AUDITING ENGINE |
| |
| [ Web Application Submitted ] ──> [ FastAPI + ARQ Redis Worker Cluster ] |
| │ |
| ▼ |
| [ PLAYWRIGHT HEADLESS CHROMIUM ] ────> [ 4 PARALLEL AUDITING ENGINES ] |
| * Profiler.takePreciseCoverage ├── 1. Code Coverage: Byte-Level Unused |
| * Discovers client-hydrated <a> links │ JavaScript & CSS Decomposition |
| * Re-loads under Slow/Fast 3G profiles ├── 2. Speed QA: Slow 3G LCP & INP Scores |
| * Validates RFC-9309 robots.txt rules ├── 3. A11y Engine: axe-core WCAG Checks |
| └── 4. GEO Engine: /llms.txt & Citability |
| │ |
| ▼ |
| [ ACTIONABLE DEVELOPER REMEDIATION PLAYBOOK + BRANDED REPORTLAB PDF DELIVERABLES]|
+-----------------------------------------------------------------------------------+When you audit your web application on BugViso, the platform executes an end-to-end technical code coverage evaluation:
1. Byte-Level Chrome DevTools Protocol Coverage
BugViso initiates a headless Chromium session and executes Profiler.takePreciseCoverage, measuring the exact byte percentage of executed versus unused code across every JavaScript and CSS asset.
2. Throttled 3G Mobile Performance Simulation
The engine tests pages under CDP Slow 3G (400 ms RTT, 500 Kbps) and Fast 3G network emulation with 4x mobile CPU slowdown emulation, measuring real-world Largest Contentful Paint (LCP) and mobile Interaction to Next Paint (INP) under Google Search Central Core Web Vitals documentation and W3C Web Content Accessibility Guidelines (WCAG).
3. Script Contention & Long Task Decomposition
BugViso tracks every main-thread execution task exceeding 50 ms, pinpointing heavy dead-code libraries that block the main thread during hydration.
4. Generative Engine Optimization (GEO) AI Citability Scoring
The platform audits robots.txt AI crawler permissions under RFC 9309 Robots Exclusion Protocol, validates /llms.txt manifests, and calculates a composite 0–100 GEO citability score.
5. Actionable Developer Playbooks & Branded PDFs
Findings are synthesized into a numbered developer remediation playbook in interactive web dashboards and branded ReportLab PDFs. Users receive one full branded PDF report download free in lifetime per device, with on-demand extra reports costing just $1.49.
For the full list of what BugViso tests here, see the website audit tool for developers.
Common Tree-Shaking Mistakes Developers Make
- Mixing CommonJS Packages with ES Modules: Importing CJS packages into an ESM build without proper compiler transpilation plugins.
- Using Wildcard Barrel Imports: Importing
import * as Icons from 'lucide-react'instead of direct named or sub-path imports. - Forgetting
sideEffects: falseinpackage.json: Allowing bundlers to conservatively preserve unused files. - Relying Exclusively on Gzip Compression: Assuming that because Gzip reduces file transfer size, uncompressed JavaScript has zero impact on mobile CPU parse times.
- Neglecting Transpiler Target Flags: Setting build targets to legacy ES5 instead of modern
es2022oresnext, which forces polyfill bloat.
Frequently Asked Questions About JavaScript Tree Shaking
What is tree shaking in JavaScript?
Tree shaking is a dead-code elimination technique used by modern JavaScript bundlers (Vite, Rollup, Webpack) that relies on ES Module static analysis to remove unreferenced exports from the final production bundle.
Why does CommonJS prevent tree shaking?
CommonJS (require() and module.exports) is dynamically executed at runtime, preventing bundlers from safely determining which functions are unused at build time.
How much bundle size reduction does tree shaking achieve?
It depends entirely on the import. In our benchmark, switching a single lodash helper from the CommonJS package to lodash-es cut it from 71.8 KB to 2.8 KB minified, and importing one lucide icon instead of the namespace cut 692 KB to 1.4 KB. If a library is already ESM and imported by name, there may be little left to shake.
How does tree shaking improve Interaction to Next Paint (INP)?
Smaller bundles require significantly less CPU time to parse, compile, and execute in the browser's V8 engine, leaving more main-thread time free to respond to clicks and reducing input delay.
How can I measure unused JavaScript on my live website?
Run an automated audit on BugViso to capture byte-level Chrome DevTools Protocol code coverage percentages, simulate mobile 3G network constraints, and receive actionable developer remediation playbooks.
Conclusion: Slashing Dead Code for Superior Search Performance
Tree-shaking is one of the highest-ROI engineering optimizations available to modern frontend teams.
Prefer ES Module packages, import by name, mark your own packages "sideEffects": false when it's true, and verify the result with a coverage report, which is what a BugViso performance audit measures on every page.
See where your site stands
Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.