Skip to content
Prev Previous commit
Next Next commit
fs: skip watching ignored paths in recursive watcher
Instead of just filtering events, skip watching ignored paths entirely
to avoid kernel resource pressure from unnecessary file watchers.
This is especially important for large directories like node_modules.
  • Loading branch information
mcollina committed Jan 20, 2026
commit c829ef2309693d0ce0bb9fa6ec58f9727061e9e9
24 changes: 11 additions & 13 deletions lib/internal/fs/recursive_watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,6 @@ class FSWatcher extends EventEmitter {
this.emit('close');
}

#emitChange(eventType, filename) {
// Filter events if ignore matcher is set and filename is available
if (filename != null && this.#ignoreMatcher?.(filename)) {
return;
}
this.emit('change', eventType, filename);
}

#unwatchFiles(file) {
this.#symbolicFiles.delete(file);

Expand All @@ -130,9 +122,15 @@ class FSWatcher extends EventEmitter {
}

const f = pathJoin(folder, file.name);
const relativePath = pathRelative(this.#rootPath, f);

// Skip watching ignored paths entirely to avoid kernel resource pressure
if (this.#ignoreMatcher?.(relativePath)) {
continue;
}

if (!this.#files.has(f)) {
this.#emitChange('rename', pathRelative(this.#rootPath, f));
this.emit('change', 'rename', relativePath);

if (file.isSymbolicLink()) {
this.#symbolicFiles.add(f);
Expand Down Expand Up @@ -190,20 +188,20 @@ class FSWatcher extends EventEmitter {
this.#files.delete(file);
this.#watchers.delete(file);
watcher.close();
this.#emitChange('rename', pathRelative(this.#rootPath, file));
this.emit('change', 'rename', pathRelative(this.#rootPath, file));
this.#unwatchFiles(file);
} else if (file === this.#rootPath && this.#watchingFile) {
// This case will only be triggered when watching a file with fs.watch
this.#emitChange('change', pathBasename(file));
this.emit('change', 'change', pathBasename(file));
} else if (this.#symbolicFiles.has(file)) {
// Stats from watchFile does not return correct value for currentStats.isSymbolicLink()
// Since it is only valid when using fs.lstat(). Therefore, check the existing symbolic files.
this.#emitChange('rename', pathRelative(this.#rootPath, file));
this.emit('change', 'rename', pathRelative(this.#rootPath, file));
} else if (currentStats.isDirectory()) {
this.#watchFolder(file);
} else {
// Watching a directory will trigger a change event for child files)
this.#emitChange('change', pathRelative(this.#rootPath, file));
this.emit('change', 'change', pathRelative(this.#rootPath, file));
}
});
this.#watchers.set(file, watcher);
Expand Down
4 changes: 2 additions & 2 deletions test/parallel/test-fs-watch-ignore-recursive.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ tmpdir.refresh();

const watcher = fs.watch(testDirectory, {
recursive: true,
// Use array to match both the directory itself and files inside it
// On macOS, FSEvents may report events for the directory when files change inside it
// On Linux, matching the directory skips watching it entirely.
// On macOS, the native watcher still needs to filter file events inside.
ignore: ['**/node_modules/**', '**/node_modules'],
});

Expand Down
Loading