Read OSS

Hot Module Replacement and Dependency Pre-Bundling in Vite

Advanced

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 token query 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 the clientInjectionsPlugin.

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.include in 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.