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 rawcallbackfires on every filesystem eventfs.watchdecides 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. onChangeonly runs once that final timer actually elapses,waitMsafter 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'sfilenameargument 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
recursiveoption 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-nativefs.watch.AbortController—fs.watch()accepts asignaloption directly, an alternative to this snippet's own cleanup closure for tying the watcher's lifetime to something else.