Watching Files for Changes

Many Node.js programs need to react when a file or folder changes on disk — reloading a config file, rebuilding assets, restarting a server, or syncing an upload folder. Node’s built-in fs module gives you three different ways to do this: an OS-level watcher, a polling-based watcher, and a modern async-iterator watcher. They behave differently under the hood, and picking the wrong one causes real bugs (missed events, high CPU usage, or a process that never exits). This lesson covers all three in depth.

Overview: How File Watching Works

There is no single, universal way for an operating system to say “this file changed.” Instead, each OS exposes its own native filesystem notification API: Linux has inotify, macOS has FSEvents (with a kqueue fallback), and Windows has ReadDirectoryChangesW. Node’s fs.watch() is a thin wrapper around whichever of these libuv supports on the current platform. When the kernel notices a change, it pushes an event up through libuv’s event loop, and your callback fires almost immediately — there’s no polling, so it’s cheap and fast.

The catch is that these native APIs are not perfectly consistent across platforms. The exact event names, whether the filename argument is populated, whether renames are reported as one event or two, and whether recursive watching is supported all vary by OS. fs.watch() is officially documented by Node as not fully consistent across platforms, and unavailable in some situations — you should treat it as “a change happened somewhere near this path,” not as a precise, guaranteed log of every edit.

Because of that, Node also ships fs.watchFile(), which takes a completely different approach: it polls the file’s stats (via fs.stat()) on a timer and compares the previous snapshot to the current one. This is slower to notice changes and burns a little CPU on every poll, but its behavior is identical everywhere, including on network filesystems where fs.watch() often doesn’t work at all.

Since Node 15.9, there is a third, modern option: fsPromises.watch(), which returns an async iterable. It uses the same native OS notifications as fs.watch() internally, but you consume events with a clean for await...of loop instead of a callback, and it supports AbortController for cancellation.

Syntax

fs.watch(filename[, options], listener)
fs.watchFile(filename[, options], listener)
fs.unwatchFile(filename[, listener])
fsPromises.watch(filename[, options])
Part Meaning
filename Path to a file or directory to watch.
options (watch) Object with persistent (keep the process alive, default true), recursive (watch subdirectories — supported on macOS and Windows, and on Linux since Node 20), and signal (an AbortSignal to stop watching).
options (watchFile) Object with persistent and interval (poll interval in ms, default 5007).
listener(eventType, filename) For fs.watch: called with "rename" or "change", and the filename if the platform provides it.
listener(curr, prev) For fs.watchFile: called with two fs.Stats objects, the current and previous state.
Return value fs.watch() returns an fs.FSWatcher (has .close() and emits "error"). fsPromises.watch() returns an AsyncIterable that yields { eventType, filename } objects.

Examples

Example 1: Basic fs.watch()

const fs = require("node:fs");

const watcher = fs.watch("config.json", (eventType, filename) => {
  console.log(`Event: ${eventType}`);
  console.log(filename ? `Filename: ${filename}` : "Filename not provided by the OS");
});

watcher.on("error", (err) => {
  console.error("Watcher error:", err.message);
});

process.on("SIGINT", () => {
  watcher.close();
  process.exit(0);
});

console.log("Watching config.json. Press Ctrl+C to stop.");
Watching config.json. Press Ctrl+C to stop.
Event: change
Filename: config.json

This watches a single file. The listener fires with an eventType of either "rename" (the file was created, deleted, or renamed) or "change" (its contents or metadata changed). Note the error listener: without it, a watcher error — for example the watched file being deleted on some platforms — can crash the process with an uncaught exception.

Example 2: Polling with fs.watchFile()

const fs = require("node:fs");

fs.watchFile("data.log", { interval: 1000 }, (curr, prev) => {
  if (curr.mtimeMs === prev.mtimeMs) return;

  console.log(`data.log changed at ${curr.mtime.toISOString()}`);
  console.log(`Size went from ${prev.size} to ${curr.size} bytes`);
});

setTimeout(() => {
  fs.unwatchFile("data.log");
  console.log("Stopped watching data.log");
}, 30000);
data.log changed at 2026-07-31T10:15:03.201Z
Size went from 482 to 601 bytes
Stopped watching data.log

fs.watchFile() re-checks the file’s stats every interval milliseconds and only calls your listener when something actually differs, so you must compare curr and prev yourself — the listener still fires on every poll tick even when nothing changed if you don’t add that guard in some Node versions, so always check mtimeMs. This approach works reliably on network drives and in Docker bind mounts where native OS notifications often silently fail, at the cost of latency (up to one full interval) and constant background CPU/disk work.

Example 3: Realistic use — debounced config reload

{
  "logLevel": "info",
  "maxConnections": 100
}
const fs = require("node:fs");
const path = require("node:path");

const configPath = path.join(__dirname, "config.json");
let config = loadConfig();
let reloadTimer = null;

function loadConfig() {
  const raw = fs.readFileSync(configPath, "utf8");
  return JSON.parse(raw);
}

const watcher = fs.watch(configPath, (eventType) => {
  if (eventType !== "change") return;

  clearTimeout(reloadTimer);
  reloadTimer = setTimeout(() => {
    try {
      config = loadConfig();
      console.log("Config reloaded:", config);
    } catch (err) {
      console.error("Failed to reload config (kept old values):", err.message);
    }
  }, 200);
});

process.on("SIGINT", () => {
  watcher.close();
  process.exit(0);
});

console.log("Watching config.json for changes...");
Watching config.json for changes...
Config reloaded: { logLevel: 'debug', maxConnections: 100 }

This is closer to real production code. It uses fs.readFileSync() on startup (blocking is fine here — it happens once, before the server starts serving traffic), then debounces reloads with setTimeout/clearTimeout because many editors and OSes fire multiple change events for a single logical save. It also wraps the reload in try/catch so a temporarily invalid (half-written) JSON file doesn’t crash the running server — it just keeps the last good config.

Example 4: The modern async-iterator API

const fsPromises = require("node:fs/promises");

async function watchDirectory(dir) {
  const controller = new AbortController();
  const watcher = fsPromises.watch(dir, { recursive: true, signal: controller.signal });

  setTimeout(() => controller.abort(), 60000);

  try {
    for await (const event of watcher) {
      console.log(`${event.eventType}: ${event.filename}`);
    }
  } catch (err) {
    if (err.name === "AbortError") {
      console.log("Stopped watching.");
      return;
    }
    throw err;
  }
}

watchDirectory("./logs").catch((err) => {
  console.error("Watch failed:", err);
});
change: app.log
rename: debug-2026-07-31.log
Stopped watching.

fsPromises.watch() uses the same native notifications as fs.watch(), so it has the same cross-platform caveats, but the async-iterator shape composes far more cleanly with modern async/await code, and cancellation via AbortController is much cleaner than manually calling .close().

How It Works Step by Step

  1. You call fs.watch(path, listener). Node asks libuv to register a native OS watch on that path.
  2. Your script keeps running synchronously past this call — watching is entirely non-blocking.
  3. Because a watcher is persistent by default, it keeps a handle open that stops Node’s event loop from exiting, even if there’s nothing else left to do.
  4. Later, the OS kernel detects a filesystem change and notifies libuv’s background watcher thread.
  5. Libuv queues the event onto the event loop’s poll phase, and your listener callback runs on the next loop iteration — not synchronously with the actual disk write, and not guaranteed to be instant.
  6. For fs.watchFile(), there’s no kernel notification at all: a setInterval-driven timer inside Node calls fs.stat() on the path every tick and diff’s the result against the previous stats snapshot.
  7. The watcher keeps firing until you explicitly call watcher.close() (for fs.watch), fs.unwatchFile() (for fs.watchFile), or abort the associated AbortSignal (for fsPromises.watch).

Common Mistakes

Mistake 1: Forgetting that watchers keep the process alive. A persistent watcher (the default) prevents Node from exiting on its own, even after all other work is done.

const fs = require("node:fs");

fs.watch("app.log", (eventType) => {
  console.log(`app.log ${eventType}`);
});

console.log("This script now hangs forever, with no way to stop it.");

Fix: keep a reference to the watcher and close it on shutdown (or on a timeout, or when using it as a short-lived CLI tool).

const fs = require("node:fs");

const watcher = fs.watch("app.log", (eventType) => {
  console.log(`app.log ${eventType}`);
});

process.on("SIGINT", () => {
  console.log("Shutting down, closing watcher...");
  watcher.close();
  process.exit(0);
});

Mistake 2: Confusing the callback-based fs.watch() with a promise. fs.watch() returns an FSWatcher synchronously; it is not awaitable. Only fsPromises.watch() gives you something you can iterate with await.

const fs = require("node:fs");

const watcher = await fs.watch("data.json", (eventType) => {
  console.log(eventType);
});

This doesn’t even parse as a CommonJS module — top-level await is only legal in ES modules, and fs.watch() doesn’t return a promise anyway. Use the callback form directly, or switch to fsPromises.watch() inside an async function and consume it with for await...of as shown in Example 4.

Mistake 3: Assuming filename is always provided, and ignoring duplicate events. On some platforms (notably certain Linux setups and network filesystems), the second argument to the fs.watch() listener can be null. Many editors also save a file by writing a temp file and renaming it, which fires two or three events for what feels like one save. Always guard against a missing filename and debounce rapid-fire events (see Example 3) instead of reacting to every single callback invocation.

Best Practices

  • Prefer fs.watch() or fsPromises.watch() for local, low-latency change detection; fall back to fs.watchFile() only for network drives or filesystems where native watching is unreliable.
  • Always attach an error listener to an fs.watch() return value — an unhandled watcher error can crash the process.
  • Debounce or throttle your handler; a single logical file save can trigger multiple change events.
  • Always call watcher.close() (or fs.unwatchFile(), or abort the AbortSignal) when you no longer need to watch, especially in tests and short-lived scripts, so the process can exit cleanly.
  • Don’t treat fs.watch() events as a perfectly reliable audit log — if you need certainty about what changed, re-read and compare the file’s actual contents or stats rather than trusting the event type alone.
  • For directories with many files (like a build’s dist folder), consider a well-maintained third-party watcher library (such as chokidar) if you need consistent recursive watching across all platforms and Node versions.
  • Wrap config or file re-parsing in try/catch so a half-written file during a save doesn’t crash your app.

Practice Exercises

  • Exercise 1: Write a script that watches a directory called uploads and logs every rename event with a timestamp. Guard against a null filename.
  • Exercise 2: Use fs.watchFile() to watch a file called status.txt with a 2-second interval, and print a message only when the file’s size actually increases (hint: compare curr.size and prev.size).
  • Exercise 3: Rewrite Exercise 1 using fsPromises.watch() with an AbortController that stops watching automatically after 10 seconds and logs "done watching".

Summary

  • fs.watch() uses native OS notifications (inotify, FSEvents, ReadDirectoryChangesW) for fast, low-overhead change detection, but behavior varies slightly by platform.
  • fs.watchFile() polls fs.stat() on a timer — slower and more resource-hungry, but perfectly consistent everywhere, including network filesystems.
  • fsPromises.watch() wraps the same native notifications as fs.watch() in an async iterable, so you can use for await...of and AbortController.
  • Watchers are persistent by default and keep the event loop alive — always close them when you’re done.
  • Always handle the error event, debounce rapid change events, and guard against a missing filename argument.