Hot Module Replacement and Dependency Pre-Bundling in Vite
Prerequisites
- ›Article 3: Dev server, module graph, transform pipeline
- ›WebSocket protocol basics
- ›Understanding of HMR concepts (module boundaries, self-accepting modules)
Hot Module Replacement and Dependency Pre-Bundling in Vite
Two systems make Vite's dev experience fast: Hot Module Replacement (HMR) updates your running application without a full page reload, and dependency pre-bundling converts npm packages into optimized ESM so the browser doesn't need to make hundreds of requests for a single import React from 'react'. Both systems are deeply intertwined with the module graph we explored in Part 3. Let's trace them end-to-end.
File Change Detection and handleHMRUpdate
As we saw in Part 3, _createServer() sets up a chokidar watcher. On line 890, file changes trigger:
watcher.on('change', async (file) => {
file = normalizePath(file)
// notify all environments' plugin containers
await Promise.all(
Object.values(server.environments).map((environment) =>
environment.pluginContainer.watchChange(file, { event: 'update' }),
),
)
// invalidate module graph cache
for (const environment of Object.values(server.environments)) {
environment.moduleGraph.onFileChange(file)
}
await onHMRUpdate('update', file)
})
This dispatches to handleHMRUpdate(), which first checks for special file types:
sequenceDiagram
participant FS as chokidar
participant HMR as handleHMRUpdate()
participant ENV as Each DevEnvironment
participant UM as updateModules()
participant WS as WebSocket
FS->>HMR: file changed
alt Config file / env file changed
HMR->>HMR: Restart server
else Vite client code changed
HMR->>WS: full-reload (all environments)
else Normal source file
loop Each environment
HMR->>ENV: Run hotUpdate plugin hooks
ENV-->>HMR: Filtered modules
HMR->>UM: updateModules(environment, file, modules)
UM->>WS: Send update/full-reload payload
end
end
If the changed file is the vite config or an env file, the server restarts entirely (lines 389-406). For normal files, the function looks up affected modules in each environment's module graph and runs the hotUpdate plugin hooks in parallel across environments.
Module Graph Traversal and Boundary Detection
The core HMR logic lives in updateModules() and propagateUpdate(). For each changed module, propagateUpdate() walks the importer chain upward:
flowchart TD
A["Changed Module"] --> B{"isSelfAccepting?"}
B -->|yes| C["✅ Boundary found:<br/>module accepts its own updates"]
B -->|no| D{"Has importers?"}
D -->|no| E["❌ Dead end → full reload"]
D -->|yes| F["Check each importer"]
F --> G{"Importer accepts<br/>this dep via hot.accept()?"}
G -->|yes| H["✅ Boundary: importer"]
G -->|no| I{"Importer isSelfAccepting?"}
I -->|yes| J["Continue upward anyway"]
I -->|no| K["Recurse: propagate to<br/>importer's importers"]
K --> D
The algorithm is a depth-first traversal that collects "propagation boundaries" — modules that have declared via import.meta.hot.accept() that they can handle updates. If the traversal reaches a root module (one with no importers) without finding a boundary, it's a "dead end" and triggers a full page reload.
A subtle detail: the function tracks circular imports (line 785) and flags boundaries within circular chains with isWithinCircularImport: true. This metadata is sent to the client so it can handle circular dependency updates carefully.
Once boundaries are collected, updateModules() builds an Update[] payload on lines 679-694 and dispatches it through the hot channel:
hot.send({ type: 'update', updates })
HotChannel Abstraction and WebSocket Transport
The HotChannel interface is an abstract transport layer. Vite doesn't hard-code WebSockets — the interface only requires send(), on(), off(), listen(), and close() methods. This allows custom transports for environments like SSR workers where WebSockets aren't available.
The default implementation is WebSocketServer (built on the ws npm package), which extends the NormalizedHotChannel interface. The normalizeHotChannel() wrapper (lines 171-328) adds a convenience layer: normalized client objects with overloaded send() and an invokeHandler system that lets the client call server functions (like fetchModule) over the transport.
flowchart LR
subgraph "Server (Node.js)"
A[DevEnvironment.hot] -->|"NormalizedHotChannel"| B[WebSocketServer]
A2[SSR Environment.hot] -->|"NormalizedHotChannel"| C[ServerHotChannel<br/>in-process]
end
subgraph "Browser"
D[HMR Client]
end
subgraph "SSR Runner"
E[ModuleRunner]
end
B <-->|WebSocket| D
C <-->|Direct calls| E
Tip: The WebSocket connection URL includes a
tokenquery parameter for security:ws://host:port?token=xxx. This prevents malicious pages from connecting to your dev server's WebSocket. The token is injected into the client code by theclientInjectionsPlugin.
Client-Side HMR Processing
The browser-side HMR client lives in src/client/client.ts. It establishes a WebSocket connection using injected configuration constants (__HMR_PROTOCOL__, __HMR_HOSTNAME__, __HMR_PORT__, __WS_TOKEN__) and creates an HMRClient instance from the shared module:
const transport = normalizeModuleRunnerTransport(
createWebSocketModuleRunnerTransport({
createConnection: () =>
new WebSocket(`${socketProtocol}://${socketHost}?token=${wsToken}`, 'vite-hmr'),
pingInterval: hmrTimeout,
})
)
The createHMRHandler from src/shared/hmrHandler.ts processes incoming payloads. For type: 'update' messages, it fetches each updated module via a timestamp-busted dynamic import, then invokes the registered HMR callbacks.
Shared HMR Logic: HMRClient and HMRContext
The HMRClient and HMRContext classes in src/shared/hmr.ts are the unifying abstraction. HMRContext implements the import.meta.hot API that user code interacts with:
export class HMRContext implements ViteHotContext {
accept(deps?: any, callback?: any): void {
if (typeof deps === 'function' || !deps) {
this.acceptDeps([this.ownerPath], ([mod]) => deps?.(mod))
} else if (typeof deps === 'string') {
this.acceptDeps([deps], ([mod]) => callback?.(mod))
} else if (Array.isArray(deps)) {
this.acceptDeps(deps, callback)
}
}
// ...
}
The same HMRClient class is instantiated by both the browser client and the ModuleRunner (for SSR HMR), parameterized by different transports. This sharing ensures that HMR protocol semantics are identical across environments.
classDiagram
class HMRClient {
+hotModulesMap: Map
+dataMap: Map
+customListenersMap: Map
+notifyListeners(event, data)
}
class HMRContext {
+accept(deps?, callback?)
+dispose(callback)
+invalidate(message?)
+on(event, callback)
+send(event, data?)
}
class BrowserClient["Browser (client.ts)"]
class ModuleRunner["SSR (runner.ts)"]
HMRClient --> HMRContext : creates per module
BrowserClient --> HMRClient : uses with WebSocket transport
ModuleRunner --> HMRClient : uses with server transport
Dependency Scanning with Rolldown
Now let's shift to the second major system: dependency pre-bundling. When Vite encounters a bare import like import React from 'react', it needs a pre-bundled ESM version in .vite/deps/. The first step is scanning — discovering which dependencies your app uses.
The ScanEnvironment class is a lightweight environment that uses Rolldown's scan() API (from rolldown/experimental) to crawl entry points. It creates a PluginContainer but no module graph or deps optimizer — just enough infrastructure to resolve imports:
import { scan } from 'rolldown/experimental'
export class ScanEnvironment extends BaseEnvironment {
mode = 'scan' as const
// ...
}
The scanner follows static imports from HTML entry points, traverses the module graph, and records every bare import it encounters. Each discovered dependency is recorded as an ExportsData entry containing its exports list and whether it has ES module syntax.
flowchart TD
A["index.html"] -->|"scan"| B["src/main.ts"]
B --> C["import React from 'react'"]
B --> D["import { useState } from 'react'"]
B --> E["import dayjs from 'dayjs'"]
C --> F["Discovered: react"]
D --> F
E --> G["Discovered: dayjs"]
F --> H["ExportsData { hasModuleSyntax, exports }"]
G --> H
Dependency Bundling and Serving
Once dependencies are discovered, runOptimizeDeps() bundles them using Rolldown. Each dependency becomes a single ESM file in the cache directory (.vite/deps/), with proper named exports regardless of the original format.
The optimizedDepsPlugin intercepts requests for these pre-bundled files:
export function optimizedDepsPlugin(): Plugin {
return {
name: 'vite:optimized-deps',
applyToEnvironment(environment) {
return !isDepOptimizationDisabled(environment.config.optimizeDeps)
},
resolveId(id) {
if (environment.depsOptimizer?.isOptimizedDepFile(id)) return id
},
async load(id) {
if (depsOptimizer?.isOptimizedDepFile(id)) {
// read from .vite/deps/ cache
}
},
}
}
The applyToEnvironment hook is a great example of the Environment API in action — dependency optimization can be selectively disabled per environment.
Runtime Discovery and Re-Optimization
Not all dependencies can be discovered statically. Dynamic imports, conditional requires, and plugin-generated imports may introduce new dependencies at runtime. The createDepsOptimizer() function handles this with a debounced re-optimization strategy:
const debounceMs = 100
export function createDepsOptimizer(environment: DevEnvironment): DepsOptimizer {
// ...
const depsOptimizer: DepsOptimizer = {
init,
metadata,
registerMissingImport,
run: () => debouncedProcessing(0),
// ...
}
}
When the import analysis plugin encounters a bare import that isn't in the optimized deps metadata, it calls depsOptimizer.registerMissingImport(). This adds the dependency to a queue. After 100ms of no new discoveries (the debounce period), the optimizer re-bundles all known dependencies and triggers a page reload so the browser picks up the new pre-bundled versions.
The holdUntilCrawlEnd option (enabled by default) adds another layer: during the initial page load, discovery is batched until the static import graph is fully crawled. This prevents multiple re-optimization cycles during startup.
sequenceDiagram
participant IA as importAnalysis plugin
participant DO as DepsOptimizer
participant RD as Rolldown
participant BR as Browser
IA->>DO: registerMissingImport('lodash-es')
Note over DO: Start 100ms debounce timer
IA->>DO: registerMissingImport('date-fns')
Note over DO: Reset debounce timer
Note over DO: 100ms elapsed, no new deps
DO->>RD: Re-bundle all deps
RD-->>DO: New optimized files
DO->>BR: full-reload (new dep hashes)
Tip: If you see repeated "new dependencies optimized" messages during dev, add the frequently-missed dependencies to
optimizeDeps.includein your config. This eliminates the runtime discovery + reload cycle for those packages.
What's Next
We've now covered the two systems that make Vite's dev mode work: HMR for instant feedback and dependency pre-bundling for npm package compatibility. In the final article, we'll explore what happens when you run vite build — how createBuilder() orchestrates multi-environment production builds through Rolldown, and how the Module Runner enables SSR module execution with HMR support.