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

Rollup vs esbuild vs webpack tree-shaking approach Three-column comparison. Rollup scope-hoists into a single module scope and drops unused named exports but requires ESM input, producing the smallest output at the slowest build speed. esbuild performs AST-based dead code elimination without hoisting, respects sideEffects arrays, and is the fastest builder with mid-sized output. Webpack 5 relies on usedExports and sideEffects flags with optional module concatenation, and CJS interop can block pruning, typically producing the largest output unless tuned. Rollup 4 esbuild 0.2x webpack 5 Scope-hoists into one scope Drops unused named exports Requires ESM input to shake Smallest output, slowest build AST-based DCE, no hoisting Fastest build in this comparison Respects sideEffects arrays Fastest build, mid-size output usedExports + sideEffects flags concatenateModules is optional CJS interop can block pruning Largest output unless tuned

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/.cache or a leftover dist/ directory from a previous run is being reused instead of a clean build.

Fix: Run rm -rf dist .cache before every measurement, and pin exact bundler versions in package.json rather 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 unusedExport and UnusedClass even though usedExports: true is set.

Root cause: sideEffects in the fixture’s package.json was left unset (defaults to true) 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": false is present in the fixture’s manifest and that the consumer imports the named export directly, not through an intermediate export * 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 concatenateModules succeeds 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 — the sideEffects flag still correctly removed unusedExport and UnusedClass; 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 comparable benchmark harness A single fixture package and a single consumer entry file feed three separate builds, one per bundler, each configured identically. Each build produces a raw and gzipped byte count that can be compared directly. One input, three builds, one metric fixture + consumer imports exactly one symbol Rollup --format es, minified esbuild --bundle --minify webpack mode production raw + gzip bytes both reported, always Change one variable at a time or the comparison measures the harness

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.

Raw versus gzipped output size Three bundler outputs compared on two measures. Rollup and esbuild are close on both. Webpack is noticeably larger raw but compresses proportionally better, so the gzipped gap is smaller than the raw gap suggests. The raw gap and the transfer gap are not the same gap Rollup raw 4187 gzip 1642 esbuild raw 4402 gzip 1701 webpack raw 6318 gzip 2088 Raw bytes show what survived elimination; gzipped bytes show what a user downloads

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



Back to Tree-Shaking & Bundle Optimization