Comparing Bundler Tree-Shaking Output
Benchmark how Rollup, esbuild, and webpack tree-shake the same library, why bundle sizes differ, and how sideEffects and module format change the result.
Two bundlers given the identical ESM entry point can produce outputs that differ by 30% or more, and the reason is never “one bundler is smarter” — it is a specific, traceable mechanism in how each one performs tree-shaking. This guide sets up a controlled benchmark so you can attribute every kilobyte difference between Rollup, esbuild, and webpack to a concrete cause instead of guessing from anecdote.
Prerequisites
Before running the benchmark below, confirm your environment matches these versions — tree-shaking behavior changed meaningfully across major releases of all three tools:
Canonical Configuration Block
Every comparison in this guide runs against the same fixture file and the same package.json. Isolating the input is the entire point — if the fixture or its manifest differs between runs, you are benchmarking your setup, not the bundlers.
// fixture/index.ts — identical input for all three bundlers
// usedExport is the only thing the consumer entry imports.
export const usedExport = (): string => "kept";
// unusedExport must never appear in any bundler's output.
export const unusedExport = (): string => "dropped";
// UnusedClass has no side effects in its constructor, so a
// tree-shaking-aware bundler can remove it entirely.
export class UnusedClass {
value = 42;
}
// sideEffectful runs a console.log at module evaluation time.
// Whether this survives shaking is the core of the comparison.
export const sideEffectful = /*#__PURE__*/ (() => {
return "computed once";
})();
{
"name": "bench-fixture",
"version": "1.0.0",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
}
}
}
The consumer entry that every bundler processes imports only usedExport, so a perfect tree-shaker would emit a bundle containing that one function and nothing else:
// consumer/entry.ts
import { usedExport } from "bench-fixture";
console.log(usedExport());
Bundler Comparison at a Glance
Step-by-Step Implementation
Step 1 — Build the Canonical Benchmark Fixture
Use the fixture and consumer entry from the Canonical Configuration Block above. Build it once as a local, uninstalled dependency so all three bundlers resolve it the same way:
mkdir -p bench/fixture bench/consumer
# copy fixture/index.ts and consumer/entry.ts into place, then:
cd bench && npm init -y && npm link ./fixture
Expected result: bench/consumer can import { usedExport } from "bench-fixture" and resolve it via the exports map defined above.
HAZARD PREVENTION
Symptom: Comparison numbers vary by more than a few bytes between repeated runs of the same bundler.
Root cause: A stale
node_modules/.cacheor a leftoverdist/directory from a previous run is being reused instead of a clean build.Fix: Run
rm -rf dist .cachebefore every measurement, and pin exact bundler versions inpackage.jsonrather than caret ranges — patch releases have changed tree-shaking heuristics before.
Step 2 — Build the Fixture With Each Bundler
Run all three with equivalent production settings: minification on, ESM input, single entry point.
// rollup.config.mjs
import { defineConfig } from "rollup";
import terser from "@rollup/plugin-terser";
export default defineConfig({
input: "consumer/entry.ts",
output: { file: "out/rollup.js", format: "es" },
plugins: [terser()],
treeshake: { moduleSideEffects: false },
});
# esbuild — CLI form, equivalent settings to the Rollup config above
npx esbuild consumer/entry.ts --bundle --minify --format=esm \
--tree-shaking=true --outfile=out/esbuild.js
// webpack.config.js
module.exports = {
mode: "production",
entry: "./consumer/entry.ts",
output: { filename: "webpack.js", path: __dirname + "/out" },
optimization: {
usedExports: true,
sideEffects: true,
concatenateModules: true,
},
};
npx webpack --config webpack.config.js
Expected result: Three files in out/ — rollup.js, esbuild.js, webpack.js — each claiming to contain only usedExport.
Step 3 — Measure Raw and Gzipped Output
Raw byte count alone is misleading because minifier verbosity differs; gzip size approximates what a browser actually downloads.
for f in out/rollup.js out/esbuild.js out/webpack.js; do
raw=$(wc -c < "$f")
gz=$(gzip -c "$f" | wc -c)
echo "$f raw=$raw gzip=$gz"
done
Typical output for this fixture (your numbers will vary by bundler version and minifier settings, but the ordering is representative):
out/rollup.js raw=118 gzip=112
out/esbuild.js raw=142 gzip=126
out/webpack.js raw=612 gzip=298
HAZARD PREVENTION
Symptom: webpack’s output contains
unusedExportandUnusedClasseven thoughusedExports: trueis set.Root cause:
sideEffectsin the fixture’spackage.jsonwas left unset (defaults totrue) or the entry was imported through a re-export barrel rather than directly, which forces webpack to treat the whole module as potentially live.Fix: Confirm
"sideEffects": falseis present in the fixture’s manifest and that the consumer imports the named export directly, not through an intermediateexport *file — see Eliminating Barrel File Anti-Patterns if a barrel is unavoidable in your real package.
Step 4 — Interpret the Differences
Do not stop at the byte counts — trace each gap to a mechanism:
- Rollup’s advantage comes from scope hoisting: it concatenates every module into one shared top-level scope during parsing, so its tree-shaking pass sees exactly one binding per export with no module wrapper overhead to strip later.
- esbuild’s mid-size output reflects a deliberate trade-off — its AST-based elimination is single-pass and extremely fast, but it does not perform the multi-pass scope hoisting Rollup does, so a few bytes of module-boundary scaffolding survive.
- webpack’s larger output, unless
concatenateModulessucceeds for every module in the graph, retains per-module wrapper functions (__webpack_require__glue) that Rollup and esbuild never emit. This is a structural cost of webpack’s module system, not a tree-shaking failure — thesideEffectsflag still correctly removedunusedExportandUnusedClass; the extra bytes are wrapper code, not dead code.
Both children of this guide dig into specific edge cases: Rollup vs esbuild vs webpack Bundle Size runs a larger, realistic library through the same harness, and sideEffects Field Edge Cases Across Bundlers covers where the three bundlers disagree on interpreting glob patterns and re-exports.
Tooling Validation
Byte counts tell you how much survived; source-map-explorer tells you what survived, module by module.
# Requires a sourcemap — add --sourcemap to each bundler invocation first
npx source-map-explorer out/webpack.js out/webpack.js.map
Sample output (treemap rendered in the terminal as a text summary when --html is omitted):
out/webpack.js
├─ webpack/runtime 184 bytes (61.7%)
├─ fixture/index.mjs 98 bytes (32.9%)
└─ consumer/entry.js 16 bytes (5.4%)
A large webpack/runtime slice relative to your actual code is the signal that concatenateModules did not fire — check the optimizationBailout reasons in webpack --json stats output to find why.
For Rollup and esbuild, rollup-plugin-visualizer and esbuild’s built-in --metafile flag serve the same purpose:
npx esbuild consumer/entry.ts --bundle --minify --metafile=meta.json --outfile=out/esbuild.js
npx esbuild-visualizer --metadata meta.json --filename=out/esbuild-report.html
Compatibility Matrix
| Bundler version | Scope hoisting | Respects sideEffects array |
Sub-path exports aware |
Honors /*#__PURE__*/ |
|---|---|---|---|---|
| Rollup 2.x | Yes | Yes | Partial | Yes |
| Rollup 3.x | Yes | Yes | Yes | Yes |
| Rollup 4.x | Yes | Yes | Yes | Yes |
| esbuild 0.14–0.16 | No | Yes | Yes | Yes |
| esbuild 0.17+ | No | Yes | Yes | Yes (--pure:) |
| webpack 4 | No (plugin only) | Yes (basic) | No | Partial |
| webpack 5.0–5.8x | Optional (concatenateModules) |
Yes | Yes | Yes |
| webpack 5.9x+ | Optional, more bailout diagnostics | Yes | Yes | Yes |
Rollup has treated scope hoisting as the default code-generation strategy since version 1; the matrix distinguishes versions only where sub-path exports resolution or sideEffects array handling changed. webpack’s concatenateModules remains opt-in even at 5.9x because it can conflict with certain dynamic import() splitting strategies.
Guides in This Section
- Rollup vs esbuild vs webpack Bundle Size — the same benchmark harness applied to a realistic, multi-module library, with a full results table.
- sideEffects Field Edge Cases Across Bundlers — glob patterns, CSS imports, and re-exports that each bundler resolves differently despite reading the same manifest field.
Related
- Implementing the
sideEffectsFlag Correctly — the manifest field every bundler in this comparison reads before deciding what to prune. - Optimizing Bundle Size for Frontend Libraries — turning the raw comparison numbers here into an enforced CI size budget for a real, browser-facing library.
- Advanced Dead Code Elimination Techniques — minifier-level passes and annotation strategies that apply on top of whatever a bundler’s tree-shaking pass leaves behind.
- Mastering the
package.jsonexports Field — the condition-ordering rules that determine which artifact each bundler in this comparison actually receives.