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.
Designing a Benchmark That Measures the Right Thing
A bundler comparison is only useful if the difference it reports comes from the bundlers rather than from the fixture. Three design choices decide that, and getting any of them wrong produces numbers that look authoritative and mean nothing.
Fix the input, vary only the tool. The same fixture package, the same consumer entry file, the same minifier settings, and the same output format for all three runs. Comparing a Rollup build that used Terser against an esbuild build that used esbuild’s own minifier measures minifiers, not tree-shaking. Where a tool cannot be configured to match, say so in the results rather than normalising it away.
Measure the consumer bundle, not the library build. What matters to a user is how many bytes of your package survive into their application after they import one symbol. Measuring the size of your own dist/ measures your build configuration, which is a different question with a different answer.
Report gzipped and raw. Raw bytes reflect how much code survived elimination; gzipped bytes reflect what crosses the network. The two can move in opposite directions — a bundler that duplicates a helper inline produces more raw bytes that compress extremely well — and a single number hides that.
A fourth choice is worth making explicitly: which consumer shape you benchmark. Three are worth measuring separately, because a package can score well on one and badly on another.
- Single-symbol import — the tree-shaking headline number. How much survives when a consumer wants one function?
- Namespace import (
import * as lib) — the pessimistic case. Some bundlers retain the whole namespace; others still eliminate unused members if no dynamic access occurs. - Deep subpath import (
import { x } from "my-lib/feature") — the number that shows whether your subpath exports genuinely isolate features, or whether every subpath drags in the root entry through a shared internal module.
The gap between the first and third numbers is the most actionable diagnostic a library author can collect. If importing my-lib/feature costs nearly as much as importing everything, the feature module is transitively pulling in the barrel, and the fix is in your source layout rather than in any bundler setting.
Interpreting a Result That Surprises You
Benchmarks produce surprises, and most of them have one of four explanations. Working through them in order is faster than re-reading configuration files.
The output includes a runtime shim. webpack and Rollup both inject interop helpers when a CommonJS module enters an ESM graph. A few hundred bytes of __toESM and __commonJS wrappers in the output means something in the chain resolved to a CommonJS file — usually because the import condition is missing for a subpath and the resolver fell back to require. This is a packaging bug that shows up as a size difference.
A dependency was inlined that should have been external. Library builds should mark runtime dependencies external; a benchmark of the consumer bundle will include them legitimately, but a suspiciously large result often means a dependency was bundled into your published artefact as well, so consumers get two copies. Check your published files with grep for a distinctive string from the dependency.
The minifier, not the bundler, made the difference. Re-run with minification disabled everywhere. If the ordering of the three tools changes, you were measuring minifiers.
The fixture is too small to be representative. Below a few kilobytes, fixed overhead — module wrappers, the entry shim, license comments preserved by the minifier — dominates, and the percentage differences are noise. Scale the fixture until the retained code is several times the fixed overhead before drawing conclusions.
for f in out/rollup.mjs out/esbuild.mjs out/webpack.mjs; do
raw=$(wc -c < "$f")
gz=$(gzip -c "$f" | wc -c)
printf '%-22s raw=%6s gzip=%6s\n' "$(basename "$f")" "$raw" "$gz"
done
rollup.mjs raw= 4187 gzip= 1642
esbuild.mjs raw= 4402 gzip= 1701
webpack.mjs raw= 6318 gzip= 2088
A spread like that one is normal and mostly uninteresting: the two smaller numbers differ by module-wrapper overhead, while webpack’s larger figure usually traces to a retained interop path. What would be interesting is one tool producing double the others — that is a signal about your package’s structure, not about the tool, and the retention chain is where to look next.
Two habits keep the numbers trustworthy over time. The first is pinning the bundler versions in the harness the same way you pin any other dependency: tree-shaking behaviour changes between minor releases, and an unpinned harness silently re-baselines itself, turning a genuine regression into “the numbers moved a bit”. The second is committing the results next to the harness as a plain text file. A results file in the repository means the next person to run the benchmark compares against a recorded state rather than their memory, and a pull request that changes the numbers shows the change as a reviewable diff. Record the tool versions in that same file, so a future reader can tell a real regression from an upstream behaviour change without re-deriving the whole setup.
It is also worth writing down what the benchmark deliberately does not measure. Parse and execute time, memory footprint, and the cost of a package’s runtime initialisation are all invisible to a byte count, and for some libraries they matter more than raw size does. Record those separately if they matter to your consumers, and be explicit in the results file about which question each number answers. A package that ships 40 kB of straightforward code can be cheaper at startup than one shipping 12 kB that builds several large lookup tables at module scope — and the second one will win every size comparison while losing the metric the consumer actually feels.
Reading the Two Numbers Together
Raw and gzipped bytes answer different questions, and a comparison that reports only one of them will eventually mislead somebody.
A repeated wrapper or a duplicated helper inflates raw size while compressing almost to nothing, so a package that looks 50% worse on raw bytes may cost a consumer only a few hundred extra bytes on the wire.
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.