Vite’s Hot Module Replacement (HMR) doesn’t just update changed code; it actually rewrites the history of your application’s execution in memory, making it feel like time travel for developers.

Let’s see HMR in action. Imagine a simple React app with a Counter.jsx component:

// src/Counter.jsx
import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

export default Counter;

And an App.jsx importing it:

// src/App.jsx
import React from 'react';
import Counter from './Counter';

function App() {
  return (
    <div>
      <h1>My App</h1>
      <Counter />
    </div>
  );
}

export default App;

When you run npm run dev, Vite starts its development server. Now, change Click me in Counter.jsx to Increment:

// src/Counter.jsx
import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

export default Counter;

Save the file. Your browser instantly updates the button text without a full page reload. The count state is preserved. That’s HMR.

Vite achieves this by leveraging native ES Modules (ESM) and a dedicated HMR server. When you edit a file, Vite’s server intercepts the change. Instead of invalidating the entire build, it determines which modules are affected by the change and sends a minimal update message to the browser. The browser then uses a small HMR runtime injected into your application to request and apply these updates. This runtime understands how to "hot swap" modules – replacing old code with new code while preserving application state.

The core problem HMR solves is the friction of traditional full page reloads during development. These reloads reset application state, forcing you to re-navigate and re-perform actions to get back to the state you were in, slowing down the development feedback loop. HMR keeps your application state intact, allowing you to see the impact of your code changes immediately.

The exact levers you control are primarily within your application’s code and Vite’s configuration. For HMR to work seamlessly with frameworks like React, you often need specific HMR APIs. For example, in React with Fast Refresh (Vite’s default), when you change a component, Fast Refresh tries to re-render only that component. If it can’t safely update in place (e.g., changing hooks), it will trigger a full module refresh, but still aim to preserve state. You can also manually accept updates for specific modules using import.meta.hot.accept(), which is more common in vanilla JavaScript or certain plugin scenarios.

Vite’s HMR server operates on a "discovery" model. When a file changes, it traverses the import graph upwards from the changed file to find the nearest HMR boundary. If a boundary is found (e.g., a module that explicitly accepts hot updates), only the modules below that boundary are reloaded. If no boundary is found, the update might propagate further up, potentially leading to a full page refresh if the change is significant enough or touches a root-level module.

The most surprising thing most developers don’t realize is that HMR doesn’t magically update everything. It’s highly dependent on the module graph and how your framework or libraries are designed to expose HMR boundaries. If you have a deeply nested import structure and a change occurs in a leaf node, HMR can efficiently update that specific part. However, if a change affects a module that many other modules depend on directly, the HMR update might be less granular, or even trigger a full refresh, because the system can’t guarantee state preservation across too many dependencies.

Understanding this module graph traversal and the concept of HMR boundaries is key to effective HMR debugging. If HMR isn’t working as expected, it’s often because a change is invalidating a boundary higher up the tree than you anticipated, or the framework’s HMR integration isn’t correctly identifying the hot-swappable parts of your code.

The next logical step is to explore how HMR interacts with different plugin ecosystems and custom server configurations.

Want structured learning?

Take the full Vite course →