C.W.K.
Stream
Lesson 05 of 05 · published

File Watchers — fs.watch and the OS Quirks Beneath

~11 min · io-net, fs-watch, filesystem

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"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:

  • Linuxinotify. 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.
  • macOSFSEvents. Coalesces events, can fire "something changed" without telling you exactly what. Recursive watching is the default; non-recursive needs workarounds.
  • WindowsReadDirectoryChangesW. 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

The npm package 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 single change, not delete+add.
  • Consistent add/change/unlink event vocabulary.
For production-grade watching, install chokidar. 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

My first "reload on file change" script used raw 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.

Code

Debounced watcher — taming macOS double-fires·javascript
// Minimal debouncer for fs.watch — handles macOS double-fires
import { watch } from 'node:fs/promises';

async function watchDebounced(path, callback, { ms = 50 } = {}) {
  const watcher = watch(path, { recursive: true });
  let timer = null;
  let pending = new Map();

  for await (const ev of watcher) {
    pending.set(ev.filename, ev.eventType);
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      const snapshot = new Map(pending);
      pending.clear();
      callback(snapshot);
    }, ms);
  }
}

watchDebounced('./src', (changes) => {
  console.log(`${changes.size} files changed:`, [...changes.entries()]);
});
chokidar — what almost every dev tool actually uses·javascript
// Using chokidar — the production-grade option
import chokidar from 'chokidar';

const watcher = chokidar.watch('./src', {
  ignored: /node_modules|\.git/,
  persistent: true,
  ignoreInitial: true,
  awaitWriteFinish: { stabilityThreshold: 100 },  // wait for write to settle
});

watcher
  .on('add',    p => console.log('+', p))
  .on('change', p => console.log('~', p))
  .on('unlink', p => console.log('-', p))
  .on('error',  e => console.error('err:', e));

// Clean shutdown
process.on('SIGINT', () => watcher.close());

External links

Exercise

Build tail.mjs — watches a single log file for appends and prints new content as it arrives, like tail -f. Test it: in one terminal run node tail.mjs ./app.log; in another, run echo 'event' >> ./app.log a few times. Each line should appear in the tail process within milliseconds. Bonus: handle log rotation (the file gets replaced via rename — your tail should detect that and reopen).
Hint
Use fs.watch(path, { persistent: true }). On each change event, stat the file, compare current size to your last-known size, read the new bytes via an open file handle at the old offset, print. For rotation, rename events with a new inode mean reopen the file. Track inode via fs.stat's ino field.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.