Fix: Webpack HMR (Hot Module Replacement) Not Working
Part of: React & Frontend Errors
Quick Answer
How to fix Webpack Hot Module Replacement not updating the browser — HMR connection lost, full page reloads instead of hot updates, and HMR breaking in Docker or behind a proxy.
The Dev Loop That Broke
I have a confession: I spent the better part of 2024 trying to keep webpack HMR working on a legacy project before I gave up and migrated the codebase to Vite over a weekend. HMR is webpack’s most fragile feature — when it works, it is invisible; when it breaks, the failure modes are creative. You run webpack dev server but the browser does not update when you save files. Instead you see:
[HMR] Waiting for update signal from WDS...
[HMR] Update failed: Cannot find update. Need to do a full reload!Or in the terminal:
ERROR in ./src/App.js
Module not found: Error: Can't resolve './NewComponent'Or the browser console shows:
[webpack-dev-server] Disconnected!
[webpack-dev-server] Trying to reconnect...Or changes trigger a full page reload instead of a hot update — losing component state.
How HMR Actually Works Under the Hood
HMR works by maintaining a WebSocket connection between the browser and webpack dev server. When a file changes, webpack sends the updated module over the WebSocket and the browser swaps it in without reloading. It fails when:
- WebSocket connection cannot be established — wrong host/port, proxy blocking WebSocket upgrades, or Docker networking issues.
- The module graph does not support HMR — some module types require a full reload.
- React Fast Refresh not configured — HMR works but React components do not preserve state without it.
output.publicPathmismatch — webpack looks for update files at the wrong URL.- Running behind a reverse proxy — the proxy does not forward WebSocket connections.
- Hot update files return 404 — the dev server is not serving from the expected path.
Platform and Environment Differences
Webpack HMR depends on three layers — file watching, WebSocket connectivity, and the dev-server middleware — and each layer has platform-specific failure modes.
Webpack 4 vs Webpack 5. Webpack 4 requires new webpack.HotModuleReplacementPlugin() in plugins plus hot: true in devServer. Webpack 5 enables the plugin automatically when hot: true is set; declaring it manually causes a warning and sometimes duplicate replacement passes. Webpack 5 also moved to webpack-dev-server v4, which restructured devServer.client options. Configs copied from Webpack 4 tutorials commonly fail with Invalid options object after a Webpack 5 upgrade.
webpack-dev-server vs webpack-dev-middleware. webpack-dev-server is the standalone dev server that wraps Express, file watching, and the HMR transport. webpack-dev-middleware is just the middleware piece you plug into your own Express, Fastify, or Koa server. Custom-server setups (e.g., a Node API that also serves the SPA in dev) need webpack-dev-middleware plus webpack-hot-middleware for HMR. The two ecosystems use different options: devServer.hot does nothing in a webpack-dev-middleware setup, and the HMR client URL is configured per the hot-middleware docs, not per the dev-server docs.
File watching per OS. On Linux, watchers use inotify. The kernel limit fs.inotify.max_user_watches defaults to 8,192 on many distributions; large repos (especially with node_modules) exhaust this silently and HMR stops detecting saves. Raise it with sudo sysctl fs.inotify.max_user_watches=524288. On macOS, FSEvents watches whole directory trees and rarely needs tuning. On Windows, native file change notifications work for local files but are unreliable inside WSL2 cross-mounts and inside Docker bind mounts. The cross-platform fallback is polling.
Docker bind mounts and chokidar polling. Docker Desktop on macOS and Windows mounts the host filesystem into the VM via a shim that does not propagate inotify events. Inside the container, webpack sees the file content change on next read but no event fires. Set CHOKIDAR_USEPOLLING=true and WATCHPACK_POLLING=true (Webpack 5 uses watchpack, not chokidar) so the watcher polls the filesystem. Poll intervals around 1000 ms are a good balance between CPU usage and reload latency.
WSL2. When source lives on a Windows drive (/mnt/c/...) and webpack runs inside WSL2, file events are not delivered. The standard fix is to move the project into the WSL2 filesystem (~/projects/...), which gives full native inotify. Running webpack on Windows side while editing in WSL has the same issue in reverse.
Behind nginx, Caddy, or a corporate proxy. HMR uses a WebSocket upgrade (Upgrade: websocket, Connection: upgrade). nginx ignores these headers unless explicitly proxied. Caddy upgrades automatically. Corporate proxies sometimes strip the Upgrade header at the network edge — the WebSocket hangs at “Pending” in DevTools Network. Set devServer.client.webSocketURL to the public-facing URL (including wss:// if the proxy adds TLS) so the browser connects through the proxy rather than directly.
HTTPS dev servers. When the page is served over HTTPS, browsers refuse to open ws:// WebSockets due to mixed-content rules. Either run the dev server over https:// itself (devServer.server: "https") or use a wss:// reverse proxy. Self-signed certificates trigger a “your connection is not private” warning that also kills the WebSocket until you accept the cert in a regular tab first.
Fix 1: Configure webpack-dev-server host and client Settings
The most common issue in Docker or non-localhost environments: the HMR client in the browser tries to connect to the wrong WebSocket URL.
In webpack.config.js:
module.exports = {
devServer: {
host: "0.0.0.0", // Listen on all interfaces (required for Docker)
port: 3000,
hot: true, // Enable HMR
liveReload: false, // Disable full reload fallback (optional)
client: {
webSocketURL: "ws://localhost:3000/ws", // Explicit WebSocket URL
// Or use 'auto' to detect automatically:
// webSocketURL: "auto",
},
allowedHosts: "all", // Allow connections from any host
},
};For webpack-dev-server v4+ (webpack 5):
module.exports = {
devServer: {
hot: true,
client: {
webSocketURL: {
hostname: "localhost",
pathname: "/ws",
port: 3000,
protocol: "ws",
},
},
},
};When I containerize a webpack dev server, the very first config change I make is host: "0.0.0.0". Bind to localhost inside a container and the dev server is unreachable from the host — the symptom looks identical to HMR being broken, which sends people down the wrong debugging path. I have lost an embarrassing amount of time to this one before I learned to set it as a default.
Fix 2: Fix HMR Behind a Reverse Proxy (nginx)
If nginx proxies requests to the webpack dev server, it must upgrade WebSocket connections:
server {
listen 80;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
# Required for WebSocket (HMR)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}Without Upgrade and Connection: upgrade headers, nginx handles WebSocket connections as regular HTTP and the HMR handshake fails.
Check if the WebSocket connection is being blocked:
Open Chrome DevTools → Network → WS tab. You should see a WebSocket connection to the dev server. If it is failing, the status shows as failed or the connection closes immediately.
Fix 3: Enable React Fast Refresh
Standard HMR for React requires full component remounts — state is lost on every update. React Fast Refresh preserves state:
For Create React App — enabled by default.
For custom webpack setup:
npm install -D @pmmmwh/react-refresh-webpack-plugin react-refresh// webpack.config.js
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
const isDevelopment = process.env.NODE_ENV !== "production";
module.exports = {
mode: isDevelopment ? "development" : "production",
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: [
{
loader: "babel-loader",
options: {
plugins: [
isDevelopment && require.resolve("react-refresh/babel"),
].filter(Boolean),
},
},
],
},
],
},
plugins: [
isDevelopment && new ReactRefreshWebpackPlugin(),
].filter(Boolean),
devServer: {
hot: true,
},
};Important: Only enable react-refresh/babel in development. Including it in production builds causes errors.
Fix 4: Fix HMR for CSS Modules and Style Files
CSS HMR typically works out of the box with style-loader, which injects styles into the DOM and supports hot updates. If CSS changes trigger full reloads, check your loader configuration:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
"style-loader", // Injects CSS into DOM — supports HMR
"css-loader",
],
},
{
test: /\.scss$/,
use: [
"style-loader",
"css-loader",
"sass-loader",
],
},
],
},
};mini-css-extract-plugin disables CSS HMR. This plugin extracts CSS into separate files (for production). In development, use style-loader instead:
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const isDevelopment = process.env.NODE_ENV !== "production";
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
// Use style-loader in dev, MiniCssExtractPlugin.loader in prod
isDevelopment ? "style-loader" : MiniCssExtractPlugin.loader,
"css-loader",
],
},
],
},
plugins: [
!isDevelopment && new MiniCssExtractPlugin(),
].filter(Boolean),
};Fix 5: Fix HMR in Docker with Polling
Docker on macOS and Windows does not propagate filesystem events from the host to the container by default. HMR watches for file changes using inotify (Linux) or FSEvents (macOS) — these do not work across Docker volume mounts.
Fix — use polling instead of filesystem events:
// webpack.config.js
module.exports = {
devServer: {
hot: true,
watchFiles: {
options: {
poll: 1000, // Poll every 1 second
usePolling: true, // Force polling instead of filesystem events
},
},
},
// For webpack 4:
watchOptions: {
poll: 1000,
aggregateTimeout: 300,
},
};Or set via environment variable in docker-compose.yml:
services:
frontend:
image: node:20
volumes:
- .:/app
working_dir: /app
command: npm start
environment:
- CHOKIDAR_USEPOLLING=true # For CRA / chokidar
- WATCHPACK_POLLING=true # For webpack 5
ports:
- "3000:3000"Polling is less efficient than native file watching but works reliably across all platforms and Docker setups.
Fix 6: Fix output.publicPath for HMR
Webpack HMR fetches update manifests and chunks from output.publicPath. If this does not match the actual URL, hot updates return 404:
// Broken — publicPath doesn't match dev server URL
module.exports = {
output: {
publicPath: "/static/", // HMR looks for updates at /static/
},
devServer: {
port: 3000,
// HMR update files are served from http://localhost:3000/
// but webpack looks for them at http://localhost:3000/static/
},
};Fixed — consistent publicPath:
module.exports = {
output: {
publicPath: "/", // Or match your dev server's static file path
},
devServer: {
port: 3000,
static: {
publicPath: "/",
},
},
};Use "auto" to let webpack determine publicPath automatically:
module.exports = {
output: {
publicPath: "auto",
},
};"auto" works well for most setups and avoids manual path configuration.
Fix 7: Fix Module-Level HMR Acceptance
For non-React code, HMR requires you to explicitly accept updates in the module:
// Without this, changes to this module cause a full page reload
if (module.hot) {
module.hot.accept("./myModule", () => {
// Re-run the module or update your app
const newModule = require("./myModule");
updateApp(newModule);
});
}React Fast Refresh and Vue’s HMR handle this automatically for components. For vanilla JS, Svelte, or other frameworks, check the framework’s HMR documentation.
For the entry point (accept all updates):
// src/index.js
import App from "./App";
render(App);
if (module.hot) {
module.hot.accept("./App", () => {
const NextApp = require("./App").default;
render(NextApp);
});
}Debug HMR Issues
Check the browser console for HMR messages:
[HMR] connected ← WebSocket established
[HMR] Updated modules: ← File change detected
[HMR] App is up to date. ← Successful hot update
[HMR] Update failed ← Hot update failed, full reload triggeredEnable verbose HMR logging:
// webpack.config.js
module.exports = {
devServer: {
client: {
logging: "verbose", // Show all HMR log messages
},
},
};Check the webpack dev server output for errors after a file change:
npm run dev
# Watch for: "Error: Cannot find module" or compilation errors
# These cause HMR to fall back to full reloadA compilation error after a file change prevents HMR from applying the update — fix the error first.
Failure Modes I Have Hit That No One Documents
Check webpack version compatibility. webpack-dev-server v4 requires webpack 5. Using v4 with webpack 4 causes subtle issues. Run npm ls webpack webpack-dev-server to verify versions match.
Check for conflicting HMR setups. If both hot: true in devServer and new webpack.HotModuleReplacementPlugin() in plugins are set (webpack 4 style), you get duplicate HMR setup. In webpack 5, hot: true is sufficient — remove the plugin:
// webpack 5 — remove this:
// new webpack.HotModuleReplacementPlugin()
// Just use:
devServer: { hot: true }Check for writeToDisk option. If devServer.devMiddleware.writeToDisk: true is set, webpack writes files to disk. This can cause HMR to pick up stale files. Disable writeToDisk in development.
Check the inotify watch limit on Linux. Run cat /proc/sys/fs/inotify/max_user_watches. If the value is 8,192 or lower and your project is large, watchers silently stop firing partway through development. Raise it persistently in /etc/sysctl.conf with fs.inotify.max_user_watches = 524288 and reload with sudo sysctl -p.
Check that ad blockers or privacy extensions are not blocking the WebSocket. Some content blockers treat the dev-server WebSocket path (/ws, /sockjs-node) as suspicious and block it. Test in a clean incognito window or temporarily disable extensions.
Check for stale node_modules/.cache. Webpack’s persistent cache (cache: { type: "filesystem" }) sometimes serves a stale module graph after large refactors, producing “Update failed: Cannot find update” errors on every save. Delete node_modules/.cache and restart the dev server. If the issue recurs, set cache.buildDependencies to invalidate when config files change.
For module resolution errors that appear after an HMR update fails, see Fix: webpack Module Not Found. For general webpack build errors, see Fix: webpack Module Parse Failed: Unexpected token. For HMR-style problems specific to Vite’s dev server, see Fix: Vite HMR connection lost. When HMR runs through nginx and the WebSocket never connects, see Fix: Nginx WebSocket proxy not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: React.lazy and Suspense Errors (Element Type Invalid, Loading Chunk Failed)
How to fix React.lazy and Suspense errors — Element type is invalid, A React component suspended while rendering, Loading chunk failed, and lazy import mistakes with named vs default exports.
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: Blurhash Not Working — Placeholder Not Rendering, Encoding Failing, or Colors Wrong
How to fix Blurhash image placeholder issues — encoding with Sharp, decoding in React, canvas rendering, Next.js image placeholders, CSS blur fallback, and performance optimization.
Fix: Docusaurus Not Working — Build Failing, Sidebar Not Showing, or Plugin Errors
How to fix Docusaurus issues — docs and blog configuration, sidebar generation, custom theme components, plugin setup, MDX compatibility, search integration, and deployment.