Repository navigation
Description
Describe the bug
On macOS, each transform of a module that lives outside root (a linked workspace package, for example) registers one more watch listener for that file, and none is ever removed. A long dev session grows the heap without bound, and every file system event under the watched folder is delivered to every accumulated listener, so the cost of each change event grows with how long the server has run.
The chain:
- Loading a module outside the root calls
ensureWatchedFile, which callswatcher.add(file)every time, with no check that the file is already watched. - On macOS, Vite's bundled chokidar 3.6.0 takes the FSEvents path:
_addToFsEvents→initWatch→setFSEventsListener, which adds a newfilteredListenerto the folder's listener set on each call, and_addPathCloserstores a new closer each time. Nothing deduplicates a path that is already watched. - On the
fs.watchpath (Linux, Windows, orserver.watch.useFsEvents: falseon macOS), chokidar does deduplicate, so the count stays at one.
Either layer can fix it: ensureWatchedFile can remember which files it has already added (per watcher), or the chokidar patch can skip setFSEventsListener for a path that already has a closer.
Reproduction
package.json:
{"name":"repro","private":true,"type":"module","devDependencies":{"vite":"8.3.5"}}repro.mjs (writes app/main.js and shared/value.js itself):
// node repro.mjs [transforms] FSEvents watcher (macOS default)
// NO_FSEVENTS=1 node repro.mjs [transforms] fs.watch watcher, for comparison
// Transforms a module that lives outside the Vite root again and again, as a dev session does on each edit,
// and prints how many watch registrations chokidar holds for that one file (read from its private _closers map).
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { createServer, version } from "vite";
mkdirSync("app", { recursive: true });
mkdirSync("shared", { recursive: true });
writeFileSync("app/main.js", 'import { value } from "../shared/value.js";\nconsole.log(value);\n');
writeFileSync("shared/value.js", "export const value = 1;\n");
const transforms = Number(process.argv[2] ?? 2000);
const outside = path.resolve("shared/value.js");
const url = `/@fs${outside}`;
const server = await createServer({
logLevel: "silent",
root: path.resolve("app"),
server: {
fs: { allow: [process.cwd()] },
middlewareMode: true,
watch: process.env.NO_FSEVENTS ? { useFsEvents: false } : undefined,
},
});
const registrations = () => server.watcher._closers.get(outside)?.length ?? 0;
const settle = () => new Promise((resolve) => setTimeout(resolve, 500));
await server.transformRequest(url);
await settle();
console.log(`platform=${process.platform} vite=${version} useFsEvents=${!process.env.NO_FSEVENTS}`);
console.log(`after 1 transform: watch_registrations=${registrations()}`);
for (let i = 0; i < transforms; i += 1) {
server.moduleGraph.invalidateModule(await server.moduleGraph.getModuleByUrl(url));
await server.transformRequest(url);
}
await settle();
console.log(`after ${transforms + 1} transforms: watch_registrations=${registrations()}`);
await server.close();Steps to reproduce
npm install
node repro.mjs 2000
NO_FSEVENTS=1 node repro.mjs 2000repro.mjs creates a middleware-mode server with root: "app", then invalidates and transforms /@fs/<project>/shared/value.js 2000 times. It prints the number of closers chokidar holds for that file.
platform=darwin vite=8.3.5 useFsEvents=true
after 1 transform: watch_registrations=1
after 2001 transforms: watch_registrations=2001
platform=darwin vite=8.3.5 useFsEvents=false
after 1 transform: watch_registrations=1
after 2001 transforms: watch_registrations=1
Expected: one registration per watched file, however many times it is transformed.
System Info
System:
OS: macOS 27.2
CPU: (12) arm64 Apple M2 Pro
Binaries:
Node: 24.22.1
npm: 11.19.1
npmPackages:
vite: 8.3.5 => 8.3.5Used Package Manager
npm
Logs
No response
Validations
- Follow our Code of Conduct
- Read the Contributing Guidelines.
- Read the docs.
- Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
- Make sure this is a Vite issue and not a framework-specific issue.
- Check that this is a concrete bug.
- The provided reproduction is a minimal reproducible example of the bug.
Activity
quantizor commentedon Oct 29, 2026
One more data point and the change I'm running locally. It keeps a per-watcher set of files already added, so each out-of-root file is registered once, and it drops a file from the set when chokidar reports it deleted so a recreated file is watched again:
On 8.3.1 with this change, transforming one out-of-root module repeatedly leaves one entry for it in
server.watcher._closers.duongynhi000005-oss commentedon Oct 30, 2026
I investigated #23940 and traced the repeated
watcher.addcalls inpackages/vite/src/node/utils.ts:Root Cause
In
packages/vite/src/node/server/transformRequest.ts:292, every transform callsensureWatchedFile(server.watcher, id, config.root). Inpackages/vite/src/node/utils.ts:785-801,ensureWatchedFilechecks whetherfileis outsiderootand unconditionally callswatcher.add(path.resolve(file)). On macOS, chokidar's FSEvents handler (_addToFsEvents) registers a newfilteredFsEventsListenerclosure in the directory'slistenersset on everywatcher.add()call for an already-watched out-of-root file.Proposed Fix
Track already-added out-of-root file paths per
FSWatcherusing aWeakMap<FSWatcher, Set<string>>inensureWatchedFilesowatcher.add(resolvedPath)is only invoked once per file per watcher instance.I have a branch ready (
duongynhi000005-oss/vite:fix-issue-23940-dedup-ensure-watched-file) and would be glad to open a PR!fix(dev): patch Vite to watch each file once and map SSR stacks on de…
docs(research): dev server memory, edits now cost nothing
fix(dev): dev server memory no longer grows with each edit; shared re…
bensig commentedon Nov 14, 2026
Confirmed with Vite 7.3.8 and chokidar 3.6.0 on macOS 27.2 (26B123), Apple Silicon, Node 24.22.1.
I used a separate middleware server with configFile: false and polling disabled. I repeatedly invalidated and transformed one JavaScript file outside the Vite root.
I checked server.watcher._closers for that file:
The installed ensureWatchedFile calls watcher.add(path.resolve(file)) without checking for an existing registration.
This reproduces the duplication in 7.3.8. The fs.watch backend avoids it in this isolated test without enabling polling.
I also observed a separate fseventsd memory incident. I have not established that Vite caused that daemon failure.