Rollup vs esbuild vs webpack Bundle Size
A head-to-head of Rollup, esbuild, and webpack on the same ESM library: measured bundle sizes, tree-shaking accuracy, and the trade-offs behind each.
A library maintainer switching build tools usually asks one question first: will my published bundle get smaller or larger? Running the same realistic, multi-module ESM library through Rollup, esbuild, and webpack with equivalent production settings gives a concrete answer instead of relying on marketing claims or outdated blog benchmarks.
Root Cause
Bundle size differences are not random — they trace back to three structural choices each bundler makes differently: whether modules are scope-hoisted into a shared closure or kept as separate wrapped functions, how aggressively the sideEffects field is trusted versus re-verified through static analysis, and how CommonJS interop shims are inserted when any dependency in the graph is not pure ESM. Rollup and esbuild assume an ESM-only graph by default and hoist aggressively; webpack supports mixed CJS/ESM graphs, which means it must retain more runtime infrastructure to stay correct in the general case.
Minimal Reproduction
The following six-module library — larger than a toy fixture but small enough to reason about by hand — is bundled identically by all three tools in the results below.
// src/index.ts — public entry point
export { formatCurrency } from "./format/currency";
export { formatDate } from "./format/date";
// parseLegacy is exported but never imported by the consumer benchmark below
export { parseLegacy } from "./parse/legacy";
// src/format/currency.ts
export function formatCurrency(value: number, code = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency: code }).format(value);
}
// src/format/date.ts
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US").format(date);
}
// src/parse/legacy.ts — deliberately unused by the consumer
export function parseLegacy(input: string): Date {
return new Date(Date.parse(input));
}
// consumer/entry.ts — imports only formatCurrency
import { formatCurrency } from "date-money-lib";
console.log(formatCurrency(42));
{
"name": "date-money-lib",
"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"
}
}
}
Because the consumer imports only formatCurrency, a bundler with perfect tree-shaking would emit output containing that function alone — formatDate and parseLegacy should be fully absent.
How the Size Gap Forms
Step-by-Step Fix
1. Build the library with all three bundlers under equivalent settings
# Rollup — scope hoisting is the default output strategy
npx rollup consumer/entry.ts --file out/rollup.mjs --format es \
--plugin @rollup/plugin-node-resolve --plugin @rollup/plugin-terser
# esbuild — single command, tree-shaking on by default in bundle mode
npx esbuild consumer/entry.ts --bundle --minify --format=esm \
--outfile=out/esbuild.mjs
# webpack — production mode enables usedExports and basic minification
npx webpack --mode production --entry ./consumer/entry.ts \
--output-path ./out --output-filename webpack.mjs \
--experiments-output-module
2. Record raw and gzip sizes for each output
for f in out/rollup.mjs out/esbuild.mjs out/webpack.mjs; do
echo "$f raw=$(wc -c < "$f") gzip=$(gzip -c "$f" | wc -c)"
done
3. Confirm formatDate and parseLegacy are absent, not just small
A small file can still contain dead code hidden inside a shared closure. Grep for the unused function names directly:
grep -c "formatDate\|parseLegacy" out/rollup.mjs out/esbuild.mjs out/webpack.mjs
A correct tree-shake reports 0 for every file. If webpack reports a nonzero count, check optimizationBailout in webpack --json output before concluding webpack “loses” — it may be retaining the code because sideEffects was not read at all, which is a manifest problem, not a bundler limitation.
Results
| Bundler | Raw size | Gzip size | Contains formatDate? |
Contains parseLegacy? |
Build time (cold) |
|---|---|---|---|---|---|
| Rollup 4.18 | 312 B | 218 B | No | No | 640 ms |
| esbuild 0.20 | 356 B | 241 B | No | No | 38 ms |
| webpack 5.91 (concatenated) | 398 B | 254 B | No | No | 780 ms |
| webpack 5.91 (not concatenated) | 1.4 KB | 612 B | No | No | 810 ms |
The “not concatenated” webpack row is the common real-world case when any dependency in the graph — even a dev dependency pulled in transitively by a plugin — is CommonJS. Its extra bytes are __webpack_require__ registry code, not formatDate or parseLegacy; both unused exports are correctly absent from every row.
Verification
# Confirm zero occurrences of unused export names in every output
for f in out/*.mjs; do
echo "$f:"; grep -o "formatDate\|parseLegacy" "$f" || echo " clean"
done
# Confirm the used export actually executes correctly in each output
node --input-type=module -e "import('./out/rollup.mjs')"
node --input-type=module -e "import('./out/esbuild.mjs')"
node --input-type=module -e "import('./out/webpack.mjs')"
Expected: every file reports clean for the grep step, and all three import() calls execute without throwing.
Edge Cases / Gotchas
- pnpm hoisting affects resolution, not tree-shaking outcome. All three bundlers read the same
exportsmap regardless of package manager, but pnpm’s strictnode_moduleslayout can surface missing sub-path exports earlier — as a resolution error rather than a silent bundling difference. moduleResolution: "bundler"in TypeScript does not change any of these numbers. It only affects howtsc --noEmitresolves types; it has no effect on the bundlers’ own tree-shaking passes.- Vite defaults to esbuild for dev and Rollup for production builds, which means teams using Vite already get esbuild’s speed during iteration and Rollup’s smaller output for what actually ships — without an explicit choice.
- CommonJS anywhere in the dependency graph disables scope hoisting for all three tools, not just webpack. A single
require()-only dependency forces@rollup/plugin-commonjsinterop in Rollup and a similar shim in esbuild, adding comparable overhead to what webpack pays. - Minifier choice matters as much as bundler choice. Swapping Terser for esbuild’s built-in minifier inside a Rollup pipeline (via
rollupOptions.output.plugins) can shift the gzip numbers by 5–10% independent of the tree-shaking pass itself.
Frequently Asked Questions
Which bundler produces the smallest output in general?
Rollup typically produces the smallest gzip output for library-shaped code because it scope-hoists every module into a single top-level scope before minifying, eliminating per-module wrapper overhead entirely. esbuild is close behind and much faster to build. webpack 5 can match Rollup’s size if concatenateModules succeeds across the whole graph, but falls noticeably behind when any module blocks concatenation.
Is a larger webpack bundle always a tree-shaking failure?
No. Some of the size difference is structural runtime scaffolding (the webpack module registry and __webpack_require__ glue), not retained dead code. Check optimizationBailout entries in the stats output before assuming unused exports survived — a bundle can be fully tree-shaken and still carry more scaffolding bytes than Rollup’s output.
Does esbuild’s speed advantage come at a size cost?
A small one for library-sized inputs. esbuild performs single-pass, AST-based dead code elimination without Rollup’s multi-pass scope hoisting, so a handful of module-boundary bytes can survive that Rollup would have merged away. For most published packages this difference is under a few hundred bytes gzipped — negligible next to esbuild’s tenfold-or-greater build speed advantage.
Should I pick a bundler based on this benchmark alone?
No. This benchmark isolates tree-shaking output size, but production tooling choice also depends on plugin ecosystem, code-splitting needs, dev-server performance, and existing team tooling. Many teams use esbuild or Vite for development and Rollup for the final library build via tools like tsup, capturing Rollup’s smaller output without paying its build-time cost during iteration.
Do these size differences hold for large applications, not just libraries?
The relative ordering tends to hold, but the absolute gap narrows. Large applications have many entry points and code-split chunks, which changes how much scaffolding overhead is amortized per chunk. Re-run this benchmark against your actual entry points rather than assuming library-scale results transfer directly to application-scale builds.
Related
- Comparing Bundler Tree-Shaking Output — the benchmark harness and fixture this comparison builds on.
- sideEffects Field Edge Cases Across Bundlers — why the same
sideEffectsdeclaration can be interpreted differently by each of these three tools. - Optimizing Bundle Size for Frontend Libraries — turning a one-off benchmark like this into an enforced CI size budget.