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.


Guides in This Section



Back to Tree-Shaking & Bundle Optimization