Build Tools: Vite, esbuild & Rolldown

Write a modern web app and you end up with hundreds of small files: your own modules, plus whatever comes down from node_modules. That factoring is great for humans — one idea per file, easy to find, easy to test. It is terrible for a browser to load directly. Hundreds of files means hundreds of network round-trips, no dead-code removal, no minification, and none of the TypeScript, JSX, or import ... from 'some-package' shorthand that browsers don’t understand on their own.

A build tool sits in that gap. It reads the module graph you wrote, resolves every import, and produces a handful of small, optimized files a browser can actually download fast. That’s the whole job. Everything below is detail on how it does it, and why the tooling behind it got rewritten in Rust between 2023 and 2026.

If you’ve read Modules, npm & Semantic Versioning, you already know the module system a bundler operates on. This picks up where that leaves off.

What a bundler actually does

Four jobs, roughly in order:

  • Resolution and bundling. Follow every import starting from your entry file, build the full graph of modules, and stitch them into as few output files as sensibly possible. A bare specifier like import { z } from 'zod' gets resolved to a real file inside node_modules — the browser can’t do that lookup, so the bundler does it ahead of time.
  • Transformation. Turn TypeScript, JSX, and newer syntax into plain JavaScript the target browsers run. This is transpilation, and it’s the slowest part at scale.
  • Optimization. Drop unused code (tree-shaking), shorten names and remove whitespace (minification), and split the graph into chunks that load on demand.
  • Asset handling. Import a CSS file, an SVG, or a PNG from JavaScript and have it come out the other side as a real URL, inlined data, or a hashed file — with the reference rewritten to match.
source graphmain.tsrouter.tsutils.tschart.tsxzoddate-fnsbundlerresolve · transformoptimize · emitoutput/index-a1b2.jschart-9f3c.jsstyle-77de.css
A bundler reads the entry module, walks every import to build the full graph, and emits a small set of hashed, optimized files. The many-to-few reduction is the point.

Two very different jobs: development and production

Here’s the insight that reshaped the whole toolchain. Building for production and running a dev server feel similar — both process your modules — but they optimize for opposite things.

Production wants the smallest, fastest-loading output. It runs once before you deploy, so it can afford to be thorough: bundle everything, shake out dead code, minify, hash filenames for caching. Time budget: seconds to minutes, and nobody’s watching each keystroke.

Development wants the fastest possible feedback. You save a file and you want the browser to reflect it in milliseconds. Bundling your entire app on every keystroke — the old model — got unbearably slow as apps grew. Startup could take 30+ seconds before you saw anything.

Vite’s answer was to stop bundling during development altogether.

your source.ts / .tsx / .cssdevelopment · vitepre-bundle deps once(cached)serve native ESMtransform on requestbrowser followsimports · HMR patchesproduction · buildbundle whole graphtree-shake · splitminify · hash namesemit source mapsa few hashed filesready for a CDN
Development and production take different routes through the same source. Dev serves modules over native ESM on demand; production bundles and optimizes everything ahead of time.

Why the dev server is instant

When you run the dev server, Vite doesn’t build your app. It starts an HTTP server and hands the browser your index.html. The browser hits a <script type="module">, sees an import, and asks the server for that one module. Vite transforms just that file — stripping types, compiling JSX — and returns it. The browser’s own module loader takes it from there, requesting each further import as it goes. Nothing is bundled; modules are served the moment they’re needed and not before.

Two refinements make that practical:

Dependency pre-bundling. Your own source changes constantly, but node_modules doesn’t. Some packages also ship as hundreds of tiny internal files — a popular date library or lodash-es can be 600+ modules. If the browser requested each over native ESM, that’s 600 round-trips for one import. So on first start, Vite pre-bundles each dependency into a single file and caches it under node_modules/.vite. Next start, if nothing changed, it skips straight to serving.

HMR that stays surgical. When you save a file, Hot Module Replacement swaps just that module in the running page — no full reload, and your app state survives. Because the module graph is native ESM, Vite only has to invalidate the path from the edited file up to its nearest HMR boundary. Update time stays roughly constant whether your app is 50 modules or 5,000.

App.tsxSidebar.tsxChart.tsxHMR boundaryBars.tsx ✎scale.tsuntoucheduntouched
On save, HMR walks up from the edited module to the nearest boundary and replaces only that subtree. Untouched modules — and your app's live state — are left alone.

The fast transformers: Go and Rust

Stripping types and compiling JSX is pure per-file work, and it’s where old JavaScript-based toolchains spent most of their time. The fix was to rewrite the transformer in a fast, parallel, compiled language.

  • esbuild (written in Go, 2020) was the first to land. It parses, transforms, and minifies 10–100x faster than the JS tools it replaced, mostly by being compiled and heavily multi-threaded. Vite used esbuild for dependency pre-bundling and on-the-fly transforms for years.
  • SWC (written in Rust) did the same for the parts of the ecosystem that standardized on Rust — it powered Next.js’s compiler and Deno’s tooling.
  • Oxc (the Oxidation Compiler, also Rust) is the newest, and it’s the parser/transformer layer under the current Vite toolchain.

These are transformers, not full bundlers — they turn one file into one file, fast. The bundling story took longer to consolidate.

Production bundling: Rollup, then Rolldown

For production, Vite long relied on Rollup — the bundler that popularized tree-shaking and produces famously clean, flat output. Rollup is written in JavaScript, though, so on large apps the production build was the slow step, sometimes taking minutes.

That’s the gap Rolldown fills: a Rust bundler that implements Rollup’s plugin API and config surface, so it’s a mostly drop-in replacement, but runs 10–30x faster. It’s built by the same group behind Vite, Vitest, and Oxc (VoidZero), which is why the whole toolchain now shares one Rust core instead of stitching together a Go transformer and a JS bundler.

The consolidation is real, not hypothetical. As of July 2026:

  • Rolldown 1.0 shipped stable in early 2026.
  • Vite 8 shipped March 12, 2026, with Rolldown as the single default bundler — no opt-in flag. It handles both development and production, so the Go-based esbuild dependency that used to sit in the dev path is gone; Oxc covers transforms.
  • In June 2026, Cloudflare acquired VoidZero and pledged to keep Vite, Vitest, Rolldown, and Oxc open source, with a fund for the ecosystem.
before — mixed toolchainesbuild · Godev transform + pre-bundleRollup · JSproduction bundle (slow)consolidates to Rustnow — one Rust coreOxc — parse + transformRolldown — bundle (dev + prod)
The toolchain consolidated from a split of Go transformer plus JS bundler toward one Rust core. Names and years are approximate to the shift, not exact release dates.

Tree-shaking: dropping what you never used

Import one helper from a library that exports fifty, and you’d rather not ship the other forty-nine. Tree-shaking is the dead-code elimination that makes that true: the bundler figures out which exports are actually reachable from your entry and drops the rest.

It works because of ES modules. import and export are static — their shape is fixed before any code runs, so the bundler can prove, by reading the source, that an export is never referenced. CommonJS require is a function call resolved at runtime; the bundler can’t be sure what you’ll pull off the module object, so it has to keep everything. This is a concrete reason to prefer ESM dependencies.

// math.js — a small library with two exports
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }

// app.js — you only use one
import { add } from './math.js';
console.log(add(2, 3));

multiply is never reachable, so it never reaches the bundle.

math.js exportsadd ✓ usedmultiply ✗divide ✗pow ✗reachabilityfrom entrybundleadd only
Tree-shaking keeps the reachable exports and prunes the rest. Static ESM is what lets the bundler prove an export is unused.

The sideEffects catch

Tree-shaking is conservative on purpose. If importing a file does something beyond exposing exports — registers a global, patches a prototype, injects a stylesheet — the bundler can’t safely drop it even when its exports look unused, because removing it would change behavior. That “does something on import” is a side effect.

A library tells the bundler it’s safe with the sideEffects field in its package.json:

{
  "name": "my-utils",
  "sideEffects": false
}

false promises every file is pure — import a file, use nothing from it, and it can be dropped whole. If some files do have side effects (a CSS import, a polyfill), list them so those survive:

{
  "sideEffects": ["./src/polyfill.js", "*.css"]
}

Code-splitting: not everything up front

Tree-shaking makes the bundle smaller. Code-splitting makes the first load smaller by not putting everything in one file. The lever is the dynamic import() — a function-like form that returns a promise for a module and, crucially, tells the bundler “this is a seam; put what’s behind it in its own chunk.”

button.addEventListener('click', async () => {
  // this module — and its dependencies — load only on click
  const { renderChart } = await import('./chart.js');
  renderChart(data);
});

A static import at the top of a file pulls its target into the current chunk. A dynamic import() carves the target off into a separate file that’s fetched only when that line runs. Nobody who never opens the chart downloads the charting code.

main.jsimport ui.jsimport util.jsawait import(‘./chart.js’)index-a1b2.jsmain + ui + utilloads immediatelychart-9f3c.jsfetched on clicksmall firstpaintdeferreduntil needed
A static import folds a module into the main chunk. A dynamic import() splits it into a separate file fetched on demand, shrinking the initial download.

The bundler also splits automatically at the seams it can prove: shared code that two dynamic chunks both need gets hoisted into a common chunk so it isn’t downloaded twice. Route-based splitting — one chunk per page — falls out of this naturally when each route is behind a dynamic import(), which is how framework routers wire up lazy pages under the hood.

The finishing passes: minify, hash, map

Three more things happen on the way to production output, and they’re worth understanding because they show up in your build folder.

Minification strips everything the engine doesn’t need: whitespace, comments, long variable names. calculateMonthlyTotal becomes a. It’s purely mechanical and typically shrinks code 30–60% before compression. In the current toolchain this is handled in Rust (Oxc/Rolldown for JS, Lightning CSS for stylesheets).

Content hashing puts a fingerprint of each file’s contents into its name — index-a1b2c3.js. Change one byte and the hash changes, which changes the URL. That lets you tell a CDN to cache these files forever: a new deploy produces new names, so browsers fetch the new file instead of a stale cached one. Files that didn’t change keep their hash and stay cached. This is the single biggest caching win a bundler gives you.

Source maps are the escape hatch from minification. A .map file records how each position in the minified output corresponds to a line and column in your original source. Your browser’s devtools read it so a stack trace points at checkout.ts:42 instead of index-a1b2.js:1:8842. You ship maps (or upload them to your error tracker) so production errors stay debuggable.

sourcefunction total(items) {  return items.reduce(    (a, b) => a + b, 0);}minifyoutputfunction t(e){return e.reduce((a,b)=>a+b,0)}index.js.mapdevtools resolve minified positions back to source
Minification collapses readable source into terse output; the source map records the mapping back, so devtools and error trackers can show original locations.

Build-time constants: define and env

One more knob you’ll reach for. Bundlers can replace an identifier with a literal at build time, before minification runs. The classic use is a framework’s process.env.NODE_ENV: set it to the string "production" and every if (process.env.NODE_ENV !== 'production') dev-only branch becomes if (false), which minification then deletes entirely. That’s how development warnings vanish from production builds.

In Vite the env story is deliberately narrow for safety: only variables prefixed VITE_ are exposed to client code through import.meta.env, so you can’t leak a secret by accident.

// only VITE_-prefixed vars reach the browser
const api = import.meta.env.VITE_API_URL;
console.log(import.meta.env.MODE); // "development" | "production"

When you can skip the bundler

The pendulum has swung back a little. Native ESM works in every current browser, import maps let you map bare specifiers to URLs without a build step, and HTTP/2 made many small requests far cheaper than they were in the round-trip-counting days. For a small site, an internal tool, or a demo, <script type="module"> plus an import map can be all you need — no build, no node_modules, edit and refresh.

You cross into needing a bundler when: you want TypeScript or JSX; you depend on packages that only ship CommonJS or hundreds of internal files; you care about tree-shaking and minified size; or you want content-hashed files for aggressive CDN caching. Most production apps hit at least two of those, which is why the bundler stays. The shift worth internalizing is that the tool is now fast enough to be invisible — the reason the whole toolchain moved to Rust.

Summary

  • A build tool resolves your module graph, transforms syntax browsers don’t run, optimizes the result, and emits a few small hashed files. That many-to-few reduction is the core job.
  • Development and production are different problems. Vite’s dev server serves native ES modules on demand — no bundling — for near-instant startup and surgical HMR; production bundles and optimizes everything ahead of time.
  • esbuild (Go) and SWC / Oxc (Rust) are fast transformers that rewrite one file at a time. Rollup was the long-standing production bundler; Rolldown is its Rust, API-compatible successor, 10–30x faster.
  • The toolchain consolidated onto Rust. Rolldown 1.0 is stable; Vite 8 (March 2026) makes Rolldown the single default bundler for both dev and build; VoidZero’s tools were acquired by Cloudflare in June 2026 and stay open source. Older projects on Vite 7 still use esbuild + Rollup — check your version.
  • Tree-shaking drops unreachable exports and works because static ESM lets the bundler prove what’s unused; the sideEffects field in package.json tells it which files are safe to remove.
  • Code-splitting via dynamic import() carves optional code into separate chunks loaded on demand — split along routes and heavy features, not every component.
  • Minification, content hashing, and source maps are the finishing passes: smaller output, cache-forever filenames, and a mapping back to original source for debugging.
  • define / import.meta.env inline build-time constants — great for feature flags and public config, never for secrets, since client bundles are readable by anyone.
  • You can skip the bundler for small sites using native ESM and import maps; you want one as soon as TypeScript, many dependencies, size budgets, or CDN caching enter the picture.