Tooling

Hot Module Replacement: How It Works

A deep dive into Hot Module Replacement covering webpack and Vite HMR internals, the HMR API, state preservation, and writing HMR-compatible JavaScript code.

Hot Module Replacement: How It Works

Hot Module Replacement is one of those features that developers use every day without understanding what actually happens under the hood. You save a file, the change appears in the browser, and you move on. But when HMR breaks, when state gets lost, when a module refuses to hot-update, the lack of understanding becomes a real problem. This article tears apart the HMR mechanism, explains how webpack and Vite implement it differently, and shows you how to write code that plays nicely with it.

What HMR Is and Why It Matters

There are three levels of development feedback loops, and conflating them is a common mistake.

Full Page Reload is the simplest approach. You change a file, the build tool recompiles, and the browser does a full navigation to reload the page. Every piece of application state is destroyed. Form inputs, scroll position, component state, modal visibility — all gone. If you are building a complex UI with deeply nested state, this destroys your workflow.

Live Reload is a step up. A WebSocket connection between the dev server and the browser triggers window.location.reload() automatically when files change. You skip the manual refresh, but you still lose all application state. The page still fully reloads.

Hot Module Replacement is fundamentally different. Instead of replacing the entire page, HMR surgically replaces only the modules that changed, while keeping the rest of the application running. Your counter stays at 47. Your form stays half-filled. Your accordion stays open. The development feedback loop drops from seconds to milliseconds.

The productivity difference is not marginal. On a large application where a full reload takes 3-5 seconds and requires clicking through several screens to reproduce your current state, HMR gives you sub-second feedback with zero state loss. Over a full day of development, this adds up to hours.

The HMR Architecture

Every HMR implementation shares the same fundamental architecture, regardless of the bundler.

The Server Side

The dev server watches the filesystem for changes. When a file changes, the server determines which modules are affected, generates an update payload, and pushes it to the client over a WebSocket connection. The server maintains a module graph — a dependency tree that maps every module to its dependents and dependencies.

The Client Side

An HMR runtime is injected into the client bundle. This runtime establishes a WebSocket connection back to the dev server, listens for update signals, fetches the updated module code, and executes replacement logic. The runtime walks the module graph upward from the changed module, looking for an HMR boundary — a module that has registered itself as capable of accepting hot updates.

The Update Flow

Here is the sequence that executes every time you save a file:

  1. Filesystem watcher detects file change
  2. Server recompiles the affected module(s)
  3. Server sends an update notification over WebSocket (usually just a hash or manifest)
  4. Client runtime receives the notification
  5. Client fetches the actual update payload (new module code)
  6. Client runtime walks the module graph from the changed module upward
  7. If an HMR boundary is found, the runtime executes dispose handlers, replaces the module, and runs accept handlers
  8. If no boundary is found, the runtime falls back to a full page reload

That last point is critical. HMR is not magic. If no module in the dependency chain has registered an accept handler, the update bubbles all the way to the root and triggers a full reload. This is the safety net.

How Webpack HMR Works Internally

Webpack's HMR implementation is the most mature and well-documented. Understanding it provides the foundation for understanding every other implementation.

The Manifest and Update Chunks

When webpack detects a change, it generates two things:

  • A manifest (JSON) that lists which chunks have been updated, identified by the previous compilation hash and the new compilation hash
  • Update chunks (JavaScript) containing the new module code

The manifest URL follows the pattern [hash].hot-update.json. The update chunk URL follows [id].[hash].hot-update.js. The client runtime fetches the manifest first to learn which chunks changed, then fetches only those chunks.

The module.hot API

Webpack exposes the HMR API through module.hot. This object only exists in development builds — webpack strips it out of production bundles via dead code elimination.

if (module.hot) {
    module.hot.accept('./counter.js', function() {
        // This runs after counter.js has been replaced
        var counter = require('./counter.js');
        counter.render();
    });
}

The if (module.hot) guard is important. It prevents runtime errors in production where module.hot is undefined, and it gives webpack a signal to tree-shake the entire block out of the production build.

The Webpack HMR Runtime

Webpack injects a runtime into your bundle that includes:

  • hotDownloadManifest — fetches the update manifest via AJAX
  • hotDownloadUpdateChunk — loads update chunks via JSONP (script tags)
  • hotApply — the core logic that replaces modules and runs handlers
  • WebSocket client code that listens for hash and ok messages from webpack-dev-server

The runtime maintains its own module registry, separate from the normal __webpack_modules__ object, to track which modules can accept updates and which dispose handlers need to run.

How Vite HMR Works

Vite takes a fundamentally different approach because it serves native ES modules during development. There is no bundling step for most of your source code.

Native ESM Advantage

When you change a file in Vite, only that single file needs to be retransformed. There is no chunk graph to recalculate, no manifest to generate, no update chunks to build. Vite sends a WebSocket message pointing to the changed module's URL, and the client simply re-imports it with a cache-busting query parameter.

// WebSocket message from Vite
{
    "type": "update",
    "updates": [{
        "type": "js-update",
        "path": "/src/counter.js",
        "timestamp": 1707840000000
    }]
}

The client then does a dynamic import('/src/counter.js?t=1707840000000'), which bypasses the browser module cache and fetches the new version. This is dramatically simpler than webpack's approach.

import.meta.hot

Vite exposes the HMR API through import.meta.hot instead of module.hot. The semantics are similar but the API is slightly different. Note that while Vite itself uses ESM, the concepts apply regardless of syntax:

// Vite HMR API (shown for comparison)
if (import.meta.hot) {
    import.meta.hot.accept(function(newModule) {
        // Handle the update
    });
}

For this article's working examples, we will focus on the webpack module.hot API since it uses the CommonJS style required by the project conventions.

The HMR API in Depth

accept — Registering for Hot Updates

The accept method is the core of the HMR API. It comes in two forms.

Self-accepting modules handle their own replacement:

// counter.js — self-accepting
var count = 0;

function render() {
    document.getElementById('count').textContent = count;
}

function increment() {
    count++;
    render();
}

module.exports = { render: render, increment: increment };

if (module.hot) {
    module.hot.accept();
    // This module will replace itself when updated
    // But 'count' resets to 0 every time
}

Dependency-accepting modules handle the replacement of their dependencies:

// app.js — accepts updates from counter.js
var counter = require('./counter.js');

counter.render();

if (module.hot) {
    module.hot.accept('./counter.js', function() {
        // counter.js was replaced, re-require it
        counter = require('./counter.js');
        counter.render();
    });
}

The difference matters. A self-accepting module re-executes entirely, which resets any module-level state. A dependency-accepting module can control how the new version integrates, preserving state that lives in the parent.

dispose — Cleanup Before Replacement

The dispose handler runs just before a module is replaced. This is where you tear down side effects:

if (module.hot) {
    module.hot.dispose(function(data) {
        // 'data' is an object that persists across updates
        data.count = count;

        // Clean up side effects
        clearInterval(timer);
        element.removeEventListener('click', handleClick);
    });

    module.hot.accept();

    // Restore state from previous version
    if (module.hot.data && module.hot.data.count !== undefined) {
        count = module.hot.data.count;
    }
}

The data object passed to dispose is the same object available as module.hot.data in the next version of the module. This is the primary mechanism for preserving state across hot updates.

decline — Refusing Updates

A module can refuse hot updates entirely:

if (module.hot) {
    module.hot.decline();
    // Any change to this module forces a full reload
}

Use this for modules that have side effects that cannot be safely undone — global polyfills, prototype modifications, or one-time initialization code.

invalidate — Forcing Re-evaluation

The invalidate method tells the HMR runtime that the current module's accept handler cannot process an update and the update should bubble upward:

if (module.hot) {
    module.hot.accept(function() {
        if (cannotHandle()) {
            module.hot.invalidate();
        }
    });
}

HMR Boundaries and Bubble-Up

When a module changes, the HMR runtime walks up the dependency graph looking for the nearest module that has called accept. This is called the HMR boundary.

Consider this dependency chain:

app.js -> layout.js -> sidebar.js -> widget.js

If widget.js changes and none of these modules have accept handlers, the update bubbles all the way to the entry point, and the runtime triggers a full reload. If sidebar.js has an accept handler for widget.js, that is the boundary — only sidebar and widget get re-evaluated.

This is why framework plugins (React Fast Refresh, Vue HMR) are so valuable. They automatically insert HMR boundaries at every component, so changing any component only re-renders that component and its subtree.

CSS HMR: Why It Works Automatically

CSS hot replacement works out of the box with every major bundler. The reason is simple: CSS has no side effects that need cleanup.

When you load CSS via style-loader (webpack) or a <style> tag (Vite), the update process is:

  1. Remove the old <style> tag
  2. Insert a new <style> tag with the updated CSS
  3. The browser automatically repaints

There is no state to preserve, no event listeners to clean up, no variables to carry forward. The old styles are simply replaced with new styles. This is why style-loader registers its own module.hot.accept handler internally — you never need to write HMR code for CSS.

// This is roughly what style-loader does internally
var styleElement = document.createElement('style');
styleElement.textContent = cssContent;
document.head.appendChild(styleElement);

if (module.hot) {
    module.hot.accept();
    module.hot.dispose(function() {
        styleElement.parentNode.removeChild(styleElement);
    });
}

JavaScript HMR: Why It Requires Explicit Handling

JavaScript is the opposite of CSS. Every JavaScript module can have side effects: event listeners, timers, DOM mutations, global state modifications, WebSocket connections, mutation observers. The HMR runtime has no way to know what side effects a module created, so it cannot automatically clean them up.

This is why vanilla JavaScript HMR requires you to write explicit accept and dispose handlers. You are telling the runtime: "I know what side effects this module creates, and here is how to clean them up."

HMR for Other File Types

Images: Most dev servers handle image HMR by updating the URL with a cache-busting parameter. Any <img> element or CSS background-image referencing the changed asset gets its URL updated.

JSON: JSON modules can be self-accepting because they have no side effects. The new data simply replaces the old export.

Templates/HTML: Template HMR depends on the framework. Pug, Handlebars, and similar template engines typically trigger a full component re-render when the template changes.

Complete Working Example

Here is a fully configured webpack project with HMR that demonstrates state preservation, CSS hot updates, and proper cleanup.

webpack.config.js

var path = require('path');
var webpack = require('webpack');
var HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    mode: 'development',
    entry: './src/app.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist')
    },
    devServer: {
        hot: true,
        port: 3000,
        static: './dist'
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: ['style-loader', 'css-loader']
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './src/index.html'
        })
    ]
};

src/index.html

<!DOCTYPE html>
<html>
<head>
    <title>HMR Demo</title>
</head>
<body>
    <div id="app">
        <h1>HMR Counter Demo</h1>
        <div id="counter-root"></div>
        <div id="log"></div>
    </div>
</body>
</html>

src/styles.css

body {
    font-family: sans-serif;
    max-width: 600px;
    margin: 40px auto;
    padding: 0 20px;
    background: #f5f5f5;
}

#counter-root {
    background: white;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    text-align: center;
}

.count-display {
    font-size: 48px;
    font-weight: bold;
    color: #333;
    margin: 20px 0;
}

button {
    padding: 10px 24px;
    font-size: 16px;
    cursor: pointer;
    margin: 0 5px;
    border: none;
    border-radius: 4px;
    background: #2196F3;
    color: white;
}

button:hover {
    background: #1976D2;
}

#log {
    margin-top: 20px;
    padding: 10px;
    font-family: monospace;
    font-size: 12px;
    color: #666;
}

src/counter.js

var state = {
    count: 0,
    listeners: []
};

function createCounter(rootElement) {
    var container = document.createElement('div');

    var display = document.createElement('div');
    display.className = 'count-display';

    var incrementBtn = document.createElement('button');
    incrementBtn.textContent = 'Increment';

    var decrementBtn = document.createElement('button');
    decrementBtn.textContent = 'Decrement';

    var resetBtn = document.createElement('button');
    resetBtn.textContent = 'Reset';
    resetBtn.style.background = '#f44336';

    function render() {
        display.textContent = state.count;
    }

    function onIncrement() {
        state.count++;
        render();
    }

    function onDecrement() {
        state.count--;
        render();
    }

    function onReset() {
        state.count = 0;
        render();
    }

    incrementBtn.addEventListener('click', onIncrement);
    decrementBtn.addEventListener('click', onDecrement);
    resetBtn.addEventListener('click', onReset);

    // Track listeners for cleanup
    state.listeners.push(
        { el: incrementBtn, event: 'click', fn: onIncrement },
        { el: decrementBtn, event: 'click', fn: onDecrement },
        { el: resetBtn, event: 'click', fn: onReset }
    );

    container.appendChild(display);
    container.appendChild(incrementBtn);
    container.appendChild(decrementBtn);
    container.appendChild(resetBtn);

    rootElement.innerHTML = '';
    rootElement.appendChild(container);

    render();

    return { render: render };
}

function destroyCounter() {
    state.listeners.forEach(function(listener) {
        listener.el.removeEventListener(listener.event, listener.fn);
    });
    state.listeners = [];
}

module.exports = {
    createCounter: createCounter,
    destroyCounter: destroyCounter,
    state: state
};

// HMR handling
if (module.hot) {
    module.hot.dispose(function(data) {
        // Preserve the count across hot updates
        data.savedCount = state.count;
        // Clean up event listeners from the old module
        destroyCounter();
    });

    module.hot.accept();

    // Restore state from previous version
    if (module.hot.data && module.hot.data.savedCount !== undefined) {
        state.count = module.hot.data.savedCount;
    }
}

src/logger.js

function log(message) {
    var logEl = document.getElementById('log');
    if (logEl) {
        var entry = document.createElement('div');
        var timestamp = new Date().toLocaleTimeString();
        entry.textContent = '[' + timestamp + '] ' + message;
        logEl.insertBefore(entry, logEl.firstChild);

        // Keep only last 10 entries
        while (logEl.children.length > 10) {
            logEl.removeChild(logEl.lastChild);
        }
    }
    console.log(message);
}

module.exports = { log: log };

src/app.js

require('./styles.css');
var counter = require('./counter.js');
var logger = require('./logger.js');

var root = document.getElementById('counter-root');
var instance = counter.createCounter(root);

logger.log('Application started');

if (module.hot) {
    // Accept updates from counter.js
    module.hot.accept('./counter.js', function() {
        logger.log('Counter module updated via HMR');

        // Re-require the updated module
        counter = require('./counter.js');

        // Re-create the counter — state is preserved inside counter.js
        instance = counter.createCounter(root);
    });

    // Accept updates from logger.js
    module.hot.accept('./logger.js', function() {
        logger = require('./logger.js');
        logger.log('Logger module updated via HMR');
    });

    // CSS updates are handled automatically by style-loader
    // No accept handler needed for styles.css
}

package.json

{
    "name": "hmr-demo",
    "scripts": {
        "start": "webpack serve"
    },
    "devDependencies": {
        "css-loader": "^6.8.0",
        "html-webpack-plugin": "^5.5.0",
        "style-loader": "^3.3.0",
        "webpack": "^5.88.0",
        "webpack-cli": "^5.1.0",
        "webpack-dev-server": "^4.15.0"
    }
}

Run npm install then npm start. Click the increment button a few times to build up a count. Then edit counter.js — change a button label or add a new button. Save the file. The counter will update in the browser without losing the count value. Edit styles.css — change a color or font size. The styles update instantly without any JavaScript re-execution.

Writing HMR-Compatible Code

There are patterns that make your code naturally HMR-friendly:

  1. Separate state from rendering. Keep state in a module-level object and rendering in functions. This makes it easy to preserve the state object across updates while re-running the render functions.

  2. Track your side effects. Every addEventListener, setInterval, setTimeout, and DOM mutation should be tracked so dispose can clean it up.

  3. Make initialization idempotent. If your module's top-level code runs twice, it should not create duplicate side effects. Use guards, or clear previous state before re-initializing.

  4. Use the data object. The module.hot.data object is your bridge between the old and new module versions. Use it to carry state, not global variables.

  5. Avoid module-level DOM queries that run once. If your module does var el = document.getElementById('app') at the top level, that reference persists across HMR updates but the element might have been replaced.

  6. Keep HMR code at the bottom. Put if (module.hot) blocks at the bottom of your module, after all exports. This keeps the HMR logic visually separate from business logic.

HMR in Production: Why Not

HMR is strictly a development tool. There are several reasons it is never used in production:

  • The HMR runtime adds significant code to your bundle
  • WebSocket connections consume server resources per client
  • The module replacement logic adds overhead to every module
  • Security: you do not want clients to be able to hot-swap arbitrary code
  • Reliability: HMR can fail silently, leaving the application in an inconsistent state

Webpack automatically strips module.hot checks from production builds. If you see HMR code in a production bundle, your build configuration is wrong.

Common Issues and Troubleshooting

Issue 1: Full page reload instead of HMR. The most common cause is a missing accept handler somewhere in the dependency chain. When a module changes but no ancestor has called module.hot.accept, the update bubbles to the entry point and triggers a reload. Check your browser console — webpack logs messages like [HMR] Cannot find update. Need to do a full reload. Add accept handlers at appropriate boundaries.

Issue 2: State resets on every update. This happens when a self-accepting module reinitializes state at the top level. Use module.hot.dispose to save state into the data object, then check module.hot.data to restore it when the module re-executes.

Issue 3: Duplicate event listeners after HMR. If you add event listeners during module initialization but do not remove them in a dispose handler, each hot update adds another listener. You end up with click handlers firing multiple times. Always pair addEventListener in initialization with removeEventListener in dispose.

Issue 4: Stale closures after update. When module A accepts updates from module B, the accept callback gets the new module B. But if module A captured references to module B's exports in closures earlier, those closures still reference the old module. Re-require the dependency inside the accept callback and update all references.

Issue 5: WebSocket connection lost. Network interruptions or dev server restarts can break the WebSocket connection. Most dev servers implement reconnection logic with exponential backoff. If you see the console message [HMR] Disconnected, check that your dev server is still running. Webpack Dev Server reconnects automatically.

Best Practices

  1. Use framework HMR plugins. React Fast Refresh, Vue HMR, and Svelte HMR handle component-level boundaries automatically. Writing manual HMR code for framework components is unnecessary and error-prone.

  2. Do not store critical state in HMR-managed modules. If losing a piece of state would break the application, consider moving it to a store that persists independently (Redux, Vuex, URL state, localStorage).

  3. Test your dispose handlers. Intentionally trigger HMR updates and verify that timers, listeners, and subscriptions are properly cleaned up. Memory leaks from incomplete cleanup accumulate during long development sessions.

  4. Keep the module graph shallow where possible. Deep dependency chains mean more potential for missing HMR boundaries. Flat module structures are easier to reason about for HMR.

  5. Guard all HMR code behind if (module.hot). This is not optional. Without the guard, production builds will throw reference errors, and the bundler cannot tree-shake the HMR code.

  6. Use HMR logging to debug update chains. Webpack outputs detailed HMR logs to the console when devServer.client.logging is set to 'verbose'. These logs show exactly which modules were updated, which accept handlers ran, and why a full reload was triggered.

  7. Accept updates as close to the changed module as possible. The closer the HMR boundary is to the changed module, the less code needs to re-execute. Accepting at the entry point re-runs nearly everything, defeating the purpose of HMR.

HMR Performance

HMR performance depends on two factors: how fast the server recompiles the changed module, and how fast the client applies the update.

Webpack's recompilation speed depends on your loader chain. If a single file change triggers Babel, TypeScript, PostCSS, and Sass compilation, the recompile takes longer. Vite is faster here because it only transforms the single changed file using native ESM — no rebundling required.

On the client side, update application is usually fast (under 10ms) because it involves replacing a function reference and calling an accept handler. The bottleneck is almost always the server-side recompilation.

For large webpack projects, enabling cache.type: 'filesystem' and using swc-loader instead of babel-loader can cut HMR recompile times by 50-80%.

Comparing HMR Implementations

Feature Webpack Vite Parcel
Transport WebSocket WebSocket WebSocket
Update unit Chunk Single file Asset
API surface module.hot import.meta.hot module.hot
CSS HMR Via style-loader Built-in Built-in
React support React Fast Refresh plugin @vitejs/plugin-react Built-in
Recompile speed Moderate (depends on loaders) Fast (single file transform) Moderate
Reliability Excellent (mature) Very good Good

Webpack's HMR is the most battle-tested. Vite's is the fastest. Parcel's requires the least configuration. All three fall back to full reload when an update cannot be applied, which is the correct behavior.

References

Powered by Contentful