rgoussu@goussu: ~/library/frontend/build-ecosystem
~/library/frontend/build-ecosystem cat bundlers.md

The bundler world

# Why bundling exists, the generational story from webpack through esbuild to the Rust rewrites converging on Rolldown, and how apps and libraries bundle differently.

Conceptsaved 2026-08-10 #typescript#javascript#build#tooling#bundlers

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 import from 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.ts out" 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