Watching Files for Changes
Sometimes you need to react to the filesystem instead of just reading it: rebuild on save, reload config, restart a server. Node offers two built-in watchers — fs.watch (event-based, fast) and fs.watchFile (polling, slow but reliable) — plus a stark reality: native file watching is notoriously inconsistent across platforms. This page shows how each works, where they lie to you, and why nearly every real tool reaches for chokidar.
fs.watch — the event-based watcher
import { watch } from 'node:fs'
const watcher = watch('config.json', (eventType, filename) => {
console.log(eventType, filename) // 'change' / 'rename', 'config.json'
})
// Stop watching when you're done:
// watcher.close()fs.watch taps the OS's native notification API (inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows). That makes it cheap and near-instant — but also the source of every quirk below, because each OS reports events differently.
Watching a directory (recursively)
import { watch } from 'node:fs'
// recursive: watch the whole tree (macOS & Windows only — see warning)
const watcher = watch('src', { recursive: true }, (event, filename) => {
if (filename) console.log(`${event}: ${filename}`)
})fs.watchFile — the polling watcher
import { watchFile, unwatchFile } from 'node:fs'
watchFile('data.log', { interval: 1000 }, (curr, prev) => {
if (curr.mtimeMs !== prev.mtimeMs) {
console.log('changed; new size:', curr.size)
}
})
// Later:
// unwatchFile('data.log')watch vs watchFile
|
| |
|---|---|---|
Mechanism | Native OS events | Polls with |
Latency | Near-instant | Up to one |
CPU cost | Near-zero (idle) | Constant (polling) |
Callback args |
|
|
Reliability | Platform-dependent, quirky | High, portable |
Network/odd FS | Often misses events | Works |
The platform inconsistencies (read this)
filenamemay benull— do not assume you always know which file changed.One logical change often emits 2+ events — you must debounce.
Editors that "atomic save" (write temp + rename) fire
rename, notchange, and can detach the watcher from the original inode.Deleting and recreating a watched file usually stops further events — re-establish the watch.
Debouncing duplicate events
Because one save can fire several events, coalesce them with a short timer so your handler runs once per burst:
Debounce rapid-fire watch events
import { watch } from 'node:fs'
function watchDebounced(path, onChange, delay = 100) {
let timer = null
return watch(path, (event, filename) => {
clearTimeout(timer)
timer = setTimeout(() => onChange(event, filename), delay)
})
}
watchDebounced('config.json', () => {
console.log('config settled — reloading once')
})config settled — reloading once
A practical reload-on-change loop
import { watch } from 'node:fs'
import { readFile } from 'node:fs/promises'
let config = JSON.parse(await readFile('config.json', 'utf8'))
let timer
watch('config.json', () => {
clearTimeout(timer)
timer = setTimeout(async () => {
try {
config = JSON.parse(await readFile('config.json', 'utf8'))
console.log('config reloaded', config)
} catch (err) {
console.error('reload failed (kept old config):', err.message)
}
}, 100)
})Use chokidar for anything real
chokidar — what most tools actually use
import chokidar from 'chokidar' // npm i chokidar
chokidar
.watch('src/**/*.js', { ignoreInitial: true })
.on('add', (p) => console.log('added', p))
.on('change', (p) => console.log('changed', p))
.on('unlink', (p) => console.log('removed', p))When to use what
Situation | Use |
|---|---|
Quick script, one local file |
|
Network drive / flaky FS |
|
Recursive tree on Linux |
|
Build tool / dev server |
|
Need clear add/change/unlink |
|