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.
The reason this problem is specific to monorepos is structural, not incidental. A single-package app imports its dependencies from a flat node_modules that Vite pre-bundles once at startup and then never re-resolves. A workspace inverts that assumption: your internal packages live in packages/*/src, they are symlinked into node_modules by the package manager, and Vite treats them as first-party source rather than as sealed dependencies. Every one of their files becomes a node in the module graph that the dev server watches, transforms on demand, and invalidates when touched. The larger the workspace, the more source files sit inside the HMR boundary, and the more work each keystroke can trigger. The slowness is emergent from the topology — it appears once the workspace is big enough, on a codebase where no single file is unusually expensive.
There are two independent cost centers, and confusing them wastes hours. The first is the filesystem watcher: chokidar has to register every path it might be asked to notice, and in a symlinked workspace that set balloons to include thousands of files inside node_modules that will never change during a session. The second is module resolution: because internal packages are treated as source, Vite re-walks their import graphs to serve and invalidate them instead of hitting a pre-bundled cache. Watcher overhead shows up as startup lag and ENOSPC errors; resolution overhead shows up as slow individual updates. This guide separates the two, gives each a diagnosis path, and fixes both in one vite.config.ts.
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. The symlink is the load-bearing detail: pnpm and Yarn’s node-linker place the real package under packages/ui and drop a symlink at node_modules/@workspace/ui pointing back to it. Vite follows that symlink to the real path, sees source files under a directory it considers first-party, and pulls them into the module graph. A workspace that uses a hoisted, copied layout instead of symlinks will not reproduce the watcher blow-up the same way, so confirm your linker before you start — ls -l node_modules/@workspace/ui should show an arrow to ../../packages/ui.
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. The shape of the repro matters: the slowness is proportional to how many symlinked files chokidar registers, so a workspace with more packages degrades faster even though your app code is unchanged. That proportionality is the diagnostic signature — if adding an unrelated package to the workspace makes HMR in the app slower, you are watching topology cost, not code cost, and the fixes below apply directly. Keep the artificially low ceiling in place only long enough to confirm the repro; leaving max_user_watches at 8192 will make the rest of your tooling (test watchers, TypeScript’s incremental server) misbehave too.
How It Works Under the Hood
To fix the latency deliberately rather than by trial and error, trace what happens between the save and the browser patch. When you write a file, chokidar emits a change event for that path. Vite maps the path back to the module in its graph, marks that module and its importers dirty by walking the importers set upward until it reaches a module that has declared an import.meta.hot.accept boundary, and then sends a JSON message over the HMR WebSocket telling the client which module ids to re-fetch. The client re-requests those ids, the server transforms them, and the new module code replaces the old. Under 200ms, none of this is perceptible.
Two things about that path go wrong at workspace scale. First, chokidar’s cost is paid up front and continuously: it must stat and register every path it watches so it can recognize a later change, and with node_modules symlinks unignored that registration set includes every transitive dependency file in the workspace. On Linux each watched path consumes one inotify watch, so the registration itself can exhaust fs.inotify.max_user_watches before the app even loads. Second, the upward walk to find an accept boundary is only cheap if the modules along the way are already resolved and cached. For an internal package served as raw source, Vite has to resolve each import specifier — apply resolve.conditions, follow the exports map, walk the symlink — every time the module is touched, because it was never pre-bundled into a single flat artifact. Pre-bundling replaces that per-change graph walk with a lookup in node_modules/.vite/deps.
The reason optimizeDeps normally skips your internal packages is that Vite’s dependency scanner only pre-bundles bare imports it classifies as third-party, and a symlinked workspace package resolves to a path inside your project tree, so the scanner leaves it as source. That default is correct for packages you are actively editing and wrong for stable shared packages you merely consume — which is exactly the tension the fix and its gotchas navigate.
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. This is the cheapest check because it costs one save and tells you whether you have a problem at all: if the number is already under 200ms, stop — the lag you feel is coming from the editor’s save latency, a slow framework runtime, or browser paint, none of which this guide addresses. Note whether the slow line names a file underpackages/*(resolution cost) or whether the terminal is quiet for seconds before any line appears (watcher cost); the two symptoms point at different fixes. - 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 -pENOSPChere has nothing to do with disk space despite the name — it is the kernel refusing to hand out more inotify watches. Raising the ceiling is a legitimate fix on a developer machine, but treat a low ceiling as a symptom, not the disease: if you need half a million watches to run one dev server, chokidar is watching files it should be ignoring, and step 3 removes the underlying demand rather than papering over it. On macOS there is noinotifylimit to raise; the equivalent pressure surfaces as elevatedfseventsdCPU, so the reduction in step 3 matters there too even though the error never appears. - Confirm chokidar is not traversing
node_modules. RunDEBUG=vite:* viteand watch thevite:configoutput; if nowatch.ignoredis set, every symlinked dependency is being stat-ed. You can make the traversal visible: start the server, then runlsof -p $(pgrep -f vite) | grep -c node_modules— a count in the thousands means the watcher has descended into dependency trees it will never usefully observe. The defaultwatch.ignoredin Vite does covernode_modulesfor non-symlinked layouts, but symlinked workspace packages defeat the default because they resolve outside the naive glob, which is why the explicit**/node_modules/**entry in the fix is not redundant. - 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. If the log names only third-party libraries and skips@workspace/*, the scanner classified them as source and is paying the per-change resolution cost described above. Open the Network panel after a reload and check the request URL for an internal import: a path ending in.vite/deps/@workspace_ui.jsis proof the pre-bundle took; a path under/packages/ui/src/is proof it did not. - Rule out duplicate framework instances. Duplicate
reactcopies across packages force full-page reloads instead of HMR patches — check withpnpm why react. The mechanism is that React’s HMR integration keys component identity off the module instance; when two packages resolve to two physically distinctreactcopies, the runtime cannot reconcile a hot update across the boundary and the plugin falls back to a full reload to stay correct. Any output frompnpm why react(ornpm ls react) that lists more than one resolved version or path is the signal. This is last in the funnel because it is the least common cause in a well-configured workspace, but it is also the one that masquerades most convincingly as a Vite bug.
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. The globs are matched against resolved paths, so **/node_modules/** catches the symlink targets that the built-in default misses. fs.allow overrides server.fs.strict, which otherwise returns The request url is outside of Vite serving allow list for any sibling-package import — a 403 that reads like a routing bug but is really the dev server’s path sandbox refusing to serve files above the project root. optimizeDeps.include caches transformed ESM for internal packages, and resolve.dedupe collapses duplicate framework copies that would otherwise break the HMR boundary.
Order of application matters when you reason about why a partial config still feels slow. fs.allow only unblocks serving; on its own it does nothing for latency. watch.ignored cuts startup and idle CPU but does not speed up an individual update once a package is being watched as source. optimizeDeps.include is the line that actually shortens the per-save round-trip, because it removes the resolution walk. If you apply only one line, apply that one — but the four are complementary, not alternatives, and each figure caption above names the single symptom that returns if you delete its line.
The optimizeDeps.include array above is hand-written, which is fine for two or three packages and a liability past that. Generate it from the workspace manifest so a newly added package is never silently left as un-bundled source:
// vite.config.ts fragment — Vite 5.x / 6.x, Node 20+
// Derive optimizeDeps.include from every internal package name.
import { defineConfig } from 'vite';
import { globSync } from 'glob'; // glob@10+
import { readFileSync } from 'node:fs';
const internalPackages = globSync('../../packages/*/package.json').map(
(p) => JSON.parse(readFileSync(p, 'utf8')).name as string,
);
export default defineConfig({
optimizeDeps: {
// Exclude the packages you are actively editing this session; see gotchas.
include: internalPackages.filter((name) => name !== '@workspace/ui'),
},
});
Reading the names from package.json rather than inferring them from directory names is deliberate: the specifier Vite resolves is the package name field, and it does not have to match the folder. Deriving the list from the manifest keeps the config correct through renames and additions with no manual edit.
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.
Verify the four properties independently, because a green result on one does not imply the others. The absence of ENOSPC confirms the watcher fix; the sub-200ms number confirms the resolution fix; the .vite/deps path confirms the pre-bundle specifically took for the package you edited; and the absence of a page reload line confirms dedupe held the HMR boundary. If you see page reload even though every other signal is green, the module you touched has no reachable import.meta.hot.accept boundary — that is an application-structure issue rather than a config one, and it is the subject of the circular-barrel-imports guide linked below. Keep the vite --debug hmr terminal open for a few real edits, not just the one synthetic save: a config that is fast for a leaf component but slow for a widely imported base module is telling you the invalidation is fanning out across importers, which no amount of watcher tuning will fix.
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. The symptom is disorienting: you save a file in@workspace/ui, the terminal reports an update, but the browser shows the old code — because the browser is being served the frozen.vite/depsbundle, not your edit. The root cause is that pre-bundling is a startup-time snapshot; the fix is to keep the package you are working in out of the include list, and the way to confirm you got it right is that its imports resolve back to/packages/ui/src/in the Network panel. In practice, teams settle on a stable set of “library” packages that are always pre-bundled and treat the one or two packages under active development as excluded, flipping membership as focus moves.- Dynamic include lists drift. If the include list grows unwieldy, generate it: glob
packages/*/package.json, read eachname, and feed the array tooptimizeDeps.include(the fragment above does exactly this). Stale hand-maintained lists silently drop newly added packages. The failure mode is quiet — a new package works, just slowly, so nobody notices the regression until HMR has crept back over a second. Deriving the list from the manifest turns a silent performance leak into an automatic inclusion, and it survives the package renames that a hardcoded string array does not. - 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. Polling trades a per-file registration cost for a per-interval scan cost, which sounds like a win but scales with the number of watched files times the poll frequency, so on a large tree it pins a CPU core for no latency benefit. Splitting dev servers works because it shrinks each server’s module graph rather than changing how the graph is watched; run the two or three domains you are actually touching and leave the rest built. If you must poll, raiseintervalwell above the default to keep the scan cost bounded. 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. The dev server will happily serve any file inside an allowed path to any client that can reach it, so a wide allow list on a machine that binds to0.0.0.0turns the file sandbox into a read-anything endpoint on your network. The correct scope is the single directory that contains every package the app imports — usually the workspace root two levels up — and nothing higher. If a legitimate import lives outside that root, add its specific directory to the array rather than widening the root to swallow it.
When Not to Reach for This
Not every slow save in a workspace is a watcher or resolution problem, and applying this config to the wrong cause hides the real one. If the vite --debug hmr number is already under 200ms, the felt lag is downstream — save-on-focus-loss delay in the editor, a heavy component that re-renders slowly, or a service worker intercepting the reload — and none of these levers touch it. If your workspace uses a hoisted, non-symlinked node_modules, the watcher never descended into per-package source in the first place, so watch.ignored has little to remove; look at bundle-time transform cost instead. And if the slowness only appears on the very first save after startup and is fast thereafter, you are seeing cold pre-bundling, which is expected once-per-session work rather than a steady-state defect — optimizeDeps is already doing its job.
CI Integration
The one property worth enforcing in CI is that the pre-bundle actually covers every internal package, because that is the setting most likely to silently regress as the workspace grows. A dev-server latency assertion is flaky on shared runners, but a config assertion is deterministic: build the expected include set from the workspace manifests the same way the config does, and fail if the resolved config omits any of them.
// scripts/check-optimizedeps.ts — Node 20+, run in CI
// Fails if any internal package is missing from optimizeDeps.include.
import { resolveConfig } from 'vite'; // vite 5.x / 6.x
import { globSync } from 'glob';
import { readFileSync } from 'node:fs';
const expected = globSync('packages/*/package.json').map(
(p) => JSON.parse(readFileSync(p, 'utf8')).name as string,
);
const config = await resolveConfig({}, 'serve');
const included = new Set(config.optimizeDeps?.include ?? []);
const missing = expected.filter(
(name) => !included.has(name) && name !== '@workspace/ui', // the edited one
);
if (missing.length) {
console.error(`optimizeDeps.include is missing: ${missing.join(', ')}`);
process.exit(1);
}
Wire this into the same job that runs your lint step. It costs milliseconds, needs no browser, and converts “HMR got slow again and nobody noticed for a month” into a red build on the commit that added the un-bundled package.
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.