When a File Changes: The Module Graph and HMR's Boundaries

When a developer saves a file in a Vite application, the important question is not only how to transform the new text. Vite must decide which already-running modules are affected, which one is allowed to accept the change, and whether the browser can preserve its current state. This chapter follows that lifecycle through Vite's module graph and Hot Module Replacement, or HMR, so the reader can predict when an edit stays local and when a broader reload is the correct result.

The previous request path left Vite with three kinds of knowledge: transformed module results, import relationships, and the runtime context in which each module is being used. HMR depends most heavily on the second kind. A cache can answer “what code did we produce last time?” A graph can answer “who depends on that code, and where can an update stop safely?”

A file is more than its contents

A source file has a path and a body, but Vite serves a module through a URL that may include queries, aliases, or plugin-generated identities. The development server therefore records module nodes in a graph. A node can be associated with a URL, a resolved identifier, and a file; it can also point to the modules it imports and the modules that import it. Its transform result is cached alongside state relevant to HMR.

That relationship matters when one file has several consumers. Suppose a component imports a utility, while two routes import the component. Editing the utility does not automatically mean that every route must be rebuilt. Vite can inspect the graph to find the affected module and walk toward modules that can handle the update. If the component or a framework boundary accepts the new utility, propagation can stop there. If no boundary can handle it, the change may continue through the importers until a full reload is required.

The graph is also what lets Vite invalidate the right cached result. When a watched file changes, a transform result that was correct a moment ago is no longer current. Vite marks the corresponding module as invalidated and reprocesses it when the browser or update path requests it. The current implementation also de-duplicates simultaneous transform requests and compares processing and invalidation timestamps. That protects a newer edit from being overwritten by an older transformation that happened to finish later.

This is a state problem, not just a text replacement problem. During a change, several events can overlap: the browser may request a module, a file watcher may report an edit, and a dependency optimizer may cause a reload. A server that simply replaced the file contents in a cache could return stale code or apply an update to the wrong module instance. The graph and invalidation state give Vite a way to order those transitions.

The environment matters as well. The same file may be used in the browser and in a server-rendering environment, but the two consumers can have different resolution conditions, transformations, and execution behavior. Current Vite environments maintain isolated module graphs. That prevents a client update from being silently treated as an SSR update, or an SSR-only module from being assumed to have a browser HMR boundary. The compatibility APIs can present a mixed view for older integrations, but the direction of the design is per-environment ownership.

From a file edit to an HMR message

The update begins outside the module graph, with the file watcher observing a change. Vite identifies the environments that may contain modules associated with that file, gathers their graph nodes, and gives plugins an opportunity to refine the affected set. A framework file may map to more than one served module, so the change is not always a one-file-to-one-node operation.

For each environment, Vite's HMR logic examines the affected modules and searches for acceptable propagation boundaries. The server invalidates the changed modules, computes the update path, and sends a message through the environment's hot channel. In a browser environment, that channel normally reaches the HMR client over WebSocket. The client then loads the updated module and invokes the acceptance behavior registered by the module or framework integration.

The word “acceptance” is precise here. It does not mean that Vite has proved the update is semantically safe. It means that the module has declared how it will receive the replacement, or that a direct importer has declared how it will respond when a dependency changes. The code responsible for accepting the update owns the application-level decision: replace a value, refresh a component, recreate a subscription, or invalidate a wider part of the graph.

A module can self-accept with import.meta.hot.accept. It can also accept an update from a direct dependency and receive the dependency's new module namespace in a callback. Vite's documentation calls such a module an HMR boundary. Framework plugins use the same underlying mechanism to preserve component state or implement framework-specific refresh behavior.

The HMR code must be conditional:

if (import.meta.hot) {
  import.meta.hot.accept()
}

The guard is not merely defensive syntax. It lets production builds remove development-only HMR code. Vite also performs static analysis of the recognizable import.meta.hot.accept( call, so a dynamically disguised equivalent is not necessarily treated as an HMR declaration. The module author is participating in a compile-time contract as well as a runtime callback.

stateDiagram-v2 [*] --> Unrequested Unrequested --> Processing: Browser requests module Processing --> Cached: Resolve load and transform complete Cached --> Cached: Reuse fresh graph result Cached --> Invalidated: File or dependency changes Invalidated --> HMRBoundary: Safe acceptance path found HMRBoundary --> Disposed: Old instance is cleaned up Disposed --> Cached: New module instance is applied Invalidated --> FullReload: No safe boundary or dead end FullReload --> Processing: Browser requests after reload Cached --> Pruned: Module is no longer imported Pruned --> [*]

The diagram is a lifecycle, not a promise that every module visits every state. An ordinary source module may move from processing to cached and later to invalidated. A self-accepting module can follow the boundary path. A CSS or framework integration may have its own cleanup behavior. A module that is removed from the page can be pruned rather than updated. The graph records enough state for Vite to distinguish these cases.

What an HMR boundary promises

An HMR boundary promises to handle a new module instance without requiring all of its importers to restart. That promise is narrower than “the application will look exactly as it did before.” The callback receives new exports, and the application decides what to do with them. If a module has created a timer, event listener, WebSocket subscription, DOM node, or other persistent side effect, replacing its code without cleanup can duplicate behavior or leave stale resources behind.

Vite provides hot.dispose for that transition. A module can register cleanup logic that runs before its old instance is replaced, and it can place data in hot.data when information must survive across instances. The data object belongs to the module path and is reused across successive HMR instances. The code must mutate that object rather than replace it wholesale, because other handlers may be using the same persisted object.

hot.prune addresses a different transition. A module can be updated while it remains imported, or it can stop being imported because the current page no longer references it. Pruning gives an integration a place to clean up when the module leaves the active graph. Vite's documentation describes CSS as one current use. This distinction helps explain why “dispose” and “prune” are not redundant names: one is tied to replacement, the other to removal from the page.

A boundary also has a responsibility around exports. Vite's HMR implementation does not literally swap the originally imported module in every case. If a boundary re-exports values from a dependency, it must update those exports in the way the API expects. Importers above the boundary are not notified simply because a lower module changed. That limitation is what makes local updates inexpensive, but it means the boundary must actually represent the state that the application needs to refresh.

A module can decide at runtime that it cannot safely handle a particular update. Calling import.meta.hot.invalidate() asks Vite to propagate the update to importers as though the current boundary were not sufficient. This is different from refusing to register an acceptance handler at all: the handler is still needed so the client will listen for future updates. The invalidation message can provide context in the browser and terminal.

These APIs make HMR a cooperation contract. Vite supplies graph traversal, invalidation, module fetching, and transport. A framework or module supplies the semantics of replacement and cleanup. When both sides agree, the visible result can be a component update with application state intact. When the agreement is absent, a reload is safer than pretending that a local replacement preserved behavior.

Why propagation sometimes stops

The simplest HMR case is a module that accepts its own update and exports a value whose consumers do not need to be recreated. Real applications contain several ways for that assumption to fail.

First, the changed module may have no acceptance boundary in the path toward the entry module. Vite can walk importers, but it cannot invent a safe application-level response for arbitrary side effects. If propagation reaches a dead end, the server falls back to a full reload. That reload discards browser state, but it re-establishes the application from the entry point rather than applying an update with unknown consequences.

Second, a syntax error can prevent the new module from being evaluated. The acceptance callback may receive no usable module, and Vite sends an error event to the client so the development overlay can report it. The old running application and the new source are then in a different relationship from an ordinary successful update; treating the failure as a normal replacement would hide the actual error.

Third, an update may cross a framework or plugin boundary whose generated modules do not map one-to-one to the edited file. Vite's plugin HMR hook receives the affected module list and can narrow it, invalidate modules manually, trigger a full reload, or send a custom event that application code handles through import.meta.hot.on. This is how a file format with domain-specific update semantics can participate without forcing Vite core to understand every language or framework.

Fourth, runtime context can change the answer. An edit to code used only by SSR does not necessarily mean that the browser should reload. A custom SSR integration may choose to forward a full reload when server-only changes affect the rendered page, while another integration may handle the server update without touching the browser. In the current multi-environment design, each environment's graph is examined separately and each environment can have its own hot channel and plugin behavior.

Finally, configuration and environment-file changes are not ordinary module edits. Vite can restart the development server when its configuration or relevant environment files change. A restart rebuilds the server-side state and plugin containers; it is a different lifecycle from sending a module update through the browser. The distinction is useful when interpreting logs: a restart is not an HMR boundary failure, and a full page reload is not necessarily a production-style rebuild.

The role of plugins in the update path

Vite's plugin API exposes a custom HMR hook for cases where ordinary import propagation is not enough. The hook receives the changed file, affected modules, a safe read function, the server, and a timestamp. A plugin can filter the affected module list to make updates more accurate. It can choose a full reload, invalidate modules itself, or send an application-specific event.

That flexibility is powerful because Vite's core graph only knows general module relationships. A framework plugin may know that one source file produces several virtual modules. A content plugin may know that a data change should refresh a generated view. A development tool may need to send a custom event rather than replace an ESM namespace. The plugin participates at the point where general graph state meets domain-specific meaning.

The cost is hidden coupling. Plugin state must be kept separate for separate environments, and a plugin that assumes a single client graph may behave incorrectly when SSR or another runtime is added. The newer environment-aware hook exposes the current environment explicitly. It lets a plugin perform the same update logic once per environment, but it also makes ownership impossible to ignore: a change can affect client and server graphs differently.

HMR precision is thus a systems property. It depends on the watcher identifying the change, the graph preserving relationships, import analysis recognizing the boundary, the plugin narrowing or extending the update when needed, the transport delivering it, and the accepting code cleaning up and applying it. A failure at any layer can widen the update or produce an error. That is a more useful explanation than assigning every reload to “Vite being slow.”

A local update is a bounded guarantee

Vite's HMR model does not promise that every edit preserves state. It promises a mechanism for making a local update when the graph and module boundaries can describe one safely. The module graph makes the affected set visible. Invalidation prevents stale transforms from surviving a change. The hot channel delivers an update to the right environment. The boundary callback and cleanup hooks determine whether the running application can accept it.

When those conditions hold, the developer gets the intended short loop: edit, transform the affected module, apply the update, continue with state intact. When they do not, invalidation, an error overlay, a server restart, or a full reload makes the boundary explicit. The speed comes from not redoing unrelated work; the reliability comes from refusing to pretend that unrelated work is always safe to skip.

References