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
- You call
fs.watch(path, listener). Node asks libuv to register a native OS watch on that path. - Your script keeps running synchronously past this call — watching is entirely non-blocking.
- Because a watcher is
persistentby default, it keeps a handle open that stops Node’s event loop from exiting, even if there’s nothing else left to do. - Later, the OS kernel detects a filesystem change and notifies libuv’s background watcher thread.
- Libuv queues the event onto the event loop’s poll phase, and your
listenercallback runs on the next loop iteration — not synchronously with the actual disk write, and not guaranteed to be instant. - For
fs.watchFile(), there’s no kernel notification at all: asetInterval-driven timer inside Node callsfs.stat()on the path every tick and diff’s the result against the previous stats snapshot. - The watcher keeps firing until you explicitly call
watcher.close()(forfs.watch),fs.unwatchFile()(forfs.watchFile), or abort the associatedAbortSignal(forfsPromises.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()orfsPromises.watch()for local, low-latency change detection; fall back tofs.watchFile()only for network drives or filesystems where native watching is unreliable. - Always attach an
errorlistener to anfs.watch()return value — an unhandled watcher error can crash the process. - Debounce or throttle your handler; a single logical file save can trigger multiple
changeevents. - Always call
watcher.close()(orfs.unwatchFile(), or abort theAbortSignal) 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
distfolder), consider a well-maintained third-party watcher library (such aschokidar) if you need consistent recursive watching across all platforms and Node versions. - Wrap config or file re-parsing in
try/catchso a half-written file during a save doesn’t crash your app.
Practice Exercises
- Exercise 1: Write a script that watches a directory called
uploadsand logs everyrenameevent with a timestamp. Guard against anullfilename. - Exercise 2: Use
fs.watchFile()to watch a file calledstatus.txtwith a 2-second interval, and print a message only when the file’s size actually increases (hint: comparecurr.sizeandprev.size). - Exercise 3: Rewrite Exercise 1 using
fsPromises.watch()with anAbortControllerthat 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()pollsfs.stat()on a timer — slower and more resource-hungry, but perfectly consistent everywhere, including network filesystems.fsPromises.watch()wraps the same native notifications asfs.watch()in an async iterable, so you can usefor await...ofandAbortController.- Watchers are
persistentby default and keep the event loop alive — always close them when you’re done. - Always handle the
errorevent, debounce rapid change events, and guard against a missingfilenameargument.
