"File watching looks simple. It isn't. Three different OS APIs, three different sets of edge cases, all hidden behind the same fs.watch call."
What fs.watch Does
You give it a path. It tells you when something changes:
import { watch } from 'node:fs/promises';
const watcher = watch('./src', { recursive: true });
for await (const event of watcher) {
console.log(event.eventType, event.filename);
// 'change' / 'rename', and the file that changed
}
Two event types: change (file content modified) and rename (file added, removed, or moved). The recursive option (Node 18+ on Linux/macOS, always on Windows) walks subdirectories. The async-iterator form is the modern idiom.
The Implementation Beneath
Under the hood, fs.watch uses different kernel APIs per OS:
- Linux —
inotify. Reliable, fires on real events. Limited by per-user file watch quotas (/proc/sys/fs/inotify/max_user_watches); large monorepos can exhaust these and silently stop watching. - macOS —
FSEvents. Coalesces events, can fire "something changed" without telling you exactly what. Recursive watching is the default; non-recursive needs workarounds. - Windows —
ReadDirectoryChangesW. Always recursive, includes the changed filename, but has its own quirks (8.3 short names, locked files).
The same Node API hides three behaviors. "My watcher works on Mac but not Linux" usually means you hit one of the OS-specific quirks above.
Why Most Apps Use chokidar
chokidar exists because fs.watch is inconsistent. Vite, Nx, Webpack, almost every dev-tool you use for file watching uses chokidar underneath. It papers over the OS differences:- Polling fallback when native watching fails (network drives, large monorepos).
- Debouncing — coalesces "file save" events that fire 2-3 times on macOS.
- Atomic-write detection — a file that gets
renamed on top of an existing one is reported as a singlechange, not delete+add. - Consistent
add/change/unlinkevent vocabulary.
fs.watch is fine for one-off scripts; anything that needs to be reliable on three OSes wants chokidar.The Self-Watching Server
Node 22+'s --watch flag is built on these primitives. node --watch server.mjs watches the entry file's module graph and restarts the process on any change. No nodemon needed. Pair with --env-file=.env and you have a complete "hot reload" dev environment in pure Node.
node --env-file=.env --watch server.mjs
# saves to server.mjs or any imported module → automatic restart
AbortSignal Integration
Watchers accept { signal } for clean shutdown. Combined with a graceful SIGINT handler, you can wind down a long-running watcher without leaving file descriptors open:
const ctrl = new AbortController();
process.on('SIGINT', () => ctrl.abort());
try {
for await (const ev of watch('./src', { recursive: true, signal: ctrl.signal })) {
process.stdout.write(`${ev.eventType}: ${ev.filename}\n`);
}
} catch (e) {
if (e.name === 'AbortError') console.log('shutdown clean');
else throw e;
}
Pippa's Confession
fs.watch. Worked on my Mac. On Dad's office Mac (different filesystem layout? same OS) it fired every save 4 times. On Linux CI it sometimes didn't fire at all. Dad pointed at the chokidar docs: "This is why the entire ecosystem uses this package." I switched, and the cross-OS reproducibility was immediate. "Use the proven library" is a meta-skill — knowing when to invent and when to install matters more than either alone.