Optimizing Vite Dev Server and HMR
Development velocity in a Vite project is bounded by two numbers: cold-start time before the first byte renders, and the round-trip latency of a single Hot Module Replacement (HMR) update after you save a file. This guide targets engineers tuning the vite dev lifecycle for sub-200ms update cycles and predictable memory footprints, covering native ESM delivery, esbuild pre-bundling, WebSocket mechanics, and module-graph invalidation. For the broader configuration model that underpins everything here, see the Vite Configuration & Ecosystem overview before tuning the server. The focus stays strictly on runtime dev performance; production bundling and plugin authoring are out of scope.
Prerequisites
This guide assumes Vite 5.x or 6.x on Node 18.18+ (Node 20 LTS recommended), a framework plugin that wires HMR (@vitejs/plugin-react 4.x or @vitejs/plugin-vue 5.x), and a TypeScript vite.config.ts. The WebSocket inspection steps assume Chromium DevTools. Workspace-specific tuning is covered separately in Fixing slow Vite HMR in large monorepos.
Core Mechanics: ESM Delivery, Pre-bundling, and the HMR Protocol
Unlike bundler-first toolchains that recompile a dependency graph on every change, Vite serves unbundled source modules on demand over native <script type="module">. When a route is requested, Vite transforms only the modules in that import chain, eliminating the O(n) recompilation penalty of Webpack-style architectures. HTTP/2 multiplexing fetches those modules in parallel over one connection.
esbuild Pre-bundling Lifecycle
Before the first request, Vite runs a dependency pre-bundling pass with esbuild. This converts CommonJS and UMD packages to ESM, flattens deep dependency trees into a single request each, and writes the result to node_modules/.vite/deps. The cache is keyed on lockfile contents and the resolved config; it is invalidated when package.json, the lockfile, or relevant config fields change. Skip or break this step and Vite transforms each CJS dependency on the fly, pushing cold start from roughly 800ms to several seconds on a typical React or Vue app. The relationship between CJS and ESM here is the same one described in understanding ESM vs CommonJS in modern bundlers.
WebSocket HMR Protocol
Vite holds a persistent WebSocket on /vite-hmr between client and dev server. On a file change the server computes the invalidation set, transforms the affected modules, and pushes a JSON payload ({ type: 'update', updates: [...] }) carrying each module’s id, accepted path, and a cache-busting timestamp. The client re-imports the new module via a query-versioned import() and runs the registered accept callback without a navigation. The channel itself is cheap (~2KB per session), but oversized module exports inflate payloads and block the main thread during application.
The Invalidation Walk and Accept Boundaries
When a module changes, Vite walks up its importer chain looking for the nearest module that called import.meta.hot.accept. That module is the HMR boundary: the update stops there and is applied in place. If the walk reaches an entry module with no boundary, Vite gives up and triggers a full page reload. This single rule explains most “HMR feels slow” complaints — the update is not slow, it is silently escalating to a reload. Circular dependencies, often introduced through barrel files, are a common way to break the boundary; that exact failure is dissected in fixing Vite HMR full reloads from circular barrel imports.
Configuration Reference
The following vite.config.ts is complete and runnable. Each block targets a specific lever: warmup for cold start, optimizeDeps for pre-bundle scope, fs.allow for workspace access, and hmr for the WebSocket transport.
// vite.config.ts — Vite 5.x / 6.x, Node 20+
import { defineConfig } from 'vite';
import path from 'node:path';
export default defineConfig({
server: {
// Eagerly transform hot modules during startup so the first
// navigation does not pay the transform cost. Paths are relative
// to the project root; list your real entry + heavy leaf modules.
warmup: {
clientFiles: [
'./src/main.tsx',
'./src/App.tsx',
'./src/components/**/*.tsx',
],
},
fs: {
// strict is true by default in Vite 5+; keep it on and explicitly
// allow the directories the server may read (monorepo roots, etc.)
strict: true,
allow: [
path.resolve(__dirname, '.'),
path.resolve(__dirname, '../packages'),
],
},
watch: {
// chokidar watches node_modules by default; excluding it removes
// the bulk of redundant fs.stat traffic and inotify pressure.
ignored: ['**/node_modules/**', '**/.git/**', '**/dist/**'],
usePolling: false,
},
hmr: {
protocol: 'ws',
host: 'localhost',
// Explicit port avoids collisions when running behind a proxy or
// in a container where the page origin differs from the HMR origin.
port: 24678,
overlay: true,
timeout: 30000,
},
},
optimizeDeps: {
// Force-include deps that Vite's scanner cannot find statically
// (dynamic imports, deep workspace packages). Pre-bundling them
// keeps HMR from re-resolving them on every change.
include: ['react', 'react-dom', 'lodash-es'],
// Exclude packages that are already valid ESM and change often in dev.
exclude: ['@my-org/local-ui'],
},
});
server.warmup.clientFiles was added in Vite 4.3 and matters most for large entry graphs: it overlaps transform work with server boot instead of stalling the first request. optimizeDeps.include and exclude decide what lands in node_modules/.vite/deps; misconfigure them and you trade either cold-start time or HMR stability. fs.allow is the escape hatch for server.fs.strict, which otherwise rejects reads outside the project root with a 403 Restricted error — the usual symptom in workspaces.
Step-by-Step: Establish and Hold a Sub-200ms Budget
- Capture a baseline. Run
vite --debug hmrand save a file. The terminal prints[vite] hmr update /src/...with a duration. Anything over ~200ms or apage reloadline is your target. - Confirm pre-bundling ran. Start with
vite --forceonce and watch forOptimizing dependencies...thenDependencies pre-bundled successfully. Verify resolved imports point atnode_modules/.vite/deps/rather than raw source. - Enable warmup for the entry graph. Add
server.warmup.clientFilesfor your real entry and heaviest leaf modules, restart, and compare time-to-first-render in the Network panel. - Pin accept boundaries. For any module that should hot-update in isolation, add an
import.meta.hot.acceptcallback. Re-run step 1 and confirm thepage reloadlines are gone. - Inspect WebSocket frames. In DevTools → Network → WS →
/vite-hmr→ Frames, watch payload sizes on save. Frames over ~100KB mean oversized exports or missing dev-mode tree-shaking.
After each change, re-run vite --debug hmr and read the printed duration — that number, not subjective feel, is the budget you defend.
Debugging & Failure Modes
Cascading invalidation ([vite] hmr update spam)
A single save that logs dozens of hmr update lines means the invalidation walk is touching far more modules than it should. The usual cause is a hub module — a shared store, a theme object, a barrel — imported almost everywhere, with no closer accept boundary. Move volatile state behind a narrow module and accept it there.
Silent full-page reloads
If the UI flashes white on every save, the update is escalating past the entry with no boundary. Run vite --debug hmr; a page reload <file> line names the file whose change could not be accepted. Circular dependencies frequently cause this, especially through re-exporting index.ts barrels.
403 Restricted when importing a sibling package
server.fs.strict blocked a read outside the root. Add the package’s directory to server.fs.allow rather than disabling strict mode globally.
Stale state and memory growth
A module that registers timers, listeners, or subscriptions but never calls import.meta.hot.dispose leaks on every hot update. Long sessions then degrade. Always pair accept with a dispose that tears down side effects.
Proxy / container HMR not connecting
When the page is served through a reverse proxy or a container, the client may try to open the WebSocket against the wrong origin and fall back to polling or full reloads. Set server.hmr.host, server.hmr.port, and server.hmr.clientPort explicitly so the browser dials the reachable endpoint.
Performance Impact & Measurement
Strict fs boundaries and a scoped watch.ignored list typically drop idle dev-server CPU from 45–60% to under 15% and shave ~200ms off initial module resolution. Tight accept boundaries cut average HMR payloads from hundreds of kilobytes to under 40KB and update-apply latency from ~750ms to ~120ms. Measure both ends: the server-side duration in vite --debug hmr, and the client-side frame size and apply time in the DevTools WS panel. Treat the --debug hmr duration as the regression gate in code review.
Compatibility Matrix
| Capability | Min Vite | Node | Notes |
|---|---|---|---|
server.warmup.clientFiles |
4.3 | 18.18+ | No-op on older versions; safe to keep |
server.fs.strict default true |
5.0 | 18.18+ | Was false in Vite 4; add fs.allow on upgrade |
optimizeDeps esbuild scan |
5.x | 18.18+ | Rolldown-Vite changes the scanner internals |
server.hmr.clientPort |
2.x | 18.18+ | Required behind most reverse proxies |
| Per-environment HMR (Environment API) | 6.0 | 20+ | Replaces several single-server assumptions |
Related
- Vite Configuration & Ecosystem — the parent overview covering config resolution, plugins, and build modes.
- Fixing slow Vite HMR in large monorepos — watcher and pre-bundling tuning for workspace-scale repos.
- Fixing Vite HMR full reloads from circular barrel imports — why barrel cycles break the accept boundary and force reloads.
- Advanced Vite plugin configuration — hook order and middleware that interact with the HMR pipeline.
- Understanding ESM vs CommonJS in modern bundlers — the module-format model behind pre-bundling.