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
filenameargument can benullon 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
watchFilecompares 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
| Aspect | fs.watch | fs.watchFile |
|---|---|---|
| Mechanism | Native OS notifications | Periodic stat polling |
| Latency | Near-instant | Up to one interval |
| CPU cost | Low (idle until event) | Continuous polling |
| Callback data | eventType, filename | current and previous Stats |
| Recursive support | Yes (with recursive: true) | No |
| Network/odd filesystems | Often unreliable | Works reliably |
| Stop watching | watcher.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 isnull. - Rename ambiguity. A
'rename'event does not tell you whether the file was added or removed — you muststatto 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.watchfor local file systems; fall back tofs.watchFileonly 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 callfs.statto determine whether a file was added or deleted. - Never assume
filenameis present; guard againstnullbefore using it. - Verify recursive support on your target platform and Node.js version before depending on
{ recursive: true }. - Call
watcher.close()orunwatchFile()on shutdown to release OS handles and stop polling. - Reach for chokidar when you need cross-platform reliability and clean add/change/unlink semantics.