vite-mastery

2.1 · difficulty 3/4 · 16 min read

The Dual-Engine Legacy: esbuild + Rollup

Why did Vite originally choose esbuild for dev and Rollup for build? What structural problems did this "good design" introduce? Understanding the history is the key to understanding why Rolldown was inevitable.

Vite 8.1Stable

2019: esbuild Arrives

Vite was born in 2020, at a time when JS toolchain performance bottlenecks were an open secret:

  • Webpack 5 cold starts routinely took ten-plus seconds
  • Babel's serialization / deserialization overhead was enormous
  • HMR in large projects was so slow it was practically unusable

Then esbuild appeared. esbuild rewrote the JS transform and bundle pipeline in Go, achieving speeds 10–100x faster than Webpack in certain scenarios (esbuild official benchmark).

Evan You, Vite's creator, saw the opportunity: if the dev stage skips bundling and only does transform, esbuild is fast enough. From that insight, Vite's core design was born:

  • Dev stage: esbuild handles single-file transforms (TypeScript / JSX compilation), native browser ESM handles module loading
  • Build stage: Rollup handles full bundling, tree-shaking, and code splitting

The Structural Contradictions of the Dual Engine

The dual-engine design was the right engineering decision for Vite 2 — there was no better alternative at the time. But as projects grew larger and the plugin ecosystem matured, its structural contradictions became increasingly visible.

Contradiction 1: Some hooks only work in the build stage

Rollup's full plugin system (tree-shaking, generateBundle, code splitting) is only available during the build stage. A plugin that needs access to Rollup bundle information simply cannot work in dev:

ts
// This plugin is useless in dev
// because dev goes through esbuild transform, not Rollup's generateBundle
function myPlugin(): Plugin {
  return {
    name: "my-plugin",
    generateBundle(_, bundle) {
      // This never executes in dev
      analyzeBundle(bundle)
    },
  }
}

Contradiction 2: Inconsistent resolve behavior between dev and build

esbuild's resolve logic is not fully equivalent to Rollup's resolveId hook. In some cases, an import path that resolves successfully in dev fails in build — or vice versa.

These bugs are hard to reproduce because you need both a dev and a build environment running simultaneously to observe the discrepancy.

Contradiction 3: Two separate transform pipeline APIs

esbuild's transform calls Go native functions directly — there is no plugin API. Rollup's transform goes through a transform(code, id) hook chain.

Plugin authors who want their plugin to work in dev must adapt to both mechanisms — esbuild's onTransform API and Rollup's transform hook — or simply only support build.

Contradiction 4: Source map handoff problems

Dev-stage source maps are generated by esbuild. Build-stage source maps are generated by Rollup. The two formats and levels of precision are not identical, which means the same piece of code may map to different locations in DevTools during dev versus in the build artifact's source map.

Why There Was No Better Choice at the Time

Given all these problems with the dual engine, why not use a single engine for both stages?

The answer is that no such tool existed in 2020:

  • esbuild had great performance, but didn't support the full Rollup plugin API and had no mature code splitting
  • Rollup had complete functionality, but its pure JavaScript implementation was too slow to meet the on-demand transform performance requirements of dev
  • Webpack supported both dev and build, but was too heavy and complex to configure, conflicting with Vite's design philosophy

Vite's dual engine was the optimal solution under the constraints of the time. This was not a design flaw — it was a historical limitation.

Vite 8's Fundamental Solution

In 2024, the Rolldown project matured. Rolldown is implemented in Rust, with performance close to esbuild, while also providing a full Rollup plugin API compatibility layer.

The core change in Vite 8: replacing the esbuild + Rollup dual engine with Rolldown:

Bundler architecture comparisondev
V7Dual engine (esbuild + Rollup)
dev
build
Browser / Node output

Two engines had different designs, so plugin behavior often needed separate adaptation.

V8Rolldown unified engine
OXC parser
Module graph
Chunking
Browser / Node output

One Rust core keeps dev/build behavior aligned, while the compatibility layer preserves Rollup APIs.

Click or hover a node to highlight data flow

This means:

  • Dev stage: Rolldown handles transform (performance comparable to esbuild)
  • Build stage: Rolldown handles full bundling (compatible with the Rollup plugin API)
  • Both stages share the same transform pipeline, so dev/build behavior consistency is guaranteed at a fundamental level

References: The Vite team's official explanation of the Rolldown integration can be found on the Vite official blog. The technical details in this section are based on the public documentation available at the time of the Vite 8.1.x release.

Self-check

  1. Why didn't Vite originally use Rollup for the dev-stage transform?
  2. In the dual-engine architecture, will the generateBundle hook in a Rollup plugin be called during dev? Why?
  3. At which specific levels does "dev/build behavior inconsistency" manifest? Give one example for each.
  4. What is the core difference between Rolldown and esbuild? Which fatal weakness of esbuild does Rolldown address?
ts
// What is wrong with the following plugin in a Vite 7 (dual-engine) environment?
// Does the same problem exist in Vite 8 (unified Rolldown)?
function analysisPlugin(): Plugin {
  return {
    name: "vite-plugin-analysis",
    generateBundle(_, bundle) {
      const sizes = Object.entries(bundle).map(([name, chunk]) => ({
        name,
        size: chunk.type === "chunk" ? chunk.code.length : 0,
      }))
      console.table(sizes)
    },
  }
}

// Question: if this plugin is configured in vite.config.ts, what happens when you run pnpm dev?
// Answer and explain the reason: