🔧 Error Fixes
· 6 min read
Last updated on

Vite HMR Not Working — How to Fix Hot Reload


[vite] hmr update /src/App.tsx
// But changes don't appear in the browser

Vite’s Hot Module Replacement (HMR) should update your app instantly when you save a file. When it stops working, you’re stuck manually refreshing — defeating the purpose of using Vite in the first place.

Here’s every known cause and fix, from most common to edge cases.

How Vite HMR works

Before diving into fixes, it helps to understand the mechanism. Vite runs a WebSocket server alongside your dev server. When you save a file, Vite detects the change via the file system watcher, determines which modules are affected, and sends an update over WebSocket to the browser. The browser then swaps the module without a full page reload.

HMR breaks when any part of this chain fails: the file watcher doesn’t detect the change, the WebSocket connection is broken, or the framework’s HMR boundary can’t accept the update.

For a deeper dive, see How Hot Reload Actually Works.

Fix 1: Mixed exports break React Fast Refresh

React Fast Refresh (Vite’s HMR for React) has a strict rule: a file must only export React components. If you export constants, utility functions, or types alongside components, Fast Refresh falls back to a full reload — or worse, does nothing.

// ❌ Mixed exports — breaks Fast Refresh
export const API_URL = 'https://api.example.com';
export const MAX_RETRIES = 3;
export default function App() {
  return <div>Hello</div>;
}

// ✅ Separate concerns into different files
// src/config.ts
export const API_URL = 'https://api.example.com';
export const MAX_RETRIES = 3;

// src/App.tsx — only the component
import { API_URL } from './config';
export default function App() {
  return <div>Hello</div>;
}

How to tell this is the issue: Check your browser console. If you see [vite] page reload instead of [vite] hmr update, Fast Refresh is bailing out. Vite will often log a reason like “could not Fast Refresh (export is not a React component).”

Fix 2: File watcher limit reached (Linux)

On Linux, the kernel limits how many files a process can watch via inotify. Large projects with many node_modules files can exceed this limit, causing Vite to silently miss file changes.

# Check current limit
cat /proc/sys/fs/inotify/max_user_watches

# If it's 8192 or 65536, increase it
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
sudo sysctl -p

Symptoms: HMR works for a while, then stops. Or it works for some files but not others. Restarting the dev server temporarily fixes it.

This doesn’t apply to macOS (which uses FSEvents) or Windows (which uses ReadDirectoryChangesW).

Fix 3: Docker or WSL — use polling

When Vite runs inside Docker or WSL2 with files mounted from the host, the native file system events don’t propagate across the boundary. Vite never sees the file change.

// vite.config.ts
export default {
  server: {
    watch: {
      usePolling: true,
      interval: 100, // Check every 100ms
    },
  },
};

Trade-off: Polling uses more CPU than native watchers. Only enable it when you’re in Docker/WSL. For a better Docker setup, keep your source files inside the container rather than mounting from the host.

Fix 4: WebSocket connection blocked

HMR relies on a WebSocket connection between the browser and Vite’s dev server. If something blocks this connection (a proxy, firewall, or browser extension), updates never reach the browser.

// vite.config.ts — explicitly configure HMR connection
export default {
  server: {
    hmr: {
      host: 'localhost',
      port: 5173,
      protocol: 'ws',
    },
  },
};

Check the browser console for errors like:

  • WebSocket connection to 'ws://localhost:5173' failed
  • [vite] server connection lost

Common causes:

  • A reverse proxy (nginx, Caddy) not forwarding WebSocket upgrades
  • Browser extensions that block WebSocket connections
  • Corporate firewalls or VPNs
  • Running Vite on a different port than expected

If you’re behind a proxy, configure it to forward WebSocket:

# nginx config for Vite dev server
location / {
  proxy_pass http://localhost:5173;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
}

Fix 5: Stale cache

Vite caches pre-bundled dependencies in node_modules/.vite. Sometimes this cache becomes stale after installing new packages or switching branches.

# Delete Vite's cache and restart
rm -rf node_modules/.vite
npm run dev

If that doesn’t help, try a full clean:

rm -rf node_modules/.vite
rm -rf node_modules
npm install
npm run dev

Fix 6: Unsupported file types

Vite only processes files it knows about. If you’re editing a file with an unusual extension or one that isn’t imported in your module graph, HMR won’t trigger.

Supported out of the box: .ts, .tsx, .js, .jsx, .css, .scss, .less, .json, .svg

For other file types, you need a Vite plugin that handles them. Check that your plugin is correctly configured in vite.config.ts.

Fix 7: Circular imports

Circular dependencies can confuse HMR’s module graph traversal. When module A imports B and B imports A, Vite may not correctly determine the HMR boundary.

// ❌ Circular: utils.ts imports from App.tsx, App.tsx imports from utils.ts
// This can cause HMR to bail out

// ✅ Break the cycle by extracting shared code into a third file

Detect circular imports with npx madge --circular src/.

Fix 8: Vue/Svelte-specific issues

Vue: Make sure you’re using <script setup> or that your component has a name option. Anonymous components can’t be hot-replaced.

Svelte: SvelteKit’s HMR requires that components are default exports. Named exports of Svelte components break HMR.

Fix 9: Custom server middleware interfering

If you’ve added custom middleware to Vite’s dev server, it might intercept the HMR WebSocket upgrade request.

// ❌ Middleware that catches all requests
server.middlewares.use((req, res, next) => {
  // This might intercept the /__vite_hmr WebSocket
  res.end('intercepted');
});

// ✅ Skip Vite's internal paths
server.middlewares.use((req, res, next) => {
  if (req.url?.startsWith('/__vite') || req.url?.startsWith('/@')) {
    return next();
  }
  // Your logic here
});

Debugging checklist

  1. Open browser DevTools → Console. Look for [vite] messages.
  2. Check the Network tab → WS filter. Is the WebSocket connected?
  3. Save a file and watch the terminal. Does Vite log hmr update?
  4. If Vite logs the update but the browser doesn’t change → WebSocket issue
  5. If Vite doesn’t log anything → file watcher issue
  6. If Vite logs page reload instead of hmr update → HMR boundary issue (mixed exports, circular deps)

FAQ

Why does HMR work for CSS but not for my React components?

CSS HMR is simpler — Vite just replaces the stylesheet. React component HMR requires Fast Refresh, which has stricter rules. Check that your component file only exports React components and that you’re not using class components (Fast Refresh only works with function components and hooks).

Does Vite HMR preserve component state?

Yes, when Fast Refresh works correctly, local state (useState, useRef) is preserved across edits. If state resets on every save, Fast Refresh is falling back to a full remount — check for mixed exports or anonymous components.

HMR works locally but not in my CI/preview environment?

HMR is a development-only feature. In production builds (vite build), there’s no dev server or WebSocket. If you’re seeing this in a preview/staging environment, you’re likely running vite preview which serves the production build without HMR.

Can I disable HMR entirely?

// vite.config.ts
export default {
  server: {
    hmr: false, // Disables HMR, forces full page reload on changes
  },
};