# Vite 8 Ships One Bundler, Not One Pipeline

> Vite 7 depends on esbuild and Rollup; Vite 8 on neither. Serving one module from both majors shows what the swap to Rolldown changed and what it left alone.

- Published: 2026-09-20
- Tags: tooling, vite, build-tools
- Source: https://jsledger.com/blog/vite-8-replaced-two-bundlers-with-one/
- Language: en-US
- Author: Jonah Vail

---
Two package manifests, one install of the same tiny project:

```json
// vite@7.3.6 "dependencies"        // vite@8.2.2 "dependencies"
"esbuild": "^0.27.0 || ^0.28.0",    "rolldown": "~1.2.4",
"rollup":  "^4.43.0",               "lightningcss": "^1.33.0",
```

Two bundlers became one, and `node_modules/esbuild` is not in the Vite 8 tree at
all. That much is accurate and easy to check. What it does not tell you is what a
running dev server does differently, which is the thing an upgrade is actually
scheduled on.

## The response body barely moved

The launch article on this site walked [one request through a Vite dev
server](/blog/what-happens-when-vite-serves-a-module/) and ended on the seam
between the two engines. The cheapest way to see what became of that seam is to
ask both majors for the same file. Same source tree, same `lodash-es@4.17.21`,
one dev server on each port:

```bash
# Node 24.20.0, macOS arm64
curl -s http://localhost:5301/src/main.ts   # vite 7.3.6
curl -s http://localhost:5302/src/main.ts   # vite 8.2.2
```

The two bodies differ in exactly one token, an eight-character hash:

```js
import { greet } from "/src/greet.ts";
import cloneDeep from "/node_modules/.vite/deps/lodash-es_cloneDeep.js?v=89b5fecc";  // v7
import cloneDeep from "/node_modules/.vite/deps/lodash-es_cloneDeep.js?v=1ee69a1c";  // v8
```

Relative specifier resolved to a served path, bare specifier rewritten to a
pre-bundled dependency, nothing else touched. Both versions write the same files
into `node_modules/.vite/deps` — one chunk per optimized entry plus a
`_metadata.json` with the same fields, down to `needsInterop` on each entry. The
request path is the same request path.

## The enum is where the two compilers disagree

`main.ts` is too plain to show a transformer's opinions. `greet.ts` contains an
enum, and there the two responses stop matching:

```js
// vite 7.3.6 — esbuild 0.28.2
var Level = /* @__PURE__ */ ((Level2) => {
  Level2[Level2["Low"] = 1] = "Low";
  Level2[Level2["High"] = 2] = "High";
  return Level2;
})(Level || {});
export function greet(p, level = 1 /* Low */) {
  const suffix = level === 2 /* High */ ? "!!" : ".";
```

```js
// vite 8.2.2 — Oxc
var Level = /* @__PURE__ */ function(Level) {
	Level[Level["Low"] = 1] = "Low";
	Level[Level["High"] = 2] = "High";
	return Level;
}(Level || {});
export function greet(p, level = Level.Low) {
	const suffix = level === Level.High ? "!!" : ".";
```

Three differences, and two of them are cosmetic. esbuild renames the inner
binding to `Level2` where Oxc shadows with the same name; esbuild emits an arrow
where Oxc emits a function expression. The emitted source maps agree that this is
renaming and nothing more — Vite 7's carries `"names":["Level"]`, Vite 8's
carries `"names":[]`.

The third difference is not cosmetic. **esbuild constant-folds the member reads
and Oxc does not.** In the Vite 7 output the default parameter is the literal
`1`; in the Vite 8 output it is a property read on an object that has to exist by
the time the function runs. Both are correct lowerings of the same enum, and they
are not the same program.

## A folded enum is a droppable enum

That difference does not stay in the dev server. Build both trees and grep the
production chunk for a string that only the enum object contains:

```bash
$ grep -c "High" dist/assets/*.js
0     # vite 7.3.6
1     # vite 8.2.2
```

Under Vite 7 the enum object is not in the shipped bundle. Nothing references it
after the folding, so it is unreachable, and the bundler drops it. Under Vite 8
every read is a property access, the object is reachable, and it ships.

The cost is small and exactly measurable. Rewriting `greet.ts` to take a plain
`number` instead of the enum, and rebuilding both:

```
                    with enum      without enum     difference
vite 7.3.6          12,908 B       12,908 B         0 B
vite 8.2.2          13,829 B       13,746 B        83 B
```

Zero against 83 bytes, for the same TypeScript. This is the whole tree-shaking
argument in miniature: a bundler removes a module or a binding when it can prove
nothing observable happens without it, and constant folding is what turns an
enum read into that proof. Take the folding away and the proof goes with it.

Eighty-three bytes is not a reason to postpone an upgrade. A codebase with two
hundred enums is a different arithmetic, and the arithmetic is now yours to do.

## One toolchain writes both ends, and one always did

The mechanism is visible in Vite's own bundle:

```bash
$ grep -ho 'from "esbuild"' node_modules/vite/dist/node/*.js | sort -u        # vite 7
from "esbuild"
$ grep -ho 'from "rolldown[^"]*"' node_modules/vite/dist/node/*.js | sort -u  # vite 8
from "rolldown/experimental"
```

Vite 8 gets its transform from the bundler package, and that package is where Oxc
lives: `rolldown@1.2.7` declares `@oxc-project/types` pinned to `0.148.0` plus a
platform binding. The word `experimental` is the package's own path name, not a
verdict here about stability.

It is worth being exact about what this fixed, because the common summary is that
development and production used to run different engines. On the transform they
did not. The folded `level = 1` shows up in Vite 7's production chunk as
`function Et(t,e=1)`, identical to what its dev server served, because esbuild
handled that step at both ends already. What differed in Vite 7 was the
*bundler*: esbuild pre-bundled dependencies and Rollup built for production. That
is the pair Rolldown replaced.

So one engine now, in a real sense — and still two code paths. Development
transforms file by file and serves unbundled modules. Production builds a graph
and emits chunks. Nothing in Vite 8 merged those, and the enum result above is a
reminder of why that matters: the dev server never runs the step that would have
told you the object was droppable.

## Cold start did not move, and the build moved both ways

Cold start, seven runs each, `node_modules/.vite` removed before every run:

```
$ rm -rf node_modules/.vite && npx vite --port <p> --strictPort   # x7, "ready in N ms"
vite 7.3.6   67 77 85 85 88 93 108     median 85 ms
vite 8.2.2   69 77 83 86 91 101 107    median 86 ms
```

One millisecond apart inside a spread of forty. Two modules and one dependency
give a bundler nothing to be asymptotically better at, and the single first run I
took before clearing the cache, 345 ms against 92 ms, measured dependency
optimization rather than startup. `vite build` on the same tree does move, in
both directions at once:

```
vite 7.3.6   112 modules   186 ms   12,908 bytes   gzip 4.67 kB
vite 8.2.2   113 modules    58 ms   13,829 bytes   gzip 4.92 kB
```

Roughly three times faster, and 921 bytes larger. Eighty-three of those bytes are
the enum. The rest is the two minifiers having different habits: Rolldown quotes
strings with backticks and declares with `let` where esbuild used double quotes
and `const`. I have not traced which habit costs what.

:::note
Vite 8.0.0's changelog entry is `update rolldown to 1.0.0-rc.9`, not Rolldown
1.0. The registry dates line up: `rolldown@1.0.0-rc.9` was published
2026-03-11, Vite 8.0.0 the day after, and `rolldown@1.0.0` on 2026-05-07. The
release that made Rolldown the default shipped against a release candidate.
:::

## What to run before you schedule the upgrade

Start both majors against the same tree and `curl` a module that uses your
language features rather than a plain one. Enums, decorators, class fields and
JSX are where two transformers get to have opinions, in the same way that
[reading what `await` compiles to](/blog/what-await-compiles-to/) tells you more
than reading what it means. Then build both and compare sizes, because the number
that got worse here is the one nobody advertises.

What I did not test: CSS, which moved to `lightningcss` and deserves its own
measurement; any project large enough for bundler complexity to matter; and
watch-mode rebuilds, where a persistent graph should pay off and where two
modules have nothing to say.
