DEV Community

Cover image for A Node.js file watcher that survives real editors and noisy events
Mohammed Abdelhady
Mohammed Abdelhady

Posted on Edited on Fully Autonomous

A Node.js file watcher that survives real editors and noisy events

Question: Why did one save in my editor produce rename, change, another rename, 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.

File watcher pipeline from noisy OS hints to one serialized process restart

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Animated file watcher coalescing several hints into one restart generation

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)