Read OSS

Hot Module Replacement and Dependency Pre-Bundling

Advanced

Prerequisites

  • Article 1: Architecture and Codebase Navigation
  • Article 2: Configuration and Environment System
  • Article 3: Dev Server and Transform Pipeline
  • Article 4: Plugin System and Core Plugins

Hot Module Replacement and Dependency Pre-Bundling

Two features define Vite's development experience: instant hot module replacement and transparent dependency pre-bundling. HMR lets you edit code and see changes without a full page reload. Pre-bundling converts thousands of CommonJS files in node_modules into single ESM bundles that the browser can fetch efficiently.

Both systems are more complex than they appear. HMR requires traversing the module graph to find update boundaries, handling circular imports, and coordinating between server and client over WebSocket. Pre-bundling must discover dependencies, bundle them with Rolldown, and seamlessly serve them — even discovering new dependencies at runtime.

Server-Side HMR: File Change to Update Payload

When you save a file, chokidar detects the change and Vite's watcher triggers handleHMRUpdate. This function orchestrates the entire server-side HMR process:

sequenceDiagram
    participant FS as File System
    participant Chokidar
    participant handleHMRUpdate
    participant PluginHooks
    participant updateModules
    participant WebSocket

    FS->>Chokidar: File changed
    Chokidar->>handleHMRUpdate: type, file, server
    handleHMRUpdate->>handleHMRUpdate: Is config/env file? → restart server
    handleHMRUpdate->>handleHMRUpdate: Is Vite client code? → full reload
    handleHMRUpdate->>handleHMRUpdate: Look up modules in each environment's moduleGraph
    handleHMRUpdate->>PluginHooks: Run hotUpdate hooks (plugins can filter modules)
    PluginHooks-->>handleHMRUpdate: Filtered module list
    handleHMRUpdate->>updateModules: propagate updates per environment
    updateModules->>updateModules: Find HMR boundaries via graph traversal
    updateModules->>WebSocket: Send update payload

The first checks on lines 391–416 handle config and env file changes by restarting the entire server. If the changed file is inside Vite's own client directory, a full reload is sent to all environments.

For normal module changes, the function iterates over all environments, looks up affected modules via moduleGraph.getModulesByFile(), and runs the hotUpdate plugin hooks. These hooks let frameworks like Vue's SFC compiler narrow the update — for example, only re-rendering the template when only the <template> block changed.

The hook execution happens in two passes: first for the client environment (lines 481–559), then for all other environments. This preserves backward compatibility with the deprecated handleHotUpdate hook which operates on the mixed module graph.

HMR Boundary Propagation

The updateModules function performs the core graph traversal to find HMR boundaries:

graph TD
    CHANGED["Changed Module<br/>(src/utils.ts)"] --> IMP1["Importer A<br/>(src/App.tsx)"]
    CHANGED --> IMP2["Importer B<br/>(src/Header.tsx)"]
    IMP1 -->|"self-accepting ✓"| BOUNDARY1["HMR Boundary<br/>Update App.tsx"]
    IMP2 --> IMP3["Importer C<br/>(src/main.tsx)"]
    IMP3 -->|"not accepting"| DEADEND["Dead End → Full Reload"]

    style BOUNDARY1 fill:#4ade80
    style DEADEND fill:#f87171

For each changed module, the propagateUpdate function walks up the importer chain looking for self-accepting modules — modules that call import.meta.hot.accept(). These are HMR boundaries. The traversal tracks:

  • PropagationBoundary: Contains the boundary module, the module that was accepted via, and whether the update path involves circular imports.
  • Circular import detection: If the traversal encounters a module it's already visited, it sets isWithinCircularImport: true on the boundary, which tells the client to catch import errors and fall back to a full reload.
  • Dead ends: If the traversal reaches the root of the module graph without finding an accepting boundary, a full page reload is triggered.

The resulting Update payload contains the module path, the accepted path, and a timestamp. CSS updates get their own type: 'css-update' payload that triggers stylesheet replacement instead of module re-evaluation.

The Browser HMR Client

As we saw in Article 1, the browser-side HMR client lives in src/client/client.ts. It declares compile-time constants that are replaced during build by clientInjectionsPlugin:

declare const __BASE__: string
declare const __SERVER_HOST__: string
declare const __HMR_PROTOCOL__: string | null
declare const __HMR_TIMEOUT__: number
declare const __HMR_ENABLE_OVERLAY__: boolean
declare const __WS_TOKEN__: string
declare const __BUNDLED_DEV__: boolean

The client creates an HMRClient instance (from the shared module) and connects it to a WebSocket transport. The handleMessage function on lines 204–336 processes incoming payloads by type:

  • update: For JS updates, modules are re-imported with a cache-busting timestamp query. For CSS updates, <link> tags are cloned and swapped to avoid flash of unstyled content.
  • full-reload: Debounced page reload (20ms) to batch rapid changes.
  • prune: Removes modules that are no longer imported.
  • error: Displays the error overlay with stack trace and code frame.

A subtle detail: the client handles __BUNDLED_DEV__ mode differently. In bundle mode, updated modules are loaded via Rolldown's runtime (globalThis.__rolldown_runtime__.loadExports(acceptedPath)) instead of native ESM import().

Tip: The client's waitForSuccessfulPing function uses a SharedWorker to coordinate reconnection attempts across tabs. This means when you restart the dev server, all open tabs reconnect simultaneously rather than individually polling.

Dependency Scanning with Rolldown

Before the dev server serves any requests, Vite pre-bundles your dependencies. The scanner lives in optimizer/scan.ts and uses Rolldown's native scan() function:

flowchart TD
    A["scanImports()"] --> B["Create ScanEnvironment"]
    B --> C["rolldown/experimental scan()"]
    C --> D["rolldownDepPlugin filters imports"]
    D --> E["Discovered bare imports<br/>(react, vue, lodash-es, ...)"]
    E --> F["Return deps map"]

The ScanEnvironment is a lightweight environment (mode: 'scan') that creates its own plugin container but doesn't need a module graph or hot channel. The devToScanEnvironment helper creates a restricted view of a DevEnvironment for scanning — it exposes config and name but blocks access to dev-only features.

The scan result is a map of bare import specifiers to their resolved file paths. This map feeds into the optimization step.

The DepsOptimizer Lifecycle

The createDepsOptimizer function creates a DepsOptimizer that manages the full lifecycle:

flowchart TD
    INIT["init()"] --> CACHE{"Cached metadata exists?"}
    CACHE -->|Yes| LOAD["Load cached optimized deps"]
    CACHE -->|No| SCAN["scanImports() → discover bare imports"]
    SCAN --> BUNDLE["runOptimizeDeps() → Rolldown bundle"]
    BUNDLE --> SERVE["Serve pre-bundled from .vite/deps/"]
    SERVE --> RUNTIME{"New dep discovered at runtime?"}
    RUNTIME -->|Yes| DEBOUNCE["Debounce 100ms"]
    DEBOUNCE --> REBUNDLE["Re-bundle with new dep"]
    REBUNDLE --> RELOAD["Full page reload"]
    RUNTIME -->|No| SERVE

The optimizer uses rolldown() from the Rolldown API to bundle each dependency into a single ESM file. The rolldownDepPlugin handles the bundling pass, resolving each entry point and marking externals.

Runtime discovery is the clever part. When the import analysis plugin encounters a bare import that isn't in the optimized deps, it registers it with the optimizer. After a 100ms debounce (to batch multiple discoveries), the optimizer re-bundles and triggers a full page reload. The holdUntilCrawlEnd option delays this until the initial static import crawl finishes, reducing unnecessary re-bundles during startup.

Experimental: Full Bundle Dev Mode

Vite 8 includes an experimental FullBundleDevEnvironment that uses Rolldown's DevEngine API to serve fully bundled output during development. Enable it with --experimentalBundle.

graph TD
    subgraph "Standard Dev Mode"
        REQ1["Browser request"] --> TRANSFORM["Per-module transform"]
        TRANSFORM --> SERVE1["Serve individual module"]
    end
    subgraph "Full Bundle Dev Mode"
        CHANGE["File change"] --> ENGINE["Rolldown DevEngine"]
        ENGINE --> MEMORY["MemoryFiles (lazy eval)"]
        REQ2["Browser request"] --> MIDDLEWARE["memoryFilesMiddleware"]
        MIDDLEWARE --> MEMORY
        MEMORY --> SERVE2["Serve bundled output"]
    end

The MemoryFiles class stores bundled output in memory with lazy content evaluation — entries can be stored as thunks that only materialize when accessed. This avoids computing etags and encoding for files that are never requested.

This mode completely disables the deps optimizer (passing disableDepsOptimizer: true), since Rolldown handles all bundling. HMR works through Rolldown's native HMR API rather than Vite's module-graph-based approach.

Currently limited to the client environment, this feature points toward Vite's future: a fully bundled dev mode with Rolldown that bridges the dev/build gap entirely.

What's Coming Next

We've now covered both pillars of Vite's dev experience — HMR for fast iteration and pre-bundling for npm dependency serving. In the final article, we'll explore production builds: how buildEnvironment resolves Rolldown options, how ViteBuilder.buildApp() orchestrates multi-environment builds, and the ModuleRunner system that executes server-side code with HMR support and cross-environment transport.