Fixing Slow Vite HMR in Large Monorepos

When [vite] hmr update took 3800ms scrolls past in the terminal and the browser lags seconds behind your saves, the dev server has crossed from “fast” into unusable. In a healthy workspace, HMR round-trips stay under 200ms; the gap is almost always filesystem-watcher overhead and unbundled internal packages, not your application code. This is the workspace-specific case of the broader tuning covered in Optimizing Vite Dev Server and HMR, which you should read first for the invalidation model these fixes depend on.

Monorepo HMR latency sources and fixes Symlinked node_modules and unbundled workspace packages inflate HMR latency; ignoring node_modules, allowing the workspace root, and pre-bundling internal packages restore sub-200ms updates. Latency sources chokidar walks symlinked node_modules inotify limit hit, falls back to polling unbundled workspace pkgs re-resolved per change Fixes watch.ignored node_modules fs.allow workspace root optimizeDeps.include pkgs result: HMR back under 200ms
Figure: the three dominant monorepo HMR latency sources on the left, and the configuration levers that neutralize each on the right.

Prerequisites & Reproducible Setup

You need Vite 5.x or 6.x on Node 20+, a workspace managed by pnpm 8+/npm 9+/Yarn 4 with symlinked node_modules, and at least one internal package (for example @workspace/ui) imported by the app. To reproduce the slow path on Linux, temporarily lower the watcher ceiling and start the dev server without any watch.ignored:

# Reproduce the watcher exhaustion (Linux). Lower the cap, then start dev.
sudo sysctl fs.inotify.max_user_watches=8192
pnpm --filter @workspace/app dev

With the cap low and node_modules unignored, editing a file in @workspace/ui produces multi-second updates or an outright watcher error.

Diagnosis Workflow

Work top-down, cheapest check first. Each step either isolates the bottleneck or rules a class of causes out.

  1. Read the HMR duration directly. Run the dev server with HMR debug logging and save a file in a shared package:
    vite --debug hmr
    A [vite] hmr update /packages/ui/... line over ~200ms confirms the problem is invalidation/resolution, not your editor.
  2. Check the OS watcher ceiling. A telltale error is:
    chokidar: ENOSPC: System limit for number of file watchers reached
    
    Inspect and, if needed, raise it:
    sysctl fs.inotify.max_user_watches
    # If < 524288:
    echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
  3. Confirm chokidar is not traversing node_modules. Run DEBUG=vite:* vite and watch the vite:config output; if no watch.ignored is set, every symlinked dependency is being stat-ed.
  4. Verify internal packages are pre-bundled. Run vite --force and confirm Optimizing dependencies... lists your workspace packages and that imports resolve under node_modules/.vite/deps/ rather than to raw packages/*/src.
  5. Rule out duplicate framework instances. Duplicate react copies across packages force full-page reloads instead of HMR patches — check with pnpm why react.

The Fix

Apply this complete vite.config.ts. It caps the watcher scope, opens the workspace root to the file server, and pre-bundles internal packages so esbuild stops re-resolving them on every change.

// vite.config.ts — Vite 5.x / 6.x, Node 20+, pnpm/Yarn/npm workspace
import { defineConfig } from 'vite';
import path from 'node:path';

export default defineConfig({
  server: {
    // 1. Keep chokidar off symlinked deps, VCS metadata, and build output.
    //    This removes 80–90% of redundant fs.stat traffic in a workspace.
    watch: {
      ignored: ['**/node_modules/**', '**/.git/**', '**/dist/**'],
      usePolling: false,
    },
    // 2. server.fs.strict is true by default in Vite 5+. Allow the
    //    monorepo root so cross-package imports are not 403-blocked.
    fs: {
      strict: true,
      allow: [path.resolve(__dirname, '../../')],
    },
  },
  // 3. Pre-bundle internal packages once at startup. Without this, every
  //    HMR trigger re-runs esbuild resolution across their import graphs.
  optimizeDeps: {
    include: ['@workspace/ui', '@workspace/utils'],
  },
  // 4. Force a single instance of the framework across all packages so
  //    duplicate graphs do not escalate HMR into full reloads.
  resolve: {
    dedupe: ['react', 'react-dom'],
  },
});

watch.ignored is the highest-leverage line: it stops chokidar from registering thousands of symlinked files. fs.allow overrides server.fs.strict, which otherwise returns The request url is outside of Vite serving allow list for any sibling-package import. optimizeDeps.include caches transformed ESM for internal packages, and resolve.dedupe collapses duplicate framework copies that would otherwise break the HMR boundary.

Verification

After applying the config, restart and re-run the baseline:

vite --debug hmr

Save a file in @workspace/ui. Expected: a single [vite] hmr update /packages/ui/... line under ~200ms, no page reload, and no ENOSPC. Cross-check the resolution path — internal imports should now show node_modules/.vite/deps/@workspace_ui.js rather than the raw source. In DevTools → Network → WS → Frames, the update payload should be a small JSON frame, not a multi-hundred-kilobyte module dump.

Gotchas & Edge Cases

  • optimizeDeps.include masks in-package edits. A pre-bundled internal package will not hot-update its own source until you restart or vite --force. For packages you actively edit, prefer optimizeDeps.exclude and accept slightly slower startup.
  • Dynamic include lists drift. If the include list grows unwieldy, generate it: glob packages/*/package.json, read each name, and feed the array to optimizeDeps.include. Stale hand-maintained lists silently drop newly added packages.
  • Extreme scale still strains native watchers. Past ~100 packages, prefer splitting dev servers by domain over enabling watch.usePolling. Reserve polling (usePolling: true, interval: 1000) for CI or VM filesystems where native events are unreliable.
  • fs.allow set too wide is a footgun. Allowing / exposes the whole disk through the dev server. Scope it to the workspace root, not above it.