Question: Why did one save in my editor produce
rename,change, anotherrename, and sometimes no filename at all?Answer: Because
fs.watch()reports an operating-system event stream. It does not report your editor's concept of "the user saved one file."
Many editors save safely by writing a temporary file, syncing it, and replacing the original. From the file system's point of view, that can be several real operations. Linux, macOS, and Windows surface them through different mechanisms. Node normalizes part of the interface, not the meaning of every event.
A useful watcher therefore does not restart a process for every callback. It collects hints, filters noise, waits for a short quiet window, checks final state, and moves one restart controller toward the newest generation.
We will build that pipeline with current Node APIs.
The callback is a hint
The familiar API looks harmless:
import { watch } from 'node:fs';
watch('src', { recursive: true }, (eventType, filename) => {
console.log(eventType, filename);
});
The callback only promises two event types: rename and change. On most platforms, rename is also used when a filename appears or disappears. The filename can be null, even on platforms that usually provide it.
Node's own watch caveats are worth reading before writing retry logic:
- The API is not fully consistent across platforms.
- Network and virtualized file systems can be unreliable.
- On Linux and macOS, a deleted and recreated file may receive a new inode while the watcher stays attached to the old one.
- Windows has distinct behavior when the watched directory is moved or deleted.
So do not translate eventType === 'rename' into "a human renamed this file." Treat it as "directory membership or identity may have changed."
Prefer the async iterator for a controlled lifecycle
node:fs/promises exposes watch() as an async iterator. Current Node 24 also gives it an AbortSignal, recursive watching on supported platforms, ignore patterns, and a bounded event queue.
import { watch } from 'node:fs/promises';
export async function readWatchEvents(root, signal, onEvent) {
const events = watch(root, {
recursive: true,
signal,
overflow: 'throw',
ignore: [
'**/.git/**',
'**/node_modules/**',
'**/dist/**',
'**/*.log',
],
});
for await (const event of events) {
onEvent(event);
}
}
Why choose overflow: 'throw'? The async iterator's queue defaults to 2048 entries. Its default overflow policy warns and drops events. A development reloader can usually recover by forcing a full rebuild, but silently trusting an incomplete event sequence is a bad default. Throwing lets the outer controller log the loss and restart the watcher from known state.
These options are documented in fsPromises.watch(). If your tool supports older Node releases, feature-test or define a minimum version. Do not copy current options into a package that claims compatibility with runtimes that predate them.
Filter twice
Ignoring node_modules, .git, build output, and logs at the watcher boundary reduces load. You still need an application filter because "not ignored" is broader than "should restart my server."
import path from 'node:path';
const RESTART_EXTENSIONS = new Set([
'.cjs',
'.js',
'.json',
'.mjs',
'.ts',
'.tsx',
]);
function shouldRestart(relativeName) {
if (relativeName === null) return true;
const extension = path.extname(String(relativeName));
return RESTART_EXTENSIONS.has(extension);
}
Returning true for a null filename is conservative. We know something changed inside the watched scope but cannot identify it. Missing one source edit is worse than one extra restart.
For a large monorepo, that policy may be too expensive. A null filename can instead schedule a scoped rescan that compares modification metadata or a content manifest. The important part is to choose a fallback. filename.toString() without a null branch is not one.
Debounce is not quite the right word
Suppose the watcher sees this burst:
rename src/.server.js.tmp
change src/.server.js.tmp
rename src/server.js
change src/server.js
We are not merely delaying the last callback. We want to merge several hints into one decision while preserving which paths may have changed. That is coalescing.
export function createCoalescer({ settleMs, flush }) {
const changedPaths = new Set();
const eventTypes = new Set();
let lostFilename = false;
let timer = null;
function push({ eventType, filename }) {
eventTypes.add(eventType);
if (filename === null) {
lostFilename = true;
} else {
changedPaths.add(String(filename));
}
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const batch = {
changedPaths: [...changedPaths],
eventTypes: [...eventTypes],
lostFilename,
};
changedPaths.clear();
eventTypes.clear();
lostFilename = false;
flush(batch);
}, settleMs);
}
function close() {
clearTimeout(timer);
timer = null;
}
return { push, close };
}
The quiet window is a product decision, not a law. Around 50 to 150 ms often feels immediate for local development, but storage, transpilation, and editor behavior differ. Measure the event bursts on the systems you support.
A maximum wait is also useful under constant writes. Without one, a pure trailing-edge timer can postpone work forever. Production tooling usually flushes when either the stream goes quiet or a maximum batch age is reached.
One restart controller, many generations
The worst watcher bugs usually come after detection. A burst starts child B while child A is still exiting; another burst starts child C; an old exit handler clears the reference to the newest child.
Make restart order explicit.
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { setTimeout as delay } from 'node:timers/promises';
export class RestartController {
#child = null;
#closed = false;
#requestedGeneration = 0;
#appliedGeneration = 0;
#drainPromise = null;
constructor(command, args, options = {}) {
this.command = command;
this.args = args;
this.options = options;
}
requestRestart(reason) {
if (this.#closed) return;
this.#requestedGeneration += 1;
console.log(`[watch] restart requested: ${reason}`);
this.#ensureDrain();
}
#ensureDrain() {
if (this.#closed || this.#drainPromise) return;
this.#drainPromise = this.#drain().finally(() => {
this.#drainPromise = null;
if (
!this.#closed &&
this.#appliedGeneration < this.#requestedGeneration
) {
this.#ensureDrain();
}
});
}
async #drain() {
while (
!this.#closed &&
this.#appliedGeneration < this.#requestedGeneration
) {
const generation = this.#requestedGeneration;
await this.#stopChild();
if (this.#closed) return;
this.#child = spawn(this.command, this.args, {
stdio: 'inherit',
...this.options,
});
this.#appliedGeneration = generation;
}
}
async #stopChild() {
const child = this.#child;
if (!child) return;
this.#child = null;
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill('SIGTERM');
const exited = once(child, 'exit').then(() => true);
const timedOut = delay(2000, false);
if (!(await Promise.race([exited, timedOut]))) {
child.kill('SIGKILL');
await once(child, 'exit');
}
}
async close() {
this.#closed = true;
await this.#drainPromise;
await this.#stopChild();
}
}
The generation counter turns a noisy stream into "eventually run the newest requested generation." It does not start one process per event. At most one drain loop owns child replacement.
Clearing this.#child before waiting also matters. An exit event from the old process can no longer overwrite a newer child reference.
For a process tree, signals are platform-specific. SIGTERM and SIGKILL do not form a complete cross-platform process-group strategy. A reusable tool should put termination behind an adapter and test it on Windows as well as POSIX systems.
Wire the pipeline
Now connect detection, filtering, coalescing, and restart control:
import { watch } from 'node:fs/promises';
export async function runDevWatcher({ root, entry }) {
const abortController = new AbortController();
const runner = new RestartController(
process.execPath,
[entry],
{ cwd: root },
);
const coalescer = createCoalescer({
settleMs: 90,
flush(batch) {
const paths = batch.lostFilename
? 'unknown path'
: batch.changedPaths.join(', ');
runner.requestRestart(paths);
},
});
const stop = async () => {
abortController.abort();
coalescer.close();
await runner.close();
};
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
runner.requestRestart('initial start');
try {
for await (const event of watch(root, {
recursive: true,
signal: abortController.signal,
overflow: 'throw',
ignore: ['**/.git/**', '**/node_modules/**', '**/dist/**'],
})) {
if (shouldRestart(event.filename)) {
coalescer.push(event);
}
}
} catch (error) {
if (error.name !== 'AbortError') {
await runner.close();
throw error;
}
}
}
There is one deliberate gap: if the watch queue overflows, this function exits instead of pretending the stream is complete. A CLI wrapper can catch that failure, perform a full rebuild, and create a fresh watcher with backoff.
That recovery path should be visible in logs. "Watching" is a system state, and losing coverage is operationally important.
Test the ugly events
Happy-path tests that write one file once are not enough. Exercise the contracts that differ by platform and editor:
- A burst of four hints produces one restart.
- Two paths in one burst are both preserved.
- A null filename triggers the conservative fallback.
- A change during process shutdown produces one later generation, not overlapping children.
- Abort stops the watcher and the child.
- Queue overflow becomes a visible recovery event.
- Temporary files and build output are ignored.
- Delete and recreate of the watched target is detected or recovered by a parent-directory watch.
Run integration tests on each operating system you claim to support. Mocking fs.watch is useful for state-machine tests, but a mock cannot tell you what FSEvents or inotify actually emits.
The key design choice is simple: an OS event is evidence that state may have changed, not a command to restart immediately. Once the watcher treats events as hints, the rest of the architecture becomes calmer: batch the evidence, inspect final state, and let one controller own the transition.


Top comments (0)