πŸ“š Learning Hub
Β· 3 min read

How Hot Reload Actually Works Under the Hood


You save a file, and your browser updates in milliseconds without losing state. It feels like magic. It’s not β€” it’s a carefully orchestrated pipeline.

Step 1: File Watcher Detects the Change

Your dev server (Vite, webpack, etc.) uses the OS file system API to watch for changes. On macOS it’s FSEvents, on Linux it’s inotify, on Windows it’s ReadDirectoryChangesW.

When you save Button.tsx, the watcher fires a callback with the file path. This happens within a few milliseconds of the file write.

// Simplified β€” this is what Vite does internally
fs.watch(projectRoot, { recursive: true }, (event, filename) => {
  if (filename.endsWith('.tsx') || filename.endsWith('.css')) {
    handleFileChange(filename)
  }
})

Step 2: Module Graph Lookup

The dev server maintains a graph of every module in your app and how they import each other. When Button.tsx changes, it looks up which modules depend on it.

App.tsx β†’ Layout.tsx β†’ Sidebar.tsx β†’ Button.tsx
                     β†’ Header.tsx β†’ Button.tsx

This graph is built during the initial page load as the browser requests modules. Every import statement creates an edge in the graph.

Step 3: HMR Boundary Detection

Here’s the critical part. The dev server walks up the module graph from the changed file, looking for an β€œHMR boundary” β€” a module that knows how to accept updates.

In React, components are HMR boundaries (thanks to React Fast Refresh). When Button.tsx changes, the HMR system finds that Button is a React component and can be hot-replaced without reloading the page.

If no boundary is found (like changing a utility function that’s imported everywhere), the dev server falls back to a full page reload.

Step 4: WebSocket Notification

The dev server sends a message to the browser over a WebSocket connection:

{
  "type": "update",
  "updates": [{
    "type": "js-update",
    "path": "/src/components/Button.tsx",
    "timestamp": 1710000000
  }]
}

The browser’s HMR client (injected by the dev server) receives this and knows which module to re-fetch.

Step 5: Module Re-fetch

The browser fetches the updated module from the dev server. Vite adds a timestamp query parameter to bust the browser cache:

GET /src/components/Button.tsx?t=1710000000

The dev server transforms the file on-the-fly (TypeScript β†’ JavaScript, JSX β†’ createElement calls) and sends it back.

Step 6: Hot Swap

The HMR runtime replaces the old module with the new one. For React components, React Fast Refresh handles this:

  1. It saves the current state of the component
  2. It unmounts the old version
  3. It mounts the new version
  4. It restores the saved state

This is why your form inputs keep their values and your toggle stays open when you edit a component.

Why It Sometimes Breaks

State shape changes β€” If you add a new useState hook, Fast Refresh can’t map old state to new state. It does a full remount, losing state.

Non-component exports β€” If a file exports both a component and a utility function, Fast Refresh can’t safely hot-replace it. You get a full reload.

Side effects at module scope β€” Code that runs at import time (like const socket = new WebSocket(...)) runs again on every hot update, creating duplicate connections.

CSS modules β€” Changing a CSS module usually triggers a full reload because the class name hash changes, which means the JavaScript that references it also needs updating.

Vite vs Webpack HMR

Webpack bundles your entire app and serves a single file. When one module changes, it rebuilds the affected chunk and sends a patch.

Vite serves modules individually using native ES modules. When one module changes, it only re-transforms that single file. No bundling step. This is why Vite’s HMR is nearly instant regardless of project size, while webpack’s HMR slows down as your project grows.

The difference is dramatic on large projects: Vite stays under 50ms, webpack can take 2-5 seconds.

The Full Timeline

0ms   β€” You press Ctrl+S
2ms   β€” File watcher detects change
5ms   β€” Module graph lookup + boundary detection
8ms   β€” WebSocket message sent to browser
12ms  β€” Browser fetches updated module
20ms  β€” Module transformed (TS β†’ JS)
25ms  β€” HMR runtime swaps the module
30ms  β€” React Fast Refresh re-renders component

30 milliseconds from save to update. That’s why it feels instant.

Related: What is Vite? Β· Vite vs Webpack Β· Vite HMR Not Working β€” fix