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.
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:
// 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:
Two engines had different designs, so plugin behavior often needed separate adaptation.
One Rust core keeps dev/build behavior aligned, while the compatibility layer preserves Rollup APIs.
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
- Why didn't Vite originally use Rollup for the dev-stage transform?
- In the dual-engine architecture, will the
generateBundlehook in a Rollup plugin be called during dev? Why? - At which specific levels does "dev/build behavior inconsistency" manifest? Give one example for each.
- What is the core difference between Rolldown and esbuild? Which fatal weakness of esbuild does Rolldown address?
// 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: