10LOC
Node.jsintermediate

A debounced fs.watch file watcher, no chokidar

Published May 14, 2027

import { watch } from "node:fs";

export const watchDebounced = (path: string, onChange: (eventType: string) => void, waitMs = 200) => {
  let timer: NodeJS.Timeout | undefined;

  const watcher = watch(path, (eventType) => {
    clearTimeout(timer);
    timer = setTimeout(() => onChange(eventType), waitMs);
  });

  return () => {
    clearTimeout(timer);
    watcher.close();
  };
};

What

watchDebounced(path, onChange) wraps fs.watch so onChange fires once, waitMs after the last filesystem event in a burst — instead of once per raw event, which for a single save can be several.

Why it matters

fs.watch's listener doesn't map cleanly to "the file changed once": a single save from most editors and build tools triggers multiple raw events in quick succession (a temp-file write-then-rename is a common cause), and Node's own docs are upfront that this is expected, platform-dependent behavior, not a bug to work around by other means. React to every raw event and a file watcher meant to trigger one rebuild fires several.

Debouncing is the standard fix, and it doesn't need a dependency: reset a timer on every event, and only actually call onChange once the timer finally elapses without being reset again. That's the core of what chokidar and similar libraries give you — plus cross-platform event normalization and glob support, which is real value for a large project, but pure overhead if a single fs.watch call debounced correctly already does what you need.

How it works

  • watch(path, callback) starts watching immediately; the raw callback fires on every filesystem event fs.watch decides to report, however many that turns out to be for one logical change.
  • Each raw event clearTimeouts whatever timer is currently pending and starts a new one — so a burst of five events in 50ms resets the clock four times and only the fifth one's timer is ever allowed to complete.
  • onChange only runs once that final timer actually elapses, waitMs after the last event in the burst — collapsing the whole burst into one call.
  • The function returns its own cleanup closure: calling it clears any pending timer and closes the underlying watcher, so a debounce that hasn't fired yet doesn't fire after the caller has already torn things down.

Gotchas

  • fs.watch's filename argument to the listener isn't guaranteed on every platform — don't build logic that depends on always having it; watch a specific file (rather than a directory) if you need to be sure what changed.
  • The recursive option works reliably on macOS and Windows but has inconsistent support on Linux — test on your actual deployment target before relying on it.
  • Watching a file that gets deleted and recreated (common with editors that save via a temp file + rename) can silently stop the watcher on some platforms, since the original inode is gone. Watching the containing directory instead is more robust if that matters.

Related

  • fs.watchFile() — a polling-based alternative with more consistent cross-platform behavior, at the cost of higher latency and CPU overhead than the OS-native fs.watch.
  • AbortControllerfs.watch() accepts a signal option directly, an alternative to this snippet's own cleanup closure for tying the watcher's lifetime to something else.