Fix: Node.js fs.watch Not Working — Cross-Platform Quirks, chokidar Migration, Recursive Watch
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Node.js file watching — fs.watch unreliable on Linux, missing events on save, recursive watch on Windows/macOS, chokidar polling fallback, ignoring patterns, debouncing, and EMFILE errors.
The Error
You watch a file and the callback fires inconsistently:
import { watch } from "node:fs";
watch("./config.json", (eventType, filename) => {
console.log(`${eventType}: ${filename}`);
});
// Edit config.json in VS Code:
// Sometimes: "change: config.json"
// Sometimes: "rename: config.json"
// Sometimes: nothingOr recursive watch errors on Linux:
watch("./src", { recursive: true }, callback);
// Error: ENOSYS: function not implemented
// (Linux: recursive watch not supported by fs.watch)Or EMFILE: too many open files on large directories:
Error: EMFILE: too many open files, watch '/path/to/dir'Or chokidar misses brand-new files:
import chokidar from "chokidar";
chokidar.watch("./uploads/", { ignoreInitial: true })
.on("add", (path) => console.log("new:", path));
// Drop a file in ./uploads/ — sometimes nothing fires.Why This Happens
Node’s fs.watch is a thin wrapper around OS-level file watching APIs, and the OSes do not agree on what file watching means. Linux uses inotify. Reliable for individual files but recursive is not supported — the kernel API only watches one directory at a time, and Node returns ENOSYS if you ask for recursion. macOS uses FSEvents. Supports recursive but events come in batches with debounce; you may see one event per save or one event covering several saves depending on the rate. Windows uses ReadDirectoryChangesW. Supports recursive natively but reports events with the path relative to the watched root, not absolute.
Each OS reports events differently. The same save in your editor may fire one event on macOS and three on Linux (because editors often “rename” via temp file + atomic move — VS Code, vim, IntelliJ, and Sublime all do this for crash safety). The eventType parameter is even less consistent: Linux distinguishes change from rename for a content edit vs an inode change, but the boundary between them shifts across kernel versions. Treating the eventType as actionable signal is a mistake; it’s a hint, and the right pattern is “something happened, re-check the file.”
For cross-platform reliability, most projects use chokidar — a userland library that normalizes behavior. Even chokidar has tradeoffs (polling mode for network filesystems, performance considerations on huge trees, memory pressure when watching node_modules). A third category — Watchman from Meta and parcel-watcher from the Parcel team — exists for cases where chokidar is too slow or memory-hungry. They’re more involved to set up but solve the scaling problems that bite chokidar on monorepos with 100K+ files.
How Other Tools Handle This
File watching libraries differ mainly in how they handle scale, polling fallback, and the inotify-limit problem on Linux.
- Node fs.watch (built-in). Thinnest possible wrapper over OS APIs. Strengths: no dependency, fast for one file or one directory. Weaknesses: no recursion on Linux, multiple events per save, no glob filtering, no debounce.
- chokidar. The de facto Node standard. Normalizes events across platforms, supports globs and
ignoredpatterns, hasawaitWriteFinishfor editor saves, can fall back to polling on filesystems that don’t support native events. Strengths: ubiquitous (Webpack, ESLint —watch, Vite, Nodemon, and basically every Node-based watcher uses it). Weaknesses: memory grows with watched files; on Linux you can hit the inotifymax_user_watchesceiling on big trees. - Watchman (Meta). A daemon written in C++. You run
watchmanas a long-lived process and Node clients talk to it via a Unix socket. Strengths: shares one set of OS-level watches across many clients, the fastest option for monorepos (used by Jest’s watch mode and Yarn’s PnP). Weaknesses: extra setup (brew install watchman), the daemon adds operational complexity, you debug it instead of debugging chokidar. - parcel-watcher. Native Node addon written in C++, used by Parcel and adopted by tools that need chokidar-level normalization with watchman-level performance. Strengths: faster than chokidar on large trees, no daemon required, supports
since-based diffing. Weaknesses: native module means platform-specific binaries; not as battle-tested as chokidar. - Native fs.watch with manual recursion. Some lightweight tools open one watcher per subdirectory. Strengths: no dependency. Weaknesses: hits
EMFILEon big trees, no glob filtering, you reinvent half of chokidar.
For most projects, chokidar is right. Switch to parcel-watcher or Watchman when you have 50K+ files to watch and chokidar’s memory/CPU is unacceptable. Stay on raw fs.watch only for trivial single-file cases. The Linux inotify limit and editor-save-storm problems below apply equally to all of them.
Fix 1: When to Use Built-in fs.watch
For simple single-file or single-directory watching where you control the platform:
import { watch } from "node:fs";
import { promisify } from "node:util";
const watcher = watch("./config.json", (eventType, filename) => {
if (eventType === "change") {
console.log("Reloading config");
}
});
// Stop watching:
watcher.close();The async iterator form (Node 18+):
import { watch } from "node:fs/promises";
const watcher = watch("./config.json");
for await (const event of watcher) {
console.log(event.eventType, event.filename);
}fs.watch works for:
- Single file (config files, lockfiles).
- Single directory (non-recursive) where you don’t care about deep changes.
It does not work reliably for:
- Recursive watching on Linux (
ENOSYS). - Detecting new files in subdirectories (Linux without recursive).
- Cross-platform code where you can’t dictate the OS.
For those, use chokidar.
Common Mistake: Editor saves trigger multiple events. VS Code, vim, IntelliJ all use atomic saves (write to temp, rename). fs.watch sees: change, rename, change, rename. Debounce.
Fix 2: Use chokidar for Cross-Platform Reliability
npm install chokidarimport chokidar from "chokidar";
const watcher = chokidar.watch("./src", {
ignored: /node_modules|\.git/, // Regex or glob
persistent: true,
ignoreInitial: false, // Fire `add` for files that already exist
awaitWriteFinish: {
stabilityThreshold: 200, // Wait 200ms after last change before firing
pollInterval: 50,
},
});
watcher
.on("add", (path) => console.log("added:", path))
.on("change", (path) => console.log("changed:", path))
.on("unlink", (path) => console.log("removed:", path))
.on("addDir", (path) => console.log("dir added:", path))
.on("unlinkDir", (path) => console.log("dir removed:", path))
.on("error", (err) => console.error("watch error:", err))
.on("ready", () => console.log("ready"));
// Stop:
await watcher.close();Key options:
ignored— regex, glob, or function. Skip these paths.ignoreInitial— whentrue, don’t fireaddevents for files that already existed at start.awaitWriteFinish— wait for the file to stabilize before firing (handles editor temp-file saves).persistent— whenfalse, Node exits if no other handles are open.
Chokidar uses native events on each OS and falls back to polling on filesystems that don’t support them (network shares, Docker volumes on Mac).
Pro Tip: awaitWriteFinish is the single biggest win for editor compatibility. Without it, you process partial writes (file half-flushed) and get errors.
Fix 3: Recursive Watch (the Right Way)
For watching entire trees:
chokidar.watch("./src", {
ignored: ["**/node_modules/**", "**/.git/**", "**/.next/**"],
});Chokidar handles recursion correctly on all platforms.
For fs.watch recursive (macOS/Windows only):
import { watch } from "node:fs";
if (process.platform === "linux") {
// fs.watch recursive is not supported on Linux.
// Use chokidar instead.
throw new Error("Recursive watch requires chokidar on Linux");
}
watch("./src", { recursive: true }, (eventType, filename) => {
// filename is relative to the watched directory
console.log(eventType, filename);
});Always prefer chokidar unless you have a specific reason to avoid the dep.
Common Mistake: Trying to manually recurse with fs.watch (open one watcher per subdirectory). It works for small trees but hits EMFILE (too many open files) on big ones — each inotify watch uses an FD.
Fix 4: Polling for Network Filesystems and Docker Volumes
Native watching doesn’t work on NFS, SMB, or Docker for Mac’s bind mounts (older versions). Force polling:
chokidar.watch("./src", {
usePolling: true,
interval: 100, // Poll every 100ms
binaryInterval: 300, // Slower poll for binary files (large)
});Polling is CPU-expensive — only use when native doesn’t work. Verify with:
chokidar.watch("./src", {
// usePolling: false (default)
}).on("ready", function () {
const watched = this.getWatched();
console.log("watching", Object.keys(watched).length, "directories");
});If chokidar reports few watched directories on what should be a deep tree, native isn’t working — switch to polling.
For Docker Desktop on Mac with bind mounts, newer versions (4.x+) support native file events via gRPC FUSE or VirtioFS. Test before assuming polling is needed.
Pro Tip: In CI environments, file changes are rare — polling is fine. In dev with live reload, native is much faster.
Fix 5: Avoid EMFILE — Increase ulimit and Ignore Aggressively
EMFILE: too many open files happens when inotify (Linux) runs out of watches:
# Check current limit:
cat /proc/sys/fs/inotify/max_user_watches
# Default: ~8192 on Linux. Modern dev needs 524288+.
# Increase:
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
sudo sysctl -pFor macOS, the equivalent uses kqueue with file descriptors:
# Check:
ulimit -n
# Default: 256
# Increase:
ulimit -n 65536Adding ulimit to ~/.zshrc or ~/.bashrc makes it permanent for new shells.
For chokidar, aggressively ignore:
ignored: [
"**/node_modules/**",
"**/.git/**",
"**/dist/**",
"**/build/**",
"**/.next/**",
"**/coverage/**",
"**/.cache/**",
],Every directory chokidar watches consumes a watch descriptor. Excluding node_modules (often hundreds of thousands of files) is the single biggest reduction.
Common Mistake: Watching node_modules for changes. You almost never need to. Always exclude.
Fix 6: Debounce Editor Saves
Editor saves can fire 2-5 events for one save:
import { debounce } from "lodash-es";
const handleChange = debounce((path) => {
console.log("file changed (debounced):", path);
rebuild();
}, 100);
chokidar.watch("./src")
.on("change", handleChange)
.on("add", handleChange);100-200ms debounce eats the temp-file dance. For batch rebuilds (process many changed files together):
const pending = new Set<string>();
const flush = debounce(() => {
rebuild(Array.from(pending));
pending.clear();
}, 200);
chokidar.watch("./src")
.on("change", (path) => { pending.add(path); flush(); })
.on("add", (path) => { pending.add(path); flush(); });The set deduplicates rapid-fire events on the same path; flush runs once per batch.
Pro Tip: Use awaitWriteFinish (Fix 2) for editor saves; use debouncing for batch operations (multiple files changed together). They solve different problems.
Fix 7: Watching Globs
Chokidar accepts globs natively:
chokidar.watch([
"./src/**/*.ts",
"./src/**/*.tsx",
"./public/**/*",
])
.on("change", (path) => console.log("changed:", path));Glob exclusion:
chokidar.watch("./src/**/*", {
ignored: ["**/*.test.ts", "**/__snapshots__/**"],
});Globs with chokidar work on all platforms — chokidar normalizes the matching.
Common Mistake: Using globs with fs.watch directly. fs.watch accepts only paths, not globs. Use chokidar or write your own glob → path expansion.
Fix 8: Memory Usage and Performance
For large monorepos, chokidar memory grows with watched files. Check:
const watcher = chokidar.watch("./packages", {
ignored: ["**/node_modules/**", "**/dist/**"],
});
watcher.on("ready", () => {
const watched = watcher.getWatched();
let total = 0;
for (const dir in watched) {
total += watched[dir].length;
}
console.log(`Watching ${total} files in ${Object.keys(watched).length} dirs`);
});If watching > 10K files, consider:
- Stricter ignores. Even one stray
dist/adds thousands. - Watch only what changes. For build tools, watch source dirs only.
- Use polling for very large trees. Polling with a higher interval (500ms) uses less memory than maintaining thousands of inotify watches.
For Vite/Webpack/esbuild, they bundle their own watchers — don’t add your own on top.
For Node test runners (vitest, jest):
{
testEnvironment: "node",
watchPathIgnorePatterns: ["/node_modules/", "/dist/"],
// Some runners use chokidar internally; configure their ignore lists.
}Pro Tip: Profile chokidar’s CPU usage with process.cpuUsage() before and after running. If usePolling: true and CPU is high, polling is the cost — switch to native if available.
Still Not Working?
A few less-obvious failures:
EACCES: permission denied, watch. Path you can’t read. Check file permissions.- Watch fires on
package-lock.jsonregeneration. Yarn/npm/pnpm rewrite this on every install. Add toignored. - Symlinks not followed. Pass
followSymlinks: true(default) — but symlink cycles can infinite-loop. Useignoredto break cycles. - Adding a new directory doesn’t trigger watch on its files. On Linux, you need to watch the parent and add the new dir’s contents recursively. Chokidar handles this; raw
fs.watchdoesn’t. - Watch keeps running after script should exit. Without
persistent: false, the watcher keeps Node alive. Callwatcher.close()explicitly or setpersistent: false. - Slow on Windows with antivirus. Defender scans every file write. Add your project to exclusions, or accept the latency.
- Watch broken in Docker. Mount type matters.
cachedanddelegatedmodes for Docker Desktop affect event delivery. For dev, preferchokidar --usePolling. - Process freezes on file rename. Atomic moves cross filesystems (e.g. /tmp → ./output) fall back to copy + delete. Watch sees this as a longer sequence. Debounce or
awaitWriteFinish. - Watcher silently stops after a few hours. Long-running processes can lose inotify watches if the watched path is briefly unmounted (NFS dropouts, USB drive sleep). Listen for the
errorevent and restart the watcher; chokidar’serrorevent fires on lost watches. - First event missed on directory creation. Linux’s inotify watches a parent directory and reports new entries; chokidar then opens a new watch on the new child. There’s a small race where files added during the chokidar setup are missed. Set
ignoreInitial: falseand let chokidar emitaddfor everything it discovers. - Watching the same path from two Node processes doubles CPU. Each process opens its own inotify/FSEvents subscription. For dev workflows running multiple
--watchcommands, a single Watchman daemon is cheaper than each tool watching independently.
For related Node and filesystem issues, see Linux too many open files, ENOSPC system limit file watchers reached, Node stream pipeline not working, and Webpack dev server not reloading.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: pnpm Catalog Protocol Not Working — Cannot Find Catalog, Resolution Errors, and Lockfile Issues
Fix pnpm 9.5+ catalog protocol errors — Cannot find catalog default, ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC, stale lockfile state, and tool incompatibility with catalog: references in monorepos.
Fix: Node.js Stream pipeline() Not Working — Backpressure, Error Propagation, AbortSignal, and Web Streams Interop
How to fix Node.js stream/promises pipeline errors — uncaught stream errors, backpressure ignored, AbortSignal not propagating, async iterators in pipeline, Transform stream object mode, and converting between Node and Web Streams.
Fix: Node.js Test Runner Not Working — node --test, TypeScript, Mocks, Coverage, and Watch Mode
How to fix Node.js built-in test runner errors — node --test not finding files, ESM vs CJS imports, TypeScript with --experimental-strip-types, mock.method isolation, coverage reporting, and watch mode setup.
Fix: pnpm Workspace Not Working — workspace:* Protocol, Catalog, Filters, and Hoisting Issues
How to fix pnpm workspace errors — workspace:* not resolving, catalog versions out of sync, --filter not matching, peer deps unmet across packages, shamefully-hoist trade-offs, and publishConfig for releases.