---
title: "18 — Module Bundlers — Two Jobs, One Dependency Graph"
uid: module-bundlers
tags: ["bundler", "esbuild", "webpack", "swc", "vite", "rolldown", "roadmap:frontend", "rollup"]
excerpt: "A bundler does exactly two jobs — transform and bundle — and the modern split is a fast dev server plus a Rust-grade production build."
date: 2026-08-12T18:35:09+0000
source: https://www.aveshina.my.id/en/blog/module-bundlers
---

The tools between npm run dev and a working page were a magic black box until I named their two jobs. The unsticking: **a bundler does two jobs — transform and bundle** [1]. Once I could name those two jobs separately, the whole zoo of tools (Webpack, Rollup, Parcel, esbuild, SWC, Vite, Rolldown) collapsed into "which of the two jobs, and how fast."

## The dependency graph, first

Browsers historically couldn't load a project's worth of JavaScript files the way I wrote them — scattered across folders, importing each other with import/export, mixing .tsx and .css and images. So a bundler's starting point is the same in every tool: starting from an entry file, follow every import/require and build a **dependency graph** of the whole project [1]. Then it walks that graph doing its two jobs.

```figure
<svg viewBox="0 0 720 300" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="The two jobs of a bundler. On the left, a dependency graph of modules (entry node App pointing to Button, Card, utils; Card pointing to utils; utils pointing to a third-party icon). Two arrows leave the graph. The top arrow labelled TRANSFORM goes to a box that compiles TS/JSX and transpiles syntax. The bottom arrow labelled BUNDLE goes to a box that resolves imports, code-splits, and tree-shakes. Both feed a single optimized output asset on the right.">
  <defs>
    <marker id="barrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- dependency graph (left) -->
    <g>
      <text x="120" y="30" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">dependency graph</text>
      <!-- App (entry) -->
      <rect x="85" y="135" width="70" height="36" rx="6" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
      <text x="120" y="158" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">App</text>
      <!-- Button -->
      <rect x="15" y="60" width="70" height="32" rx="6" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
      <text x="50" y="80" font-size="11" fill="#334155" text-anchor="middle">Button</text>
      <!-- Card -->
      <rect x="155" y="60" width="70" height="32" rx="6" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
      <text x="190" y="80" font-size="11" fill="#334155" text-anchor="middle">Card</text>
      <!-- utils -->
      <rect x="15" y="220" width="70" height="32" rx="6" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
      <text x="50" y="240" font-size="11" fill="#334155" text-anchor="middle">utils</text>
      <!-- icon (3rd party) -->
      <rect x="155" y="220" width="70" height="32" rx="6" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.2"/>
      <text x="190" y="240" font-size="11" fill="#422006" text-anchor="middle">icon pkg</text>
      <!-- edges -->
      <path d="M110,135 L60,92" fill="none" stroke="#cbd5e1" stroke-width="1.2"/>
      <path d="M130,135 L180,92" fill="none" stroke="#cbd5e1" stroke-width="1.2"/>
      <path d="M110,171 L60,220" fill="none" stroke="#cbd5e1" stroke-width="1.2"/>
      <path d="M180,92 C140,150 90,180 70,220" fill="none" stroke="#cbd5e1" stroke-width="1.2" stroke-dasharray="3 3"/>
      <path d="M85,236 L155,236" fill="none" stroke="#cbd5e1" stroke-width="1.2"/>
    </g>

    <!-- arrows to the two jobs -->
    <path d="M235,110 C300,90 320,70 370,70" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#barrow)"/>
    <text x="300" y="62" font-size="11" font-weight="700" fill="#1e1b4b" text-anchor="middle">① TRANSFORM</text>

    <path d="M235,190 C300,210 320,230 370,230" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#barrow)"/>
    <text x="300" y="250" font-size="11" font-weight="700" fill="#052e16" text-anchor="middle">② BUNDLE</text>

    <!-- transform box -->
    <rect x="380" y="48" width="160" height="56" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="460" y="72" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">compile + transpile</text>
    <text x="460" y="90" font-size="10" fill="#475569" text-anchor="middle">TS / JSX → plain JS</text>

    <!-- bundle box -->
    <rect x="380" y="208" width="160" height="56" rx="8" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="460" y="232" font-size="12" font-weight="700" fill="#052e16" text-anchor="middle">resolve + split + shake</text>
    <text x="460" y="250" font-size="10" fill="#475569" text-anchor="middle">code-split · tree-shake</text>

    <!-- output -->
    <path d="M540,76 C590,90 600,130 615,150" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#barrow)"/>
    <path d="M540,236 C590,220 600,180 615,160" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#barrow)"/>
    <rect x="610" y="125" width="90" height="56" rx="8" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="655" y="150" font-size="12" font-weight="700" fill="#500724" text-anchor="middle">optimized</text>
    <text x="655" y="166" font-size="12" font-weight="700" fill="#500724" text-anchor="middle">assets</text>
  </g>
</svg>
```

## Job one — transform

The first job is **translating files into something the browser can run.** TypeScript gets compiled to JavaScript; JSX becomes plain function calls; newer syntax gets *transpiled* (rewritten into an older version browsers already understand) [3][4]. Anything that isn't vanilla browser-runnable JS — CSS imports, image imports, .vue/.svelte files — gets turned into JS in this pass too.

This is where the older tool, Babel, used to live. Babel is correct and mature but written in JavaScript, so on a big codebase the transform pass alone could take tens of seconds. The whole recent speed story in bundling is "rewrite the transform in a faster language." **SWC** does exactly that — a Rust-based compiler for JS/TypeScript/JSX that's orders of magnitude faster than Babel and now powers Next.js's compilation underneath [3]. **esbuild** is the parallel move with a different engine: a Go-based bundler/minifier that does the transform and bundling at near-instant speed (a *minifier* shrinks a file by stripping whitespace and shortening names), deliberately trading a few advanced plugin features for raw throughput [4]. SWC and esbuild aren't competitors so much as two answers to the same question — "can the transform stop being the slow part?" — built in Rust and Go respectively.

## Job two — bundle

The second job is **walking that graph and turning many files into the few assets the browser actually loads** [1][2]. That means three sub-tasks I used to blur together:

- **Resolve** the import graph — figure out which modules depend on which, and in what order.
- **Code-split** — break the output into chunks so the browser loads a route's code lazily instead of one giant file up front.
- **Tree-shake** — drop exported code that no importer ever uses, so dead code never ships [5].

This is the job **Rollup** made its name on. Rollup is built around ES modules (the modern import/export system) and its tree-shaking is the reference implementation — it produces unusually small, clean output, which is why it became the default for libraries [5]. **Webpack**, the legacy incumbent, does both jobs and does them with a configuration surface so large it became a meme; it earned its dominance by handling every asset type and edge case, at the cost of being slow and config-heavy [2]. **Parcel** sits at the opposite end of that dial: a zero-config bundler that detects your file types and transforms them automatically, favoring simplicity and rapid prototyping over control [7].

## The Vite shift — dev and prod are not the same job

The single idea that reorganized all of this for me: **the dev server and the production build don't have to be the same tool, and they shouldn't be.** During development I want instant feedback; for production I want the smallest, most optimized bundle. Vite's whole design is to stop forcing one tool to do both badly.

**Vite** splits the work. In dev it doesn't bundle at all — it serves source files as **native ES modules** (the import/export system served straight to the browser, which resolves the imports itself), and it uses esbuild to transform files on the fly. The result is a dev server that starts in milliseconds regardless of project size, and Hot Module Replacement (HMR) — swapping in a changed file without reloading the whole page — that feels instantaneous [6]. For production, where native ESM would be too slow to ship, Vite hands off to a real bundler — historically **Rollup**, increasingly **Rolldown** — for code-splitting, tree-shaking, and minification [6].

```figure
<svg viewBox="0 0 700 240" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Vite splits the bundler into two paths. Left box labelled Dev: source files go to a fast compiler (esbuild), and are served to the browser over native ESM with no bundling, giving instant HMR. Right box labelled Prod: source files go to Rolldown/Rollup, which code-splits and tree-shakes into optimized assets that ship to users.">
  <defs>
    <marker id="vbarrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- DEV side -->
    <rect x="25" y="30" width="300" height="180" rx="10" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="175" y="55" font-size="13" font-weight="700" fill="#1e1b4b" text-anchor="middle">DEV (Vite)</text>
    <rect x="55" y="75" width="110" height="34" rx="6" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
    <text x="110" y="96" font-size="11" fill="#334155" text-anchor="middle">source files</text>
    <path d="M165,92 L195,92" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#vbarrow)"/>
    <rect x="195" y="75" width="100" height="34" rx="6" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="245" y="96" font-size="11" font-weight="700" fill="#422006" text-anchor="middle">esbuild</text>
    <path d="M245,109 L245,140" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#vbarrow)"/>
    <rect x="65" y="145" width="230" height="40" rx="6" fill="#fff" stroke="#6366f1" stroke-width="1.2"/>
    <text x="180" y="162" font-size="11" font-weight="700" fill="#1e1b4b" text-anchor="middle">native ESM to browser · no bundle</text>
    <text x="180" y="177" font-size="10" fill="#475569" text-anchor="middle">instant HMR</text>

    <!-- PROD side -->
    <rect x="375" y="30" width="300" height="180" rx="10" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="525" y="55" font-size="13" font-weight="700" fill="#052e16" text-anchor="middle">PROD (Vite)</text>
    <rect x="405" y="75" width="110" height="34" rx="6" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
    <text x="460" y="96" font-size="11" fill="#334155" text-anchor="middle">source files</text>
    <path d="M515,92 L545,92" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#vbarrow)"/>
    <rect x="545" y="75" width="110" height="34" rx="6" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="600" y="96" font-size="11" font-weight="700" fill="#500724" text-anchor="middle">Rolldown</text>
    <path d="M600,109 L600,140" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#vbarrow)"/>
    <rect x="415" y="145" width="230" height="40" rx="6" fill="#fff" stroke="#16a34a" stroke-width="1.2"/>
    <text x="530" y="162" font-size="11" font-weight="700" fill="#052e16" text-anchor="middle">code-split · tree-shake · minify</text>
    <text x="530" y="177" font-size="10" fill="#475569" text-anchor="middle">optimized assets shipped</text>
  </g>
</svg>
```

## Rolldown — Rust-ifying the production bundler

**Rolldown** is the piece that clicked last. Rollup's tree-shaking is great but, being JavaScript, it's the slow part of Vite's production build on large apps. Rolldown is a from-scratch **Rust rewrite of Rollup**, designed as a drop-in replacement — same module-resolution semantics and plugin compatibility, but with the bundling pass running at native speed [8]. Its whole reason for existing is to remove that last JS-shaped bottleneck so Vite's dev and prod paths can both be fast without trading away Rollup-grade output quality.

That's the broader arc worth seeing: the transform job moved to Rust/Go (SWC, esbuild), the dev server moved to native ESM (Vite), and now the bundling job itself is moving to Rust (Rolldown). Webpack, Rollup-in-JS, and Babel aren't wrong — they're the mature generation the faster tools are standing on the shoulders of.

## How I map them

When a tool name comes up now, I just ask which of the two jobs it's doing:

- **esbuild / SWC** — the transform job, fast (Go / Rust).
- **Webpack** — both jobs, legacy, maximal config.
- **Rollup** — the bundle job, library-grade tree-shaking, written in JS.
- **Rolldown** — the bundle job, Rollup's replacement in Rust.
- **Parcel** — both jobs, zero config.
- **Vite** — the orchestrator: native ESM + esbuild in dev, Rollup/Rolldown in prod.

Two jobs, one graph, and a clear answer to "which job, and how fast." That's the whole category. The mystery was that I'd been treating six tools as six unrelated things; they're really six points on the same two-axis grid.

## References

[1] webpack, "Concepts — Modules and the dependency graph," webpack Documentation. [Online]. Available: [https://webpack.js.org/concepts/dependency-graph/](https://webpack.js.org/concepts/dependency-graph/)

[2] webpack, "What is webpack?," webpack Documentation. [Online]. Available: [https://webpack.js.org/concepts/](https://webpack.js.org/concepts/)

[3] SWC Project, "SWC (Speedy Web Compiler) — Overview," 2024. [Online]. Available: [https://swc.rs/docs/](https://swc.rs/docs/)

[4] E. Wallace, "esbuild — An extremely fast bundler for the web," 2024. [Online]. Available: [https://esbuild.github.io/](https://esbuild.github.io/)

[5] Rollup, "Introduction — Tree-shaking," Rollup Documentation. [Online]. Available: [https://rollupjs.org/introduction/](https://rollupjs.org/introduction/)

[6] Vite, "Why Vite — Problems & Solutions," Vite Documentation. [Online]. Available: [https://vite.dev/guide/why.html](https://vite.dev/guide/why.html)

[7] Parcel, "Getting Started," Parcel Documentation. [Online]. Available: [https://parceljs.org/docs/](https://parceljs.org/docs/)

[8] Rolldown, "Guide — Introduction," Rolldown Documentation. [Online]. Available: [https://rolldown.rs/guide/](https://rolldown.rs/guide/)

```quiz
Q: A bundler's two core jobs are…
- minify and serve
- transform (compile/transpile) and bundle (resolve, split, tree-shake)
- lint and format
correct: 1
explain: Transform turns non-browser code (TS, JSX, CSS imports) into runnable JS; bundle walks the dependency graph to resolve, code-split, and tree-shake into optimized assets.

Q: Why does Vite feel instant in development even on a huge project?
- It pre-bundles everything into one file on startup
- It skips bundling entirely and serves source over native ES modules, using esbuild to transform files on the fly
correct: 1
explain: Vite's dev server lets the browser do dependency resolution via native ESM and only transforms individual files with esbuild, so server start and HMR don't depend on project size.

Q: SWC and esbuild are both best described as…
- fast transform/compilers written in Rust (SWC) and Go (esbuild), meant to replace Babel-era JS transforms
- production bundlers that compete with Rollup
correct: 0
explain: SWC (Rust) and esbuild (Go) focus on the transform job — compiling TS/JSX far faster than Babel. They're the speed layer other tools (Next.js, Vite) build on.

Q: What is Rolldown?
- A zero-config bundler like Parcel, rewritten in Rust
- A from-scratch Rust rewrite of Rollup, designed as a drop-in replacement for faster production bundling
correct: 1
explain: Rolldown keeps Rollup's module-resolution semantics and tree-shaking quality but moves the bundling pass into Rust so Vite's production build isn't bottlenecked by a JS bundler.

Q: Tree-shaking is the bundler's job of…
- dropping exported code that no importer ever uses, so dead code never ships
- splitting the output into lazy-loaded chunks per route
correct: 0
explain: Tree-shaking removes unused exports from the final bundle. Splitting into lazy chunks is code-splitting — a separate sub-task of the bundle job.
```
