Skip to content

Fix: Webpack Dev Server Not Reloading — HMR and Live Reload Not Working

FixDevs ·

Quick Answer

How to fix Webpack dev server not reloading — Hot Module Replacement configuration, watchFiles settings, polling for Docker/WSL, HMR API for custom modules, and port conflicts.

The Problem

Webpack dev server doesn’t reload when source files change:

webpack serve

# Changes to src/App.js saved...
# ...nothing happens. Browser shows the old version.
# Expected: Browser updates automatically

Or Hot Module Replacement partially works but full page state is lost on every save:

[HMR] Waiting for update signal from WDS...
[HMR] Updated modules:
 - ./src/components/Button.js
[HMR] Update applied.
# Updates, but React component state resets on every change

Or HMR throws an error and falls back to a full reload:

[HMR] Cannot apply update.
[HMR] You need to restart the application!
Uncaught Error: Cannot find module './components/NewComponent'

Or in Docker or WSL2, no file change is ever detected:

# File saved inside WSL2
# webpack: no changes detected
# Browser: still showing old version

Why This Happens

Webpack dev server relies on the filesystem to detect changes and push updates to the browser. The chain is:

  1. File watcher detects a change on disk
  2. Webpack recompiles the affected modules
  3. WebSocket notifies the browser
  4. HMR runtime applies the update (or triggers a full reload)

Each step can fail independently:

  • File watcher not detecting changes — Docker volumes, WSL2, and network filesystems don’t emit native filesystem events. Webpack’s default watcher (using chokidar) requires polling on these systems.
  • Wrong publicPath — if output.publicPath doesn’t match where the browser loads assets from, the HMR WebSocket connects to the wrong URL.
  • hot: false or missing — HMR must be explicitly enabled. Without it, Webpack uses live reload (full page refresh) or no reload at all.
  • No HMR handler in the modulemodule.hot.accept() must be called for a module to accept hot updates without a full reload. React Fast Refresh adds this automatically for React components.
  • Port mismatch or WebSocket blocked — the browser’s WebSocket connection to the dev server is blocked by a proxy, firewall, or incorrect port configuration.
  • Multiple Webpack instances — running two webpack serve processes causes port conflicts, and updates from one don’t reach the browser connected to the other.

Fix 1: Enable HMR Correctly

HMR must be enabled both in the dev server config and, for non-React modules, in the module itself:

// webpack.config.js
const webpack = require('webpack');

module.exports = {
  mode: 'development',

  devServer: {
    hot: true,              // Enable HMR — required
    liveReload: true,       // Fall back to full reload if HMR fails
    open: true,             // Open browser on start
    port: 3000,
  },

  plugins: [
    new webpack.HotModuleReplacementPlugin(),  // Required for Webpack 4
    // Webpack 5: HMR is built-in when hot: true is set — no plugin needed
  ],
};

For non-framework JavaScript — add module.hot.accept():

// src/app.js — entry point
import { render } from './renderer';
import { createApp } from './createApp';

let app = createApp();
render(app);

// Tell Webpack this module accepts hot updates
if (module.hot) {
  module.hot.accept('./createApp', () => {
    // Re-import the updated module and re-render
    const { createApp: newCreateApp } = require('./createApp');
    app = newCreateApp();
    render(app);
  });
}

React projects — use React Fast Refresh instead of HMR manually:

npm install -D @pmmmwh/react-refresh-webpack-plugin react-refresh
// webpack.config.js
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');

module.exports = (env, argv) => ({
  mode: argv.mode,

  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            plugins: argv.mode === 'development'
              ? ['react-refresh/babel']  // Only in development
              : [],
          },
        },
      },
    ],
  },

  plugins: argv.mode === 'development'
    ? [new ReactRefreshWebpackPlugin()]
    : [],

  devServer: {
    hot: true,
  },
});

React Fast Refresh preserves component state between hot updates — unlike plain HMR, which resets state.

Fix 2: Fix File Watching in Docker and WSL2

Native filesystem event notifications (inotify) don’t reliably cross the Docker volume boundary or WSL2/Windows filesystem boundary. Switch to polling:

// webpack.config.js
module.exports = {
  devServer: {
    hot: true,
    watchFiles: {
      paths: ['src/**/*'],
      options: {
        usePolling: true,     // Poll instead of native events
        poll: 1000,           // Check every 1 second (increase if CPU usage is high)
      },
    },
  },

  // Also configure the main watcher
  watchOptions: {
    poll: 1000,               // Poll every 1 second
    aggregateTimeout: 300,    // Delay rebuild 300ms after last change (debounce)
    ignored: /node_modules/,  // Don't watch node_modules
  },
};

Environment-based polling (only enable in Docker/CI, not on native OS):

// webpack.config.js
const isDocker = require('is-docker');  // npm install is-docker

module.exports = {
  watchOptions: {
    poll: isDocker() ? 1000 : false,  // Poll only in Docker
    ignored: /node_modules/,
  },
};

WSL2-specific — edit files from the WSL2 filesystem, not Windows:

# WRONG — editing /mnt/c/Users/... from WSL2 uses Windows filesystem,
# inotify events don't fire reliably
code /mnt/c/Users/me/project/src/App.js

# CORRECT — keep the project in the WSL2 filesystem
# Clone the project inside WSL2: ~/projects/myapp/
code ~/projects/myapp/src/App.js

# Or enable polling as a fallback for /mnt paths:
# In webpack.config.js: watchOptions: { poll: 500 }

Fix 3: Fix publicPath and WebSocket URL

If the dev server’s WebSocket URL doesn’t match what the browser expects, HMR connections fail silently:

// webpack.config.js
module.exports = {
  output: {
    publicPath: '/',          // Must match the URL path for assets
  },

  devServer: {
    hot: true,
    port: 3000,
    host: '0.0.0.0',          // Required to accept connections from Docker host

    client: {
      webSocketURL: {
        hostname: 'localhost',  // Browser WebSocket connects to this host
        port: 3000,             // Must match devServer.port
        protocol: 'ws',         // 'ws' for http, 'wss' for https
      },
      // Or specify as string: 'ws://localhost:3000/ws'
    },
  },
};

When running behind a reverse proxy (Nginx, Traefik):

# nginx.conf — proxy WebSocket connections to Webpack dev server
location /ws {
    proxy_pass http://localhost:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_set_header Host $host;
}
// webpack.config.js — tell the client to use the proxied WebSocket URL
devServer: {
  client: {
    webSocketURL: 'wss://myapp.local/ws',   // The proxied URL
  },
},

Fix 4: Fix HMR for CSS and Style Updates

CSS changes should update without a full page reload using style-loader’s built-in HMR support:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',    // Injects CSS into DOM and supports HMR
          'css-loader',
        ],
      },
      {
        test: /\.scss$/,
        use: [
          'style-loader',    // style-loader (not MiniCssExtractPlugin) for HMR
          'css-loader',
          'sass-loader',
        ],
      },
    ],
  },
};

Warning: MiniCssExtractPlugin extracts CSS into separate files — it doesn’t support HMR. Use style-loader in development and MiniCssExtractPlugin in production:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

const isDev = process.env.NODE_ENV === 'development';

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          isDev ? 'style-loader' : MiniCssExtractPlugin.loader,  // Switch per environment
          'css-loader',
        ],
      },
    ],
  },
  plugins: isDev ? [] : [new MiniCssExtractPlugin()],
};

Fix 5: Fix “Cannot Apply Update” Errors

When HMR can’t apply an update, it logs an error and falls back to a full reload (or requires a manual reload):

[HMR] Cannot apply update.
[HMR] You need to restart the application!

Cause 1: A module in the update chain doesn’t have an accept handler:

The update propagates up the module dependency tree until it finds an accept handler. If no handler is found, HMR fails.

// Add an accept handler at the entry point to catch all updates
if (module.hot) {
  module.hot.accept();  // Accept updates for this module and its entire subtree
}

Cause 2: Runtime error during the update:

// HMR error boundary — handle errors during update application
if (module.hot) {
  module.hot.accept('./App', (err) => {
    if (err) {
      console.error('HMR update failed:', err);
      window.location.reload();  // Fall back to full reload on HMR error
    }
  });
}

Cause 3: Dynamic import chunk splitting confusing HMR:

// webpack.config.js — disable chunk splitting in development
module.exports = {
  optimization: {
    splitChunks: false,           // Disable in dev — can confuse HMR
    runtimeChunk: false,
  },
};

Fix 6: Fix Port Conflicts and Multiple Instances

Running two Webpack dev servers on the same port causes the second one to fail silently:

# Check if something is already using port 3000
lsof -i :3000          # macOS/Linux
netstat -ano | findstr :3000   # Windows

# Kill the conflicting process
kill $(lsof -t -i:3000)

# Or use a different port
webpack serve --port 3001

Configure port with conflict detection:

// webpack.config.js
module.exports = {
  devServer: {
    port: 'auto',    // Webpack 5: automatically find an available port
    // Or: port: 3000
  },
};

package.json scripts — avoid running multiple servers accidentally:

{
  "scripts": {
    "start": "webpack serve --mode development",
    "start:fresh": "fuser -k 3000/tcp; webpack serve --mode development"
  }
}

Fix 7: Debug HMR Connection Issues

When HMR seems to not connect, inspect the browser’s WebSocket connection:

// Check HMR connection status in browser console
// Webpack injects HMR runtime — inspect it:
console.log(module.hot);  // null if HMR not enabled, object if active

// Enable verbose HMR logging
// webpack.config.js
devServer: {
  client: {
    logging: 'verbose',   // 'none' | 'error' | 'warn' | 'info' | 'log' | 'verbose'
    overlay: {
      warnings: true,     // Show build warnings in browser overlay
      errors: true,       // Show build errors in browser overlay
    },
    progress: true,       // Show compilation progress in browser
  },
},

Check WebSocket connection in DevTools:

  1. Open Chrome DevTools → Network tab
  2. Filter by “WS” (WebSocket)
  3. You should see a WebSocket connection to ws://localhost:3000/ws
  4. Click it → Messages tab: look for {"type":"ok"} messages after each rebuild

No WebSocket connection visible — the client script isn’t loaded. Check that entry doesn’t override Webpack’s default HMR client injection:

// webpack.config.js
// WRONG — specifying entry without the HMR client
module.exports = {
  entry: './src/index.js',   // Webpack adds HMR client automatically
  // This is fine ↑

  // ALSO FINE for explicit entry:
  entry: [
    'webpack/hot/dev-server',  // Webpack HMR client
    './src/index.js',
  ],
};

Still Not Working?

Symlinked node_modules — if packages are symlinked (monorepo using npm link or Yarn workspaces), Webpack may not watch the symlink target. Set resolve.symlinks: false to follow symlinks, or add the symlinked path to watchOptions.ignored exclusions.

cache: true with stale cache — Webpack 5’s persistent cache can serve stale modules. Clear it: rm -rf node_modules/.cache then restart.

Browser extension blocking WebSocket — some ad blockers or security extensions block WebSocket connections. Test in an incognito window without extensions.

HTTPS dev server without trusted cert — if devServer.https: true but the self-signed cert isn’t trusted, the browser blocks the WebSocket. Either trust the cert or use HTTP for development.

historyApiFallback and route conflicts — enabling historyApiFallback: true is correct for SPA routing, but it must not redirect WebSocket connections. Webpack handles this automatically with proper configuration.

For related tooling issues, see Fix: Webpack Bundle Size Too Large and Fix: Webpack Alias Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles