Skip to content

Fix: Node.js fs.watch Not Working — Cross-Platform Quirks, chokidar Migration, Recursive Watch

FixDevs · (Updated: )

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: nothing

Or 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 ignored patterns, has awaitWriteFinish for 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 inotify max_user_watches ceiling on big trees.
  • Watchman (Meta). A daemon written in C++. You run watchman as 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 EMFILE on 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 chokidar
import 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 — when true, don’t fire add events for files that already existed at start.
  • awaitWriteFinish — wait for the file to stabilize before firing (handles editor temp-file saves).
  • persistent — when false, 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 -p

For macOS, the equivalent uses kqueue with file descriptors:

# Check:
ulimit -n
# Default: 256

# Increase:
ulimit -n 65536

Adding 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.json regeneration. Yarn/npm/pnpm rewrite this on every install. Add to ignored.
  • Symlinks not followed. Pass followSymlinks: true (default) — but symlink cycles can infinite-loop. Use ignored to 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.watch doesn’t.
  • Watch keeps running after script should exit. Without persistent: false, the watcher keeps Node alive. Call watcher.close() explicitly or set persistent: 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. cached and delegated modes for Docker Desktop affect event delivery. For dev, prefer chokidar --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 error event and restart the watcher; chokidar’s error event 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: false and let chokidar emit add for 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 --watch commands, 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles