Skip to content
Node.js nd fs 5 min read

Watching Files & Directories

Watching the file system lets your program react the moment a file or directory changes — rebuilding assets, reloading configuration, or syncing state without polling on a tight loop. Node.js ships two built-in watchers with very different mechanics: fs.watch, which hooks into the operating system’s native change-notification APIs, and fs.watchFile, which polls a file’s stats at an interval. Each has trade-offs and well-known platform quirks, so understanding when to reach for which (and when to reach for a library instead) saves a lot of debugging.

Event-based watching with fs.watch

fs.watch is the efficient choice. It registers with the kernel — inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows — and fires a callback only when something actually changes. The callback receives an eventType ('rename' or 'change') and the filename that triggered it.

import { watch } from 'node:fs';

const watcher = watch('./config.json', (eventType, filename) => {
  console.log(`Event: ${eventType} on ${filename}`);
});

// Stop watching when you're done
process.on('SIGINT', () => {
  watcher.close();
  process.exit(0);
});

Output:

Event: change on config.json
Event: rename on config.json

The 'change' event means the file’s contents or metadata were modified. The 'rename' event is broader than its name suggests: it fires when a file is created, deleted, or moved — essentially any change to the directory entry itself. You often have to call fs.stat afterward to learn what really happened.

The filename argument can be null on some platforms, and it is not guaranteed to be provided for every event. Always guard against a missing filename before using it.

fs.watch also returns an FSWatcher, which is an EventEmitter. You can attach listeners instead of passing a callback, and the modern promises API exposes an async iterator:

import { watch } from 'node:fs/promises';

const ac = new AbortController();
setTimeout(() => ac.abort(), 30_000); // auto-stop after 30s

try {
  const watcher = watch('./src', { recursive: true, signal: ac.signal });
  for await (const event of watcher) {
    console.log(event.eventType, event.filename);
  }
} catch (err) {
  if (err.name !== 'AbortError') throw err;
}

Recursive watching

Passing { recursive: true } watches an entire directory tree. This is supported natively on macOS and Windows, and — since Node.js 20 — on Linux as well. On other platforms or older versions it throws or is silently ignored, so confirm support before relying on it.

import { watch } from 'node:fs';

watch('./src', { recursive: true }, (eventType, filename) => {
  console.log(`${eventType}: ${filename}`);
});

Polling with fs.watchFile

fs.watchFile takes a completely different approach: it calls fs.stat on a fixed interval and compares the result to the previous one. It is less efficient and higher-latency, but it works uniformly across platforms and over network file systems (NFS, SMB) where native notifications are unreliable.

The listener receives two Stats objects — current and previous — rather than an event type:

import { watchFile, unwatchFile } from 'node:fs';

watchFile('./data.log', { interval: 1000 }, (curr, prev) => {
  if (curr.mtimeMs !== prev.mtimeMs) {
    console.log(`Modified at ${curr.mtime.toISOString()}`);
  }
  if (curr.size !== prev.size) {
    console.log(`Size changed: ${prev.size} -> ${curr.size} bytes`);
  }
});

// Later, stop polling
unwatchFile('./data.log');

Output:

Modified at 2026-06-14T09:32:11.004Z
Size changed: 2048 -> 4096 bytes

Because watchFile compares timestamps, the listener fires on every poll where stats differ — including when a file is touched but its content is identical in size. Compare the fields you care about (mtimeMs, size) explicitly rather than assuming the callback means a meaningful change.

fs.watch vs fs.watchFile

Aspectfs.watchfs.watchFile
MechanismNative OS notificationsPeriodic stat polling
LatencyNear-instantUp to one interval
CPU costLow (idle until event)Continuous polling
Callback dataeventType, filenamecurrent and previous Stats
Recursive supportYes (with recursive: true)No
Network/odd filesystemsOften unreliableWorks reliably
Stop watchingwatcher.close()unwatchFile(path)

Platform inconsistencies

The native watchers are thin wrappers over OS APIs, and those APIs disagree:

  • Duplicate events. A single save often fires multiple 'change' events because editors write, truncate, and rename in quick succession. Debounce your handler.
  • Missing filename. Only macOS and Linux reliably provide the filename; on some setups it is null.
  • Rename ambiguity. A 'rename' event does not tell you whether the file was added or removed — you must stat to find out.
  • Atomic saves. Many editors save by writing a temp file then renaming over the original, which can break a watch on the original inode entirely.
import { watch } from 'node:fs';

let timer;
watch('./app.css', () => {
  clearTimeout(timer);
  timer = setTimeout(() => console.log('Rebuilding styles...'), 100);
});

This debounce collapses a burst of events into a single rebuild.

A robust alternative: chokidar

For production tools — bundlers, dev servers, linters in watch mode — most projects use chokidar. It normalizes the platform differences above, smooths out duplicate events, handles atomic saves, and offers a clean emitter API with explicit add, change, and unlink events.

npm install chokidar
import chokidar from 'chokidar';

const watcher = chokidar.watch('./src', {
  ignored: /node_modules/,
  persistent: true,
  ignoreInitial: true,
});

watcher
  .on('add', (path) => console.log(`File added: ${path}`))
  .on('change', (path) => console.log(`File changed: ${path}`))
  .on('unlink', (path) => console.log(`File removed: ${path}`));

Output:

File changed: src/index.js
File added: src/utils.js
File removed: src/old.js

Best Practices

  • Prefer fs.watch for local file systems; fall back to fs.watchFile only for network mounts or when native events are unreliable.
  • Always debounce handlers — a single user save commonly emits several events.
  • Treat 'rename' as “the directory entry changed” and call fs.stat to determine whether a file was added or deleted.
  • Never assume filename is present; guard against null before using it.
  • Verify recursive support on your target platform and Node.js version before depending on { recursive: true }.
  • Call watcher.close() or unwatchFile() on shutdown to release OS handles and stop polling.
  • Reach for chokidar when you need cross-platform reliability and clean add/change/unlink semantics.
Last updated June 14, 2026
Was this helpful?