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.
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.
- Read the HMR duration directly. Run the dev server with HMR debug logging and save a file in a shared package:
Avite --debug hmr[vite] hmr update /packages/ui/...line over ~200ms confirms the problem is invalidation/resolution, not your editor. - Check the OS watcher ceiling. A telltale error is:
Inspect and, if needed, raise it:chokidar: ENOSPC: System limit for number of file watchers reachedsysctl fs.inotify.max_user_watches # If < 524288: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p - Confirm chokidar is not traversing
node_modules. RunDEBUG=vite:* viteand watch thevite:configoutput; if nowatch.ignoredis set, every symlinked dependency is being stat-ed. - Verify internal packages are pre-bundled. Run
vite --forceand confirmOptimizing dependencies...lists your workspace packages and that imports resolve undernode_modules/.vite/deps/rather than to rawpackages/*/src. - Rule out duplicate framework instances. Duplicate
reactcopies across packages force full-page reloads instead of HMR patches — check withpnpm 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.includemasks in-package edits. A pre-bundled internal package will not hot-update its own source until you restart orvite --force. For packages you actively edit, preferoptimizeDeps.excludeand accept slightly slower startup.- Dynamic include lists drift. If the include list grows unwieldy, generate it: glob
packages/*/package.json, read eachname, and feed the array tooptimizeDeps.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.allowset too wide is a footgun. Allowing/exposes the whole disk through the dev server. Scope it to the workspace root, not above it.
Related
- Optimizing Vite Dev Server and HMR — the parent guide on warmup, pre-bundling, and accept boundaries.
- Fixing Vite HMR full reloads from circular barrel imports — the other common cause of reload-instead-of-patch.
- Vite Configuration & Ecosystem — config resolution and the wider toolchain context.