Fix: Node.js fs.watch Not Working — Cross-Platform Quirks, chokidar Migration, Recursive Watch
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:
- Linux: uses
inotify. Reliable for individual files butrecursiveis not supported. - macOS: uses
FSEvents. Supports recursive but events come in batches with debounce. - Windows: uses
ReadDirectoryChangesW. Supports recursive natively.
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).
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).
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.
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: 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.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.