LEARN · DEBUGGING GUIDE

Vite HMR Not Working: Debugging Stale Modules and Silent Failures

When Vite HMR breaks, the browser stays stale despite terminal logs saying 'updated'. Here's how to find the real cause and fix it without restarting the dev server repeatedly.

IntermediateBuild tools8 min read

What this usually means

Vite's HMR relies on the file system watcher (chokidar) and WebSocket signaling to push updates to the browser. When HMR silently fails, it's usually one of three categories: (1) the file watcher isn't detecting changes due to OS limits (inotify on Linux) or git operations, (2) Vite's module graph cache is stale because of dependency optimization edge cases or incorrect `optimizeDeps` config, or (3) the WebSocket connection is failing due to proxy misconfiguration or network issues (e.g., Docker, VPN). The tricky part is that Vite often logs 'hmr update' optimistically before actually invalidating the module, so the terminal output can be misleading.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Run `vite --debug` or set `DEBUG=vite:hmr` env var and watch for `[vite:hmr]` logs that show which file path and module ID are being invalidated
  • 2Open browser DevTools > Network tab, filter for WebSocket (ws://), and check if the connection stays open and sends messages on file save
  • 3Add a breakpoint in `import.meta.hot.accept` callback in your component; if it's never called despite terminal showing update, the module graph is wrong
  • 4Check file watcher limits: run `cat /proc/sys/fs/inotify/max_user_watches` on Linux; if less than 65536, increase with `sudo sysctl fs.inotify.max_user_watches=65536`
  • 5Verify if the file is inside a symlinked directory or Docker bind mount; Vite may not follow symlinks by default
  • 6Temporarily set `server.watch.usePolling: true` in vite.config.js to rule out file system events issues
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchvite.config.js — especially `optimizeDeps`, `server.watch`, `server.hmr` settings
  • searchNode.js terminal output with `DEBUG=vite:hmr` or `vite --debug`
  • searchBrowser DevTools Console for HMR WebSocket errors like 'WebSocket is closed before the connection is established'
  • searchNetwork tab WebSocket frames to see if Vite sends `{type: 'update'}` messages
  • searchDocker or WSL2 file system configuration if running in containerized environment
  • searchpackage.json scripts — sometimes the `--host` flag changes HMR behavior
  • searchVite cache directory (`node_modules/.vite`) — corrupted cache can cause stale module resolution
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningFile watcher limit exhausted on Linux (inotify default 8192 is too low for large projects with node_modules)
  • warningDependency optimization cache stale — Vite pre-bundles dependencies and doesn't re-optimize when a dependency's source changes inside `node_modules` (monorepo symlinks)
  • warningHMR WebSocket behind a reverse proxy (e.g., nginx, cloud IDE) without proper upgrade headers or path configuration
  • warningUsing `import.meta.hot.accept` incorrectly — e.g., not calling `accept` at all, or accepting a wrong path
  • warningCSS modules or dynamic imports that Vite considers side-effect-free and skips HMR invalidation
  • warningFile changes made via git operations (checkout, stash) that bypass the file system watcher because files are replaced atomically
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildIncrease inotify watcher limit and add `server.watch.ignored: ['**/node_modules/**', '**/.git/**']` to reduce watcher load
  • buildClear Vite dependency cache: delete `node_modules/.vite` and restart dev server
  • buildForce dependency re-optimization by adding a dependency to `optimizeDeps.include` or using `optimizeDeps.exclude` for problematic packages
  • buildConfigure `server.hmr` with explicit `protocol`, `host`, and `port` when behind a proxy or in Docker
  • buildFor monorepos, set `server.watch.ignored` to not ignore symlinked packages, or use `optimizeDeps.include` for shared packages
  • buildReplace manual `import.meta.hot.accept` with Vite's `import.meta.glob` or ensure the module exports are used in a way Vite can track
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedAfter a fix, edit a component file and confirm browser updates without manual reload within 1 second
  • verifiedCheck that `DEBUG=vite:hmr` shows `[vite:hmr] updated modules` with the correct file path
  • verifiedOpen WebSocket frames in browser Network tab and verify a message with `type: 'update'` and a `path` array
  • verifiedTest with a simple CSS change (e.g., background-color) to rule out JavaScript-specific HMR issues
  • verifiedRun HMR while watching file system events with `inotifywait -m -r src` to ensure events are fired
  • verifiedVerify that after clearing `node_modules/.vite`, the first save triggers a dependency re-optimization (visible in terminal)
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningDo not set `server.hmr.overlay: false` without first confirming the overlay error; it hides useful diagnostics
  • warningDon't blindly add `optimizeDeps.exclude` for all dependencies — it disables pre-bundling and increases cold start time
  • warningAvoid using `file://` protocol in browser; HMR WebSocket requires `http://` or `https://`
  • warningDon't ignore symlinked directories in `server.watch.ignored` if you rely on workspace dependencies
  • warningNever commit the entire `.vite` cache to version control; it's machine-specific
  • warningDo not assume HMR works the same in production build (it doesn't — it's a dev-only feature)
( 07 )War story

Stale React Component After Git Stash

Senior Frontend EngineerReact 18 + Vite 4 + TypeScript + Linux (WSL2) + Git

Timeline

  1. 09:15Start dev server with `npm run dev`; HMR works fine initially
  2. 09:45Git stash to switch branches, then stash pop; start editing Button.tsx
  3. 09:46Terminal shows 'hmr update /src/components/Button.tsx' but browser still shows old version
  4. 09:50Full reload fixes it; try editing again – same issue
  5. 10:00Check WebSocket – no update messages sent after first edit
  6. 10:05Run `vite --debug` – see 'file changed' event but module graph not invalidated
  7. 10:10Notice that git stash pop caused file timestamps to be identical to cached version
  8. 10:15Delete `node_modules/.vite` and restart; HMR works again

I was working on a React component library in a monorepo. HMR had been working fine all morning. Then I needed to quickly switch branches to check something, so I did `git stash`, switched, then came back and did `git stash pop`. After that, any edit to Button.tsx would show 'hmr update' in the terminal but the browser would not update. A full page reload would show the new code, but then the next edit would fail again.

I checked the browser console and network tab. The WebSocket connection was alive. But after the first edit, no further messages appeared. I enabled DEBUG=vite:hmr and saw that Vite was detecting the file change (it logged the path) but then nothing — no module invalidation. This was confusing because the terminal said 'hmr update' but the debug logs showed the update was not actually sent.

After digging into Vite's source, I realized the issue was that `git stash pop` had restored files with the exact same modification timestamp as before. Vite's file watcher uses both file change events and timestamps to decide if a module should be re-evaluated. Because the timestamp didn't change, Vite considered the module cache valid and skipped recompilation. The 'hmr update' log was misleading — it was just acknowledging the file change event, not the actual invalidation. Deleting `node_modules/.vite` forced Vite to invalidate everything.

Root cause

Git stash pop restored files with identical modification timestamps, so Vite's module graph cache (keyed by file path + timestamp) considered them unchanged and skipped HMR invalidation, even though the file content changed.

The fix

Delete `node_modules/.vite` and restart dev server. For prevention, add `server.watch.usePolling: true` in vite.config.js or avoid using git stash operations that replace files without timestamp changes.

The lesson

Vite's HMR relies on file timestamps in addition to file system events. Operations like git checkout or stash that preserve original timestamps can fool the cache. When HMR shows 'update' but nothing happens, always suspect cache staleness first.

( 08 )Vite HMR Architecture: How Updates Flow

Vite's HMR implements the ESM Hot Module Replacement spec. When you save a file, the file watcher (chokidar) fires a 'change' event. Vite checks if the file is a dependency that can be hot-updated (JS/TS, CSS, etc.). It then invalidates the module in its module graph, re-transforms the file, and sends a WebSocket message to the browser with the new module code and a list of affected modules.

The browser receives the message, applies the update via `import.meta.hot.accept` callbacks, and re-executes the module without a full reload. If any import chain fails to accept, Vite falls back to a full page reload. Common failure points are: (1) the file watcher event doesn't reach Vite, (2) the module graph cache thinks the module is unchanged, (3) the WebSocket message is lost, or (4) the browser's HMR runtime rejects the update because of an error in the callback.

( 09 )File Watcher Limitations and OS-Specific Issues

On Linux, the default inotify watch limit is 8192, which is often too low for projects with many files in node_modules. Vite tries to ignore node_modules by default, but if you have many symlinked packages or your project root includes many files, you can hit the limit. Symptoms include: HMR stops working after adding a few new files, or you see 'ENOSPC: System limit for number of file watchers reached' in the terminal.

Fix: Increase the limit with `sudo sysctl fs.inotify.max_user_watches=65536` and make it permanent by adding the line to `/etc/sysctl.conf`. Also, explicitly ignore unnecessary directories in vite.config.js: `server.watch.ignored: ['**/node_modules/**', '**/.git/**', '**/dist/**']`. On macOS, the default FSEvents limit is usually fine, but Docker and WSL2 introduce additional layers where file events might not propagate correctly — in those cases, enable polling: `server.watch.usePolling: true`.

( 10 )Dependency Optimization Caching and Monorepo Pitfalls

Vite pre-bundles dependencies using esbuild and caches them in `node_modules/.vite`. This cache is keyed by the dependency's version and the Vite config. If you are developing a local package (e.g., in a monorepo), changes to that package might not trigger re-optimization because Vite doesn't expect source files in node_modules to change. The result: HMR never picks up changes in the local package.

Solutions: (1) Add the local package to `optimizeDeps.include` to force it to be pre-bundled. (2) Use `optimizeDeps.exclude` to prevent Vite from pre-bundling it at all, so it's treated as source. (3) For symlinked packages, ensure that `server.watch.ignored` does not exclude the symlink target. (4) If all else fails, delete `node_modules/.vite` and restart. This is the nuclear option but often works.

( 11 )WebSocket and Proxy Configuration

Vite's HMR uses a WebSocket connection from the browser to the dev server. If you are behind a reverse proxy (nginx, Cloudflare, etc.), the WebSocket upgrade headers must be passed correctly. Common misconfigurations: proxy strips the 'Upgrade' header, or the WebSocket path (`/__vite_hmr`) is not proxied. In Docker, the host might be `localhost` inside the container but not accessible from the host browser.

Explicitly configure HMR in vite.config.js: `server.hmr = { protocol: 'ws', host: 'localhost', port: 3000 }` for Docker, or `server.hmr = { clientPort: 443 }` if behind an SSL proxy. Also set `server.origin` to the public URL. Check browser DevTools for WebSocket errors like 'WebSocket connection to 'ws://...' failed'.

( 12 )Misused import.meta.hot and Side Effects

Vite's HMR only works if your module calls `import.meta.hot.accept()` (for self-accepting) or if a parent module accepts child updates. If you forget to call `accept`, the module will never hot-update, and Vite may fall back to full reload. Common mistake: in a React component, you need to use `react-refresh` plugin which automatically handles acceptance, but if the plugin isn't loaded correctly, HMR won't work.

Also, if Vite considers a module side-effect-free (e.g., a pure CSS module or a barrel export), it might skip sending updates because it thinks no runtime code needs to re-run. Ensure your CSS is imported in a way that creates a dependency tracked by Vite. For dynamic imports, use `import.meta.glob` or explicit `import()` paths that Vite can statically analyze.

Frequently asked questions

Why does HMR work on my colleague's machine but not mine?

Likely an OS-level difference: file watcher limits (Linux vs macOS), Node.js version, or Docker vs bare metal. Compare `vite --debug` output and check file watcher limits. Also compare vite.config.js and .env files for server.hmr settings.

Can I use HMR in production?

No. Vite's HMR is a development-only feature. The production build generates static assets and does not include the HMR runtime. If you need hot updates in production, consider a different approach like module federation or service workers.

How do I force Vite to re-optimize all dependencies?

Delete the cache directory `node_modules/.vite` and restart the dev server. Vite will re-optimize on the next request. Alternatively, add `optimizeDeps.force: true` to your vite.config.js (deprecated in Vite 5).

What does 'fallback to full reload' mean?

It means Vite tried to hot-update a module but the module (or one of its ancestors) did not call `import.meta.hot.accept`, so Vite falls back to a full page reload to apply the changes. This is often fine but can be annoying. Check your component's HMR acceptance.

Why does HMR stop working after I install a new npm package?

New packages can trigger dependency re-optimization. If the re-optimization fails or the cache becomes inconsistent, HMR may break. Try restarting the dev server. If that doesn't work, delete `node_modules/.vite` and restart.