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.
The problem exists because dev-server latency compounds. A single edit-to-render cycle happens hundreds of times a day per engineer, so a 700ms update cycle is not a 700ms cost — it is the difference between staying in a mental model and losing it. Beyond roughly 200ms the feedback stops feeling instantaneous, attention drifts to the terminal or the tab, and the edit-observe loop that makes component work fast collapses into a batch process. The numbers in this guide are not vanity targets; they are the threshold below which the tool disappears and above which it becomes the bottleneck. This matters more in Vite than in a bundler-first setup precisely because Vite promises near-zero rebuild cost, so any latency you do observe is almost always a specific, fixable misconfiguration rather than an inherent floor.
What breaks without the tuning here is subtle, because nothing errors. A missing accept boundary does not throw; it silently downgrades a hot patch into a full page reload, wiping component state and re-running the whole module graph. An unbounded file watcher does not crash; it pins a CPU core at idle and adds tens of milliseconds of jitter to every save. A cold pre-bundle cache does not fail; it just makes the first navigation after every git pull feel like the project is broken. These failures are invisible in a green build, which is why the discipline in this guide is measurement-first: you instrument the two channels, read the actual duration, and only then touch config. Where this sits in the build pipeline is narrow but load-bearing — it is the inner loop, upstream of the production Rollup build and orthogonal to it, and it is the part of the toolchain an engineer touches most and profiles least.
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.
You also need a way to measure, not just feel, an update cycle: the --debug hmr flag on the server side and the DevTools Network → WS panel on the client side. Every recommendation below is defended by one of those two numbers — server-reported transform duration and client-side frame size — so if you cannot read them, tune nothing yet.
The version constraints are not arbitrary. Vite 5.0 flipped server.fs.strict to true by default, which is the single change most likely to make a working config throw 403 Restricted after an upgrade, so anything below 5.x behaves differently on exactly the lever this guide leans on. The Node floor of 18.18 is where the native fetch and stable node: protocol imports the dev server relies on stopped being experimental; on 16.x you will hit warnings and occasional resolver quirks that have nothing to do with your config. The framework plugin version matters because HMR is not a core Vite feature for framework components — core Vite only ships the transport and the accept protocol, while @vitejs/plugin-react (via React Fast Refresh) and @vitejs/plugin-vue (via its own SFC HMR runtime) are what actually preserve component state across a patch. Pair a new Vite with an old framework plugin and you get degraded HMR that reloads instead of patching, which reads as a Vite bug but is a plugin mismatch.
Chromium DevTools is specified for the WebSocket steps because its Network → WS → Frames view renders each HMR frame with its byte size and direction inline; Firefox’s equivalent panel exists but does not surface per-frame payload size as legibly, and payload size is the number you are hunting. If you are on Safari, drive the same measurement from the terminal side and treat the DevTools steps as optional.
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. The practical consequence is that dev-server cost scales with the size of the route you are looking at, not the size of the whole application — a 2,000-module app and a 200-module app feel identical on a page that imports 40 modules.
That inversion is the whole reason Vite exists, and understanding it changes how you read every latency number below. A bundler-first dev server does its expensive work up front and amortizes it: cold start is slow, but individual rebuilds can be fast because the graph is already resident. Vite does the opposite — cold start is cheap because nothing is bundled, and the cost is deferred to the moment each module is first requested. This means the two metrics you care about have different shapes. Cold start is dominated by the pre-bundling pass and the transform of whatever the entry graph touches; steady-state HMR is dominated by the size of the invalidation set and the transform cost of the single changed module. Tuning one does not automatically improve the other, and conflating them is the most common reason engineers tune the wrong lever.
Under the hood, every source request flows through the same pipeline: the browser asks for a module by URL, Vite’s dev middleware resolves that URL to a file, runs the file through the plugin transform chain (esbuild for TS/JSX stripping, framework plugins for HMR injection), and returns valid ESM with import specifiers rewritten to resolvable URLs. The transformed result is cached in memory keyed by module id, so a second request for an unchanged module is a map lookup, not a re-transform. HMR is just this pipeline run again for one module plus a WebSocket message telling the client which cached module to discard. Keeping that mental model — resolve, transform, cache, invalidate — is enough to reason about almost every failure mode in this guide.
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.
There are two reasons pre-bundling matters beyond format conversion, and both are about request count. First, a package like lodash-es ships as several hundred internal ESM files; served raw, importing one helper would fan out into hundreds of separate HTTP requests, and even over HTTP/2 the per-request overhead dominates. esbuild bundles that package into a single file so one import is one request. Second, the browser’s ESM loader caches by URL, so a dependency that never changes should be immutable and served with a long cache header — pre-bundled deps are, which is why they do not re-transform on every navigation the way your own source does. The scanner that decides what to pre-bundle runs an esbuild pass over your entry HTML and its static imports; anything reachable only through a dynamic import() or a string-constructed path is invisible to it, which is exactly what optimizeDeps.include exists to patch.
The invalidation cadence is worth internalizing because a mismatched cache is one of the most disorienting failures in Vite. The cache directory carries a _metadata.json hash computed from the lockfile, the optimizeDeps config, and a handful of resolved fields; on boot Vite recomputes that hash and, if it differs, throws the entire .vite/deps directory away and re-bundles. This is why changing a dependency version silently triggers a slow first start, and why vite --force — which skips the hash check and always re-bundles — is the correct first move when a dependency behaves as if it is a stale copy of itself. If you see a dependency exporting symbols that no longer exist in its package.json version, the cache is stale and --force is the fix. A second, quieter form of invalidation happens during a session: if you import a dependency the scanner missed, Vite discovers it mid-flight, re-runs pre-bundling, and issues a full page reload to reset the module graph — the log line reads new dependencies optimized. Frequent mid-session re-optimization is the signal to move that dependency into optimizeDeps.include.
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 important subtlety is what the payload does not contain. The WebSocket frame is small — it carries module ids and timestamps, not module source. The actual updated code arrives over a separate HTTP request: the client sees update for /src/Button.tsx, then fetches /src/Button.tsx?t=1718900000000 where the timestamp query busts the browser’s ESM cache and forces a fresh transform. This split is why a “slow HMR” investigation has two independent measurements. The WebSocket frame size tells you whether the invalidation set is bloated (many modules in one update); the subsequent HTTP transform duration tells you whether a single module is expensive to re-transform. A large frame with fast transforms means your accept boundaries are too high; a small frame with a slow transform means one module is doing too much at compile time (a giant generated file, a heavy Babel plugin, an inline data blob).
An accept boundary is registered explicitly through import.meta.hot. The framework plugins inject this for you on components, but any module that owns volatile runtime state — a store, a cache, a websocket connection — should register its own boundary so a save patches it in place instead of escalating. The dispose half is not optional cleanup; it is the difference between a stable session and one that leaks a listener on every keystroke:
// store.ts — Vite 5.x / 6.x HMR self-accept with teardown
let socket = new WebSocket('ws://localhost:9000');
let listeners = new Set<(msg: string) => void>();
socket.addEventListener('message', (e) => {
for (const fn of listeners) fn(e.data);
});
export function subscribe(fn: (msg: string) => void) {
listeners.add(fn);
return () => listeners.delete(fn);
}
if (import.meta.hot) {
// Accept updates to THIS module: the new version replaces the old
// one in place, so nothing walks up to an entry and forces a reload.
import.meta.hot.accept();
// Runs on the OUTGOING module just before the new one loads. Without
// this, every save opens a fresh WebSocket and orphans the old one,
// so a 30-minute session accumulates dozens of live sockets.
import.meta.hot.dispose(() => {
socket.close();
listeners.clear();
});
}
Without the dispose callback the accept still works — the update applies without a reload — but each generation leaks the previous socket and its listener set, so memory and open connections climb linearly with edit count. This is the mechanism behind the “session degrades over time” failure discussed later: the update path is correct, the teardown path is missing.
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.
The walk is a breadth-first traversal of the importers set, not the imports set — Vite maintains a reverse edge for every module recording who imports it, and the invalidation starts at the changed module and expands outward through those reverse edges until every branch either hits a boundary or terminates at an entry. Two properties fall out of this. First, the shape of the update set is a function of your import topology, not your edit: changing a leaf component that only its parent imports produces a tiny set, while changing a module that a barrel re-exports to two hundred call sites produces a set of two hundred unless a boundary interrupts the walk sooner. Second, a boundary only helps if it sits between the changed module and the entry; an accept call on the changed module itself (self-accepting) is the tightest possible boundary and is what framework plugins install on every component.
The reason barrels are so destructive here is that they collapse the importer graph. A file that imports one symbol from ./components actually imports the barrel, which imports everything, so the reverse-edge set of any single component effectively includes every consumer of the barrel. An edit that should have invalidated three modules now invalidates the barrel and everything downstream of it, and because the barrel itself rarely has an accept boundary, the walk runs to the entry and reloads. The fix is structural — import from the concrete module path, not the barrel — and it is the single highest-leverage change for HMR in a component-heavy codebase.
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.
A few of these fields interact in ways the field-by-field reading hides. warmup.clientFiles accepts globs, but a glob that matches too much is a footgun: warming ./src/**/*.tsx on a 1,500-component app moves the entire transform cost to server boot, so the terminal blocks for several seconds before the first request is served — you have not removed the cost, you have front-loaded it onto every restart. Warm the entry, the router, and the handful of leaf modules the first meaningful paint actually needs, and let everything else transform lazily on first request. The watch.usePolling: false setting is the default and is correct on Linux and macOS where inotify/FSEvents deliver native change events; the only time to flip it to true is inside a Docker volume mount or a networked filesystem where native events do not propagate, and even then it costs CPU proportional to the watched file count, which is why the paired watch.ignored exclusions matter so much under polling.
The optimizeDeps.include/exclude pair encodes a genuine trade-off rather than a right answer. Including a dependency pre-bundles it once and serves it immutably, which is what you want for stable third-party code like react-dom. Excluding a dependency tells Vite to serve its source directly, which is what you want for a local workspace package you are actively editing — if it were pre-bundled, edits to it would require a --force re-bundle to appear, defeating HMR. The rule of thumb: include anything from node_modules your scanner misses, exclude anything you are simultaneously developing. Getting this backwards is the cause of both “my library change does not show up” (it was pre-bundled) and “my third-party dep re-optimizes constantly” (it should have been included).
Read the config as four independent levers rather than one block: each one moves a different number, and it is worth knowing which, so a regression can be traced back to the single field that caused it.
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. Do this before touching any config: the baseline is what tells you whether a later change actually moved the number or just changed how it feels. Save the same representative file each time — a mid-tree component, not a leaf and not the entry — so the measurements are comparable across iterations. If the very first save is slow but subsequent saves are fast, you are measuring cold transform cost, not steady-state HMR, and should discard the first sample. - 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. Open the Network panel and check thatreact-domresolves to a single/.vite/deps/react-dom.jsrequest; if instead you see dozens of requests intonode_modules/react-dom/..., pre-bundling did not cover it and cold start is paying for on-the-fly transforms. A mid-sessionnew dependencies optimizedline followed by a reload means the scanner missed a dependency — note which one, because it belongs inoptimizeDeps.includeat step 3. - 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. Warmup only helps the first navigation, so measure it by hard-reloading a cold tab, not by saving. Keep the glob tight: if server boot visibly slows after adding warmup, you are warming too much and should pull the glob back to the entry graph plus the two or three modules the first paint blocks on. The correct outcome is that time-to-first-render drops while boot time stays flat. - 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. Framework components get this from the plugin automatically, so this step is about non-component modules — stores, hooks, config objects — that own state you do not want reset on every save. Add theaccept, save the module, and watch for the update to log as anhmr updaterather than apage reload. If it still reloads, the boundary is being bypassed by a circular import and the fix is topological, not a matter of adding moreacceptcalls. - 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. Remember the frame carries ids, not source, so a 100KB frame is a signal that the invalidation set is enormous — dozens or hundreds of modules in one update — which points back to a barrel or a hub module rather than to one heavy file. A small frame paired with a slow subsequent HTTP transform is the opposite problem and is diagnosed on the server side, not here.
After each change, re-run vite --debug hmr and read the printed duration — that number, not subjective feel, is the budget you defend. The loop is deliberately tight: one change, one re-measure, one conclusion. Batching several config edits and then measuring tells you the aggregate moved but not which edit did it, which is exactly the information you need when a later regression forces you to bisect.
Debugging & Failure Modes
Five failure modes account for nearly every “HMR is slow” report. Each has a distinct log signature, so the fix starts by reading the terminal, not by editing config.
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 symptom is a burst of update lines in vite --debug hmr from one keystroke, often paired with a visibly janky patch as the browser re-imports every module in the set. The root cause is a hub module — a shared store, a theme object, a barrel — imported almost everywhere, so its reverse-edge set spans a large fraction of the graph and no closer accept boundary interrupts the walk. The fix is to move the volatile state behind a narrow module and self-accept it there, so the walk terminates at the hub instead of fanning out to every consumer. Confirm by re-saving and checking that the update log now names a handful of modules rather than dozens; the WebSocket frame size in DevTools should drop in proportion.
Silent full-page reloads
If the UI flashes white on every save, the update is escalating past the entry with no boundary. The symptom is component state resetting on every edit — form inputs clearing, scroll position jumping, a modal closing — even though nothing errors. Run vite --debug hmr; a page reload <file> line names the file whose change could not be accepted, which is the root cause made explicit: the walk from that file reached an entry without finding a boundary. Circular dependencies frequently cause this, especially through re-exporting index.ts barrels, because the cycle prevents Vite from establishing a clean boundary and it conservatively reloads. Fix it by importing the changed module from its concrete path rather than through the barrel, or by adding a self-accept boundary on the module that owns the state. Confirm the page reload line is replaced by an hmr update line for the same file.
403 Restricted when importing a sibling package
server.fs.strict blocked a read outside the root. The symptom is a specific import — usually a sibling workspace package or a file above the project root — returning 403 Restricted in the Network panel while everything inside src/ loads fine. The root cause is that Vite 5+ defaults server.fs.strict to true and serves only files under the project root and a small allowlist, precisely to stop a dev server from leaking arbitrary disk contents. The fix is to add the package’s directory to server.fs.allow rather than disabling strict mode globally, which would reopen exactly the hole the default closes. Confirm by reloading the import and watching for a 200 in the Network panel; if it still 403s, the allowlisted path does not actually contain the resolved file, usually because a symlink resolved to a real path outside the allowlist.
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. The symptom is a session that starts fast and degrades over an hour of editing — updates get slower, memory in the tab climbs, and eventually the same code that worked at 9am stutters by lunch. The root cause is that each hot update loads a new copy of the module while the old copy’s side effects (an open socket, a running interval, a registered event listener) stay live because nothing tore them down. The fix is to pair every accept with a dispose callback that closes sockets, clears intervals, and removes listeners, as shown in the store example above. Confirm by taking two heap snapshots several minutes and many edits apart; a healthy module shows flat retained size, a leaking one shows detached listeners or sockets accumulating by generation.
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. The symptom is that the page loads but never hot-updates — the DevTools console shows a failed WebSocket connection to a host or port the browser cannot reach, and saves trigger a manual-refresh-only workflow. The root cause is that the browser derives the HMR WebSocket URL from the page origin by default, but behind a proxy the page origin (say https://app.local) differs from the address the dev server actually listens on, so the handshake dials an unreachable endpoint. The fix is to set server.hmr.host, server.hmr.port, and often server.hmr.clientPort explicitly so the browser dials the reachable endpoint — clientPort is the one that matters when the proxy terminates on a different external port than the server’s internal one. Confirm in DevTools → Network → WS that the /vite-hmr connection shows 101 Switching Protocols and stays open.
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.
The CPU number is the one engineers most often dismiss, and it is the most corrosive. An untuned watcher recursing into node_modules issues thousands of fs.stat calls and holds thousands of inotify watches, which does not show up as slowness on any single save but does show up as a fan that never spins down and a laptop that drains on battery. More importantly it adds variance: when the watcher is busy servicing spurious node_modules events, a real save waits behind them, so your p50 HMR duration looks fine while your p95 spikes unpredictably. Scoping watch.ignored removes the source of that variance, which is why the tuned setup feels not just faster but consistent, and consistency is what keeps you in the loop.
These figures are illustrative of the shape of the win, not guarantees for your repo — a 200-module app tuned well may already sit under budget, while a 5,000-module monorepo may need the watcher and pre-bundling work in fixing slow Vite HMR in large monorepos before any of these numbers are reachable. The point is not to hit these exact values but to make the numbers visible in review: a PR that adds a barrel or widens a warmup glob should show up as a measurable regression in the --debug hmr duration, and that is only enforceable if someone is reading the number.
When not to tune
Not every dev-server complaint is a config problem, and reaching for vite.config.ts first wastes time on the cases where it cannot help. If cold start is slow but HMR is fast, and the pre-bundle cache is confirmed warm, the bottleneck is almost always transform cost in your own source — a heavy Babel plugin, a TypeScript isolatedModules violation forcing full type-aware transforms, or a generated file measured in megabytes — none of which the levers here touch. If HMR is fast in a scratch project but slow only in one repo, the problem is topological (barrels, hub modules, cycles) and belongs in the structural fixes, not the server block. And if the machine itself is the constraint — a full disk throttling the pre-bundle write, an antivirus scanning every file the watcher touches, a container with a CPU quota — no amount of Vite config will move the number. Rule those out before spending a day on optimizeDeps.
Compatibility Matrix
The levers in this guide landed across several Vite majors; the timeline below shows when each became available so you can tell which apply to your pinned version before reading the table.
The one entry worth reading twice is the fs.strict default flip at Vite 5.0. On Vite 4 the dev server served files anywhere on disk that resolved, so a monorepo import from a sibling package “just worked”; on Vite 5 the same import returns 403 Restricted because the default is now to serve only the project root and its allowlist. This is the classic silent upgrade break — the config did not change, the behavior did — so the correct migration order is to add every workspace root to server.fs.allow before bumping the major, not after the 403s start appearing in a teammate’s console. The Environment API in Vite 6.0 is a larger shift: it generalizes the single-server, single-module-graph assumption into per-environment graphs (client, SSR, and custom runtimes each get their own), which means some plugins and HMR-adjacent code written against the old single-graph model need updating. If you are not doing SSR or custom runtimes, the 6.0 changes are mostly transparent; if you are, treat the HMR pipeline as environment-scoped rather than global.
| 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.