Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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.

While HMR breakage is technically a development-time problem, it has real production consequences. When developers can’t see their changes reflected in the browser, they resort to hard refreshes, restart the dev server repeatedly, and lose minutes per change cycle. In staging environments where deploy previews rely on a running dev server (e.g., Gitpod, Codespaces, or Docker-based preview environments), a broken HMR pipeline means reviewers see stale code. The cumulative cost across a team is significant: a 30-second HMR lag multiplied by hundreds of saves per day across multiple developers adds up to hours of wasted time per sprint.

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, go to Network tab.
  2. Filter by “WS” (WebSocket).
  3. You should see a WebSocket connection to ws://localhost:3000/ws.
  4. Click it, then go to 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',
  ],
};

Fix 8: Fix HMR in Staging and Preview Environments

Deploy preview environments (Vercel previews, Netlify deploy previews, Gitpod, GitHub Codespaces) run Webpack dev server on a remote host with a different URL than localhost. HMR fails because the WebSocket URL defaults to localhost, which doesn’t resolve to the preview server.

Gitpod / Codespaces — configure WebSocket URL dynamically:

// webpack.config.js
const isRemote = process.env.GITPOD_WORKSPACE_URL || process.env.CODESPACE_NAME;

module.exports = {
  devServer: {
    hot: true,
    host: '0.0.0.0',
    allowedHosts: 'all',
    client: {
      webSocketURL: isRemote
        ? {
            hostname: '0.0.0.0',
            port: 443,
            protocol: 'wss',
          }
        : 'auto',
    },
  },
};

Docker-based preview environments:

When a Docker container runs webpack serve and exposes the port, the WebSocket URL must use the Docker host’s address, not the container’s internal address.

// webpack.config.js — Docker preview
module.exports = {
  devServer: {
    hot: true,
    host: '0.0.0.0',
    port: 3000,
    client: {
      webSocketURL: {
        hostname: process.env.PREVIEW_HOST || 'localhost',
        port: process.env.PREVIEW_PORT || 3000,
        protocol: process.env.PREVIEW_PROTOCOL || 'ws',
      },
    },
  },
};

Why this matters for code review: If HMR is broken in preview environments, reviewers see stale code. They approve a PR based on old output. The deploy goes live with changes the reviewer never actually saw rendered. This is a process failure disguised as a tooling issue.

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.

File save not triggering compilation — some editors (notably Vim with backupcopy=no or JetBrains IDEs with “safe write”) write changes to a temporary file then rename it. The rename event may not trigger the file watcher. In JetBrains, disable “Use safe write” under Settings, then Appearance and Behavior, then System Settings. In Vim, set backupcopy=yes.

Antivirus software blocking file events — Windows Defender and other antivirus tools sometimes intercept file writes and delay or suppress filesystem events. Exclude your project directory from real-time scanning, or switch to polling mode as described in Fix 2.

watchOptions.ignored is too broad — if your ignored pattern matches source files (e.g., ignored: '**/*' or a regex that accidentally catches src/), no changes are detected. Test by temporarily removing the ignored option entirely.

For related tooling issues, see Fix: Webpack Bundle Size Too Large, Fix: Webpack Alias Not Working, Fix: Vite HMR Connection Lost, and Fix: Webpack Module Not Found.

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