Fixing slow Vite HMR in large monorepos

When [vite] hmr update took 3800ms appears in the terminal and UI changes lag behind file saves, the dev server has crossed into unacceptable latency. In a healthy monorepo setup, HMR round-trips should consistently stay under 200ms. Diagnosing vite hmr slow monorepo behavior requires isolating filesystem watcher bottlenecks and dependency resolution overhead before adjusting configuration. Establishing this baseline is critical when operating within the broader Vite Configuration & Ecosystem, where single-package defaults frequently break under workspace scale.

Root Cause Analysis: File System Watcher Overhead

Monorepo package managers (pnpm, npm, Yarn) rely on symlinked node_modules to share dependencies. Vite’s underlying chokidar watcher traverses these symlinks by default, triggering thousands of redundant fs.stat calls across non-source directories. When vite server.watch ignored monorepo boundaries are left unconfigured, the OS rapidly exhausts available inotify handles.

This manifests as:

chokidar: ENOSPC: System limit for number of file watchers reached

Once the vite chokidar watcher limit is hit, chokidar silently falls back to recursive polling. Polling introduces high CPU overhead and destroys event-driven HMR latency. Compounding this, unpre-bundled workspace packages force esbuild to re-resolve internal dependency graphs on every file change. The combination of polling fallback and on-the-fly resolution creates cascading HMR delays and terminal freezes.

Reproducible Configuration Fixes

Apply these exact settings to your vite.config.ts to cap watcher scope and stabilize the dependency graph:

import { defineConfig } from 'vite';
import path from 'path';

export default defineConfig({
 server: {
 // Strictly exclude non-source directories from the watcher tree
 watch: { ignored: ['**/node_modules/**', '**/.git/**', '**/dist/**'] },
 // Break strict filesystem sandboxing to allow cross-package imports
 fs: { allow: [path.resolve(__dirname, '../')] }
 },
 // Pre-bundle internal workspace packages to bypass runtime esbuild resolution
 optimizeDeps: { include: ['@workspace/ui', '@workspace/utils'] }
});

Configuration breakdown:

  • server.watch.ignored: Uses glob patterns to prevent chokidar from registering node_modules, VCS metadata, and build artifacts. This eliminates 80-90% of unnecessary fs.stat calls.
  • server.fs.allow: Overrides server.fs.strict defaults. Without this, you will encounter server.fs.strict is blocking symlink resolution for workspace package when importing across package boundaries.
  • optimizeDeps.include: Forces Vite to pre-bundle specified workspace packages during server startup. This caches transformed ESM output and prevents esbuild from re-scanning internal dependencies on every HMR trigger.

For deeper architectural context on watcher tuning and pre-bundling strategies, refer to Optimizing Vite Dev Server and HMR.

Step-by-Step Troubleshooting Workflow

Execute this diagnostic sequence to verify watcher registration and isolate latency sources:

  1. Audit OS watcher limits (Linux/macOS):
sysctl fs.inotify.max_user_watches
# If < 524288, increase it:
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
  1. Run Vite with debug logging:
VITE_DEBUG=1 vite --debug

Watch for vite:config and vite:deps logs. Confirm that server.watch registers only source directories and that optimizeDeps completes before the server starts.

  1. Validate pre-bundling cache:
vite optimize --force

This clears stale caches and forces a fresh graph scan. Expected output should show Optimizing dependencies... followed by Dependencies pre-bundled successfully.

  1. Isolate slow HMR paths: Temporarily add problematic directories to server.watch.ignored. If HMR latency drops below 200ms, the excluded path contains excessive files or broken symlinks triggering watcher thrashing.

  2. Verify resolution: Check terminal output for vite:resolve logs. Ensure internal workspace packages resolve to pre-bundled cache paths (node_modules/.vite/deps/) rather than raw source files.

Advanced Monorepo-Specific Optimizations

When baseline fixes stabilize the watcher but latency persists, address dependency graph conflicts:

  • resolve.dedupe tuning: Frameworks like React or Vue must resolve to a single instance across packages. Add resolve: { dedupe: ['react', 'react-dom'] } to prevent duplicate module graphs from triggering full-page reloads instead of HMR patches.
  • esbuild external vs bundle: For large internal libraries that change infrequently, mark them as build.rollupOptions.external in production, but keep them in optimizeDeps.include for dev. This balances fast startup with accurate HMR boundaries.
  • Workspace-aware pre-bundling: If optimizeDeps.include grows unwieldy, use dynamic discovery:
import { globSync } from 'glob';
const workspacePkgs = globSync('packages/*/package.json').map(p => JSON.parse(fs.readFileSync(p, 'utf8')).name);
// Pass workspacePkgs to optimizeDeps.include
  • Extreme-scale fallback: For repos exceeding 100+ packages, native watchers may still struggle. As a last resort, enable server.watch.usePolling: true with server.watch.interval: 1000 only for specific CI/VM environments. Prefer splitting dev servers by package domain to isolate HMR scope.