Overview
The browser is the one deployment target with no package manager and no filesystem:
it cannot resolve node_modules, and loading a real app's thousands of modules over
HTTP one by one is untenable. A bundler resolves the whole module graph ahead of time
and emits a few optimized files — and once it holds the graph, it can tree-shake dead
exports, split code along routes, minify, and pull CSS and images into the same
dependency story. The tool generations since 2012 retell one plot twice: first
JavaScript bundlers competing on features, now native rewrites competing on speed —
converging on a small Rust core everything else wraps.
Key points
- What a bundler actually does: resolve every
importfrom the entry to a closed module graph, transform each module (TS → JS, JSX, CSS modules), then link — tree-shake unused exports, split shared chunks, hash filenames for caching, minify. The asset graph (CSS, images, workers) rides the same mechanism. - webpack — the configurable incumbent: loaders and plugins made everything bundleable and its ecosystem unmatched; the cost is configuration sprawl and JS-speed builds. Still everywhere in the installed base, rarely chosen fresh.
- Rollup — ESM-first and the library standard: clean, readable output, proper tree-shaking before anyone else, and a plugin API so good it became the lingua franca (Vite's plugin system is Rollup-compatible by design).
- esbuild — the shock: written in Go, parallel, 10-100x faster, transpiles and bundles. Deliberately limited extensibility — it declines to be a platform, which is why others embed it rather than extend it.
- swc — the Rust transpiler, not a full bundler: powers Next.js's compiler,
ts-node --swc, Jest transforms — the fast-emit half of the check/emit split. - Parcel — the zero-config stance, one line: everything automatic, beloved for small apps, never the ecosystem centre.
- The Rust convergence: Turbopack (Next.js's bundler), Rolldown — the Rust Rollup successor, production-ready and becoming Vite's core — and Oxc, the parser/resolver/minifier toolkit under it. The endgame: one native core, many faces.
- Library bundlers ride on top: tsup (esbuild-based), tsdown (Rolldown-based),
unbuild — thin CLIs that turn "entry in, ESM +
.d.tsout" into one command. - Apps and libraries bundle differently: an app bundles everything — deps included, minified, hashed. A library emits clean unminified ESM plus declarations and leaves dependencies external — bundling them in would defeat the consumer's dedupe and tree-shaking.
Who wraps whom — the dependency direction of the current toolscape:
flowchart LR
TSUP["tsup (library CLI)"] --> ESBUILD["esbuild (Go)"]
TSDOWN["tsdown (library CLI)"] --> ROLLDOWN["Rolldown (Rust)"]
VITE["Vite"] -->|"dev: dependency pre-bundle"| ESBUILD
VITE -->|"prod: build"| ROLLUP["Rollup (JS)"]
VITE -->|"rolldown-vite: both roles"| ROLLDOWN
ROLLDOWN --> OXC["Oxc (parser, resolver, minifier)"]
NEXT["Next.js"] --> TURBOPACK["Turbopack (Rust)"]
NEXT --> SWC["swc (Rust transpiler)"]
Details
The bundlers compared
| Bundler | Written in | Niche | One-line stance |
|---|---|---|---|
| webpack | JavaScript | Legacy apps, deep customization | Everything is possible, nothing is fast |
| Rollup | JavaScript | Libraries; Vite's prod engine | Clean ESM output; the plugin API everyone speaks |
| esbuild | Go | Transpile + bundle inside other tools | Speed via refusing to be extensible |
| swc | Rust | Transpiler embedded in frameworks | The emit half only; no linking |
| Parcel | JS/Rust | Zero-config small apps | Convention so total there is no config |
| Turbopack | Rust | Next.js | Vertical integration; not a general tool |
| Rolldown | Rust | Rollup successor, Vite's new core | Rollup's API at esbuild's speed — the convergence point |
| tsup / tsdown / unbuild | wrappers | Library builds | One command from entry to publishable package |
Why tree-shaking needs ESM
Static import/export makes the dependency graph analysable without executing
anything — a bundler can prove an export unused and drop it. CJS require() is a
runtime call that can be conditional, computed, or wrapped, so CJS dependencies
largely defeat tree-shaking; one CJS package deep in the graph can drag its whole
bulk into the bundle. This, more than aesthetics, is why "ship ESM" became the
library-publishing rule.
Examples
A complete library build — clean ESM, types, deps external by default:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true, // emit .d.ts alongside
sourcemap: true,
clean: true, // wipe dist/ first
});
tsup externalizes everything in dependencies automatically; the consumer's bundler
sees clean per-module ESM it can tree-shake.
Related
- The TypeScript build ecosystem — parent summary: where the bundler layer sits in the stack.
- Vite deep dive — the tool that assembled this world's pieces into the frontend default.
- Package managers: npm, pnpm, yarn, bun —
the layer below: the
node_modulestree bundlers resolve from. - Building TypeScript for the backend — where bundling is optional but increasingly chosen anyway.
- Web performance — code-splitting, tree-shaking and caching budgets are what the linking step exists to serve.