When you save a file in a Vite project, the page updates instantly without a reload or state loss. The surprising part is that the browser had no idea the file changed until the server told it. It didn’t poll. It didn’t compare file hashes. The server detected the change at the OS level, walked a dependency graph to find the smallest set of modules to update, and pushed new code over a WebSocket. By the time the browser would have discovered the change on its own, the update was already running.
Think of a factory where assembly workers never check the parts inventory. A floor manager monitors the stock in real time. When a part is updated, the manager radios only the specific stations that use that part and tells them to swap it without stopping production. Vite works like that floor manager. The dev server is the manager. chokidar watches the inventory—your source files. The module graph knows which stations use which parts—the import relationships. The WebSocket client is the radio.
How Vite Boots Up and Wires the HMR System
When you run vite dev, the server sets up a file watcher, a plugin pipeline, an in memory module graph, and injects a client script into the HTML.
Vite’s createServer builds a ViteDevServer with a Koa‑like middleware stack, a ModuleGraph, and a list of plugins loaded from your config. The graph starts empty. No modules are parsed yet. The server only records them as the browser requests them.
Next, the server creates a chokidar watcher. It collects the project root, environment directories, and config file paths into a list, deduplicates them with a Set, and passes that to chokidar.watch. This deduplication matters. Before PR #18432, duplicate watch paths could cause chokidar to fire add events during startup even with ignoreInitial enabled. Vite interpreted those as file additions and spuriously pushed HMR updates. The fix eliminated the noise and showed how tightly watcher behavior controls HMR correctness.
The watcher subscribes to change, add, and unlink events. It does not involve the browser at all. Those events flow into a handler that decides whether to restart the server or to trigger the HMR pipeline.
On the browser side, Vite’s HTML middleware injects a <script> tag for /@vite/client at the top of the HTML. That script opens a persistent WebSocket back to the dev server. It authenticates using a token in the URL so only pages served by that dev server can connect. Once connected, the client listens for messages with types like update, full-reload, and prune.
The /@vite/client script also defines createHotContext. Every time Vite transforms a module that uses import.meta.hot, it injects a call to createHotContext that wires up accept, dispose, and other HMR methods. The client stores those hot contexts in an internal map, forming a browser‑side mirror of the server’s module graph.
This architecture gives the server full control over what the browser knows about file state. The browser simply executes modules and runs HMR callbacks when told.
Building the Dependency Graph on the Fly
Every time the browser requests a module, Vite transforms it, records its imports, and adds a node to the module graph.
Requests flow through the transformMiddleware. For each URL, the server runs plugin resolveId, load, and transform hooks. During transformation, an import analysis step rewrites bare imports to explicit URLs and simultaneously records relationships in the ModuleGraph. A new or existing ModuleNode gets its importedModules set populated, and each dependency’s importers set adds the current module. The node also caches the transformed result, including code, sourcemaps, and an ETag.
Because the graph builds lazily from real requests, it represents exactly the transitive closure of modules the browser is using. There is no wasted work scanning unused files.
While recording imports, Vite also performs HMR prune analysis. It notes which previously imported modules are no longer imported and marks them for disposal. Later, when delivering updates, the server can send a prune message so the client can tear down those modules and free any side effects they held.
By the time your page finishes loading, the server has a precise picture of the dependency tree. When a file changes, it knows exactly who cares.
How the Server Detects File Changes First
The file watcher uses OS notifications, so the server learns about changes immediately, without any browser involvement.
chokidar normalizes low‑level file system events into change, add, and unlink. Vite’s watcher handler receives these events and builds a hot update context. That context carries the changed file path, the change type, a timestamp, and a read function that can safely read the new file content. The read function retries until it gets a consistent snapshot, which avoids races with editors that write in multiple passes.
Because the watcher tracks the file system directly, the notification arrives before the browser would ever see a cached response or issue a new navigation request. The browser does not poll for changes. It does not check ETag or Last-Modified headers. The server is permanently ahead.
For each file change, the server iterates over all environments (browser, SSR, etc.) and maps the file path to ModuleNode instances in each environment’s graph. Plugins can refine this list through the hotUpdate hook. For example, the Vue plugin might decide that a change to a .vue file should map only to the script and template modules, not styles, because CSS HMR is handled separately.
This environment‑aware processing means the same source file can trigger different HMR strategies depending on where it runs. The fixed list of affected modules becomes the input for the HMR propagation algorithm.
The HMR Propagation Algorithm and Boundaries
Vite walks up the dependency graph from the changed file, looking for modules that can accept the update, and stops at HMR boundaries.
The algorithm starts from each affected module. It first checks whether the module is self‑accepting. A module becomes self‑accepting when it (or a plugin on its behalf) calls import.meta.hot.accept() with no arguments. If the module self‑accepts, it is its own boundary. The update will not propagate further up the graph for that path.
If the module does not self‑accept, the algorithm looks at every importer. For each importer, it checks whether the importer has registered an accept callback for this specific dependency. That happens when code calls import.meta.hot.accept('./dep.js', callback). If such an importer exists, that importer acts as the boundary.
The algorithm continues upward until every path from the changed module terminates at a boundary. If any path cannot find a boundary, Vite falls back to a full page reload. Without a boundary, there is no safe way to inject new code without risking broken state or dangling references.
This is why you often see Vue components updating instantly while a shared utility change triggers a reload. The Vue SFC plugin injects self‑acceptance into every component, so leaf component changes are confined. But a utility used by many modules with no accept handlers forces a reload.
Vite’s hmr.ts implements this logic by traversing importers on the ModuleGraph. It collects the set of modules that need updates and, if no fallback is needed, packages them into update payloads.
Pushing Updates to the Browser Before It Asks
Once the server determines which modules need new code, it sends an update payload over the WebSocket, and the client re‑imports those modules with cache‑busting timestamps.
The server constructs an update message containing an array of objects. Each object has the module URL with a t query parameter set to the change timestamp. For example, src/App.vue?import&t=1719234567890. The HMR client receives this message and looks up the hot context for each path.
The client first calls any registered dispose callbacks to tear down previous side effects. Then it dynamically imports the new module using import() with the timestamped URL. Because the URL is different from the one originally cached, the browser fetches fresh code without any cached interference. After the new module loads, the client calls the accept callbacks with the updated module. Application code inside those callbacks can then replace stateful instances in place, which is why component state survives updates.
For CSS, the mechanism is lighter. Vite’s CSS plugin treats every CSS module as self‑accepting. The update payload causes the client to swap the <link> tag’s href or update a <style> element’s content using the same URL trick. No JavaScript re‑execution is needed.
All of this happens asynchronously with respect to the browser’s main event loop. The browser never initiates a navigation. It never checks its HTTP cache to see if a file is stale. By the time the browser would have discovered a change through a natural refresh, the server has already invalidated its internal caches, computed the boundaries, and delivered the patch.
Why This Design Matters for Developers
This server‑driven model removes an entire class of problems that plague polling‑based HMR or manual reloads. Because the server is the source of truth, you never see stale cached assets. The module graph localizes updates so only the parts that changed re‑execute. Framework authors can lean on import.meta.hot to build rich dev experiences where component trees update without unmounting.
In setups with multiple environments, like a browser client and an SSR renderer, the server runs the HMR pipeline serially. The client gets a WebSocket push. The SSR side invalidates cached transform results, so the next SSR request picks up the new code. This coordination happens without the developer doing any extra work.
Performance stays sharp because the graph bounds how far updates spread. A change in a tiny component triggers a single re‑import. A large page without boundaries gets a fast full reload instead of a broken incremental update.
The watcher deduplication fix that prevented startup noise is a small but telling example. Even seemingly trivial details in how the watcher interacts with the OS can compromise the whole HMR experience. Vite’s codebase shows how carefully these pieces fit together.
Quick Reference
| Property | Value |
|---|---|
| HMR client endpoint | /@vite/client |
| File watcher library | chokidar |
| Watcher deduplication | Watches unique directory set (PR #18432) |
| WebSocket message type for updates | update |
| Update URL pattern | path?import&t=<timestamp> |
| CSS HMR | Self‑accepting; swaps <link> or <style> |
| Fallback on no boundary | Full page reload |
| Overlay enabled by default | Yes (server.hmr.overlay) |
Frequently Asked Questions
Q: What happens if a file change affects a module that no importer accepts?
If Vite cannot find a self‑accepting module or an importer that explicitly accepts that dependency, it triggers a full page reload. The algorithm walks the entire upward chain, and if any path lacks a boundary, a partial update would leave dangling references or stale code.
Q: How does Vite handle HMR for CSS without requiring import.meta.hot?
Vite’s built‑in CSS plugin injects self‑acceptance into every CSS module. When a CSS file changes, the server sends an update message for that module. The HMR client then swaps the corresponding <link> tag’s href or updates the inline <style> element using a cache‑busting URL.
Q: Can HMR updates propagate across multiple environments like browser and SSR?
Yes. Vite runs the HMR pipeline for each environment in series. The browser client receives the update over WebSocket. The SSR environment invalidates its transform cache for the changed modules, so the next render request pulls the new code. Both stay in sync without cross‑environment WebSockets.
Q: Why does the dev server sometimes perform a full reload after a small change?
If the changed module is imported by many files and none of its importers or itself use import.meta.hot.accept(), Vite cannot confine the update. Adding a self‑accept call to a leaf component, or using a framework plugin that injects acceptance (like Vue), usually fixes this.
Q: How does the dev server avoid reading an empty file when an editor flushes writes in multiple steps?
The hot update context exposes a read function that retries fs.readFile until the file size or content stabilizes. This avoids race conditions with editors that write the file in stages, such as vi with swap files or IDEs that save via atomic rename.
If you want this kind of breakdown every week—how real tools actually work under the hood—subscribe to Internals Decoded at internalsdecoded.com.