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 registeringnode_modules, VCS metadata, and build artifacts. This eliminates 80-90% of unnecessaryfs.statcalls.server.fs.allow: Overridesserver.fs.strictdefaults. Without this, you will encounterserver.fs.strict is blocking symlink resolution for workspace packagewhen 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:
- 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
- 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.
- 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.
-
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. -
Verify resolution: Check terminal output for
vite:resolvelogs. 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.dedupetuning: Frameworks like React or Vue must resolve to a single instance across packages. Addresolve: { dedupe: ['react', 'react-dom'] }to prevent duplicate module graphs from triggering full-page reloads instead of HMR patches.- esbuild
externalvsbundle: For large internal libraries that change infrequently, mark them asbuild.rollupOptions.externalin production, but keep them inoptimizeDeps.includefor dev. This balances fast startup with accurate HMR boundaries. - Workspace-aware pre-bundling: If
optimizeDeps.includegrows 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: truewithserver.watch.interval: 1000only for specific CI/VM environments. Prefer splitting dev servers by package domain to isolate HMR scope.