Vite 3 and the dev server that stopped being a special case

Vite 3 arrived in July, eighteen months after the 2.0 that made it worth using. There are few new features and several changed defaults, and the changed defaults are what broke things — a port number, a build target and a dependency resolution rule, each of which had been written down somewhere as a constant.

The symptom

$ npm i -D vite@3
$ npm run dev

  VITE v3.0.4  ready in 402 ms
  ➜  Local:   http://localhost:5173/

$ docker compose up -d
$ curl http://localhost:3000
curl: (52) Empty reply from server

# and the four places 3000 was written down:
#   docker-compose.yml   ports: ['3000:3000']
#   nginx.conf           proxy_pass to :3000
#   the README
#   a Playwright config baseURL

The port move is the whole of the immediate breakage and it is trivial to fix in four places. It is worth writing about because it is the shape of every problem in this upgrade: a default that had become a constant somewhere outside the tool.

Why it happens

A tool that is eighteen months old has defaults chosen before anybody had operational experience with it, and a major version is the only opportunity to change them. Port 3000 collides with almost every other development server; 5173 does not.

The fix

Pinning what should not have been a default

export default defineConfig({
  server: {
    port: 5173,
    strictPort: true,      // fail rather than silently pick 5174
    host: true,            // 0.0.0.0, for a container
    hmr: { clientPort: 5173 },
  },
})

// strictPort is the one that matters in a container:
// without it a clash makes Vite choose another port, the
// published mapping points at nothing, and the failure
// reads as a networking problem.

The silent fallback to another port is the worst behaviour in the whole tool and strictPort removes it — a dev server that is running and unreachable is much harder to diagnose than one that refused to start. Setting it explicitly is worth doing regardless of the version.

hmr.clientPort is the piece that catches anybody running behind a proxy: the hot-update websocket connects to whatever the server advertises, which is the container-internal port unless told otherwise. The symptom is a dev server that serves pages and never updates them.

The build target that changed

2.x default   build.target: 'modules'
              → es2019-ish, and a esbuild target of es2020

3.x default   build.target: 'modules'
              → the same NAME, a different meaning:
                es2020, edge88, firefox78, chrome87, safari14

which drops Safari 13 and iOS 13 without the name of the
setting changing.

$ npx browserslist 'safari 13'
safari 13

# 0.9% of this site's traffic. a decision, not an
# accident — and it had been made by upgrading.

A default whose name is unchanged and whose meaning is not is the hardest kind of change to notice, because a diff of the configuration shows nothing. Checking the analytics for the dropped browsers is a ten-minute job that turns an accident into a decision, and the decision here was to accept it.

// and the escape hatch, if 0.9% is not acceptable
import legacy from '@vitejs/plugin-legacy'

export default defineConfig({
  build: { target: ['es2019', 'safari13'] },
  plugins: [
    legacy({ targets: ['defaults', 'not IE 11'] }),
  ],
})

// which produces a second bundle and a nomodule script
// tag. it roughly doubles the build time.

The two-graph problem, one year on

The arrangement — esbuild in development, Rollup in the build — was the thing to be nervous about when Vite was adopted in 2021. A year of production use produced three bugs of that class, which is a useful number to report rather than a theoretical concern.

three bugs in a year, all found by the production build
running in CI on every push:

  1  a circular import that esbuild tolerated and Rollup
     hoisted differently. the cycle was a real defect and
     removing it fixed both.

  2  a CommonJS package whose default export resolved
     differently. `pkg.default` in the build, `pkg` in
     dev. added to optimizeDeps.include and pinned.

  3  a dynamic import with a template literal that
     Rollup could not statically analyse, so the chunk
     was never emitted. a 404 at runtime, in production
     only. found by the browser tests.

all three were found before release. none reached a user.

Running the production build in CI on every push is the entire mitigation and it costs about nine seconds. A team building only at release would have found all three in production, which is the difference between a footnote and an incident report.

The third is the one worth knowing about specifically: a dynamic import whose path is computed cannot be statically analysed, so the target is never included in the build. The dev server resolves it at request time and works perfectly, which is the exact failure mode the two-graph arrangement makes possible.

What actually improved

$ hyperfine --warmup 2 'npx vite build'
Benchmark: npx vite build
  Time (mean ± σ):     8.412 s ±  0.204 s     # was 11.8s

$ npx vite
  ready in 398 ms                              # was 412 ms

# the build is 29% faster, from esbuild and Rollup version
# bumps. the dev server is unchanged, because it was
# already fast.

# and the thing that is genuinely new:
$ npx vite preview --host
  ➜  Network: http://192.168.1.41:4173/
# preview now listens externally by default

A twenty-nine per cent faster build is a real improvement and is not why anybody upgrades — the reason to take a major is to stay on a supported version, and the performance is a side effect of the dependencies it pulls. Reporting it that way round is more honest than the release notes.

Verifying it worked

$ npx vite build
✓ 412 modules transformed. built in 8.41s
dist/assets/vendor.8f21ac3d.js  142.11 KiB │ gzip: 51.02 KiB

$ npx playwright test
  41 passed

$ grep -rn ':3000' docker-compose.yml nginx/ README.md playwright.config.js
# (no output)

# and the build twice, with no source change: identical
# hashes
$ npx vite build && ls dist/assets > /tmp/a
$ rm -rf dist && npx vite build && ls dist/assets | diff /tmp/a -

The reproducible-hash check catches a plugin injecting a timestamp or a source map path containing an absolute directory, both of which produce cache-busting nobody can explain. It is worth automating once and it is the sort of check that only ever fires after somebody adds a plugin.

What this costs

A fast-moving tool in a place that wants stability — Vite has taken a major every eight months, and each one has changed defaults rather than APIs. That is a good trade for a development server and is friction on a build pipeline, where a changed browser target is a decision that arrives disguised as a version bump.

The two-graph arrangement remains the structural cost and a year of evidence says it produces about three bugs a year, all catchable by running the production build in CI. That is an acceptable number and it is not zero, which is the honest position — a single-graph bundler has none of them and is slower to develop against.