NodeJSWatching Files for Changes

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

JS
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)

JS
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}`)
})
`recursive: true` is NOT supported on Linux
The `recursive` option works on macOS and Windows but is **silently unsupported on Linux** (and historically threw or no-op'd). To watch a tree on Linux with built-ins you must walk the directory and set up a watcher per subdirectory yourself — exactly the kind of tedium that pushes people to `chokidar`, which handles recursion uniformly everywhere.
fs.watchFile — the polling watcher

JS
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')
`watchFile` polls — it `stat`s on a timer
Instead of OS events, `watchFile` calls `stat` every `interval` ms and compares the old and new `Stats` objects. That makes it slower and less responsive (and a constant low-level CPU cost), but far more **reliable and portable** — it works on network drives and exotic filesystems where native events don't fire. Use it as a fallback when `fs.watch` proves unreliable for your target.
watch vs watchFile

fs.watch

fs.watchFile

Mechanism

Native OS events

Polls with stat

Latency

Near-instant

Up to one interval

CPU cost

Near-zero (idle)

Constant (polling)

Callback args

(eventType, filename)

(curr, prev) Stats

Reliability

Platform-dependent, quirky

High, portable

Network/odd FS

Often misses events

Works

The platform inconsistencies (read this)
`fs.watch` behaves differently on every OS
The Node docs themselves warn that `fs.watch` is "not 100% consistent across platforms." In practice: `filename` is **not always provided** (often `null` on macOS/Linux for certain events); a single save frequently fires **multiple events**; the `eventType` is only ever `'rename'` or `'change'` and the mapping is fuzzy (many editors trigger `'rename'` for an ordinary save because they write-to-temp-then-rename); and watchers can break when the watched file is replaced (the underlying inode changes). Never build precise logic on the raw event stream.
  • filename may be null — 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, not change, 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

JS
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

JS
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)
})
Always guard the re-read
When an editor saves, there's a brief window where the file may be empty or half-written — a naive `JSON.parse` will throw. Debounce (so you read after the write settles) **and** wrap the parse in try/catch so a transient bad read doesn't crash the process or wipe a good config.
Use chokidar for anything real
Production tools don't use raw `fs.watch`
Webpack, Vite, nodemon, and most build tooling rely on **`chokidar`** — a library that wraps `fs.watch`/`fs.watchFile`, normalizes the cross-platform mess, optionally falls back to polling, debounces, and adds glob support and clear `add`/`change`/`unlink` events. If your watching is more than a toy, install it rather than re-deriving all the workarounds above.

chokidar — what most tools actually use

JS
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

fs.watch + debounce

Network drive / flaky FS

fs.watchFile (polling)

Recursive tree on Linux

chokidar (or per-dir fs.watch)

Build tool / dev server

chokidar

Need clear add/change/unlink

chokidar

Next
That completes the filesystem. Next, the binary foundation beneath all of it: [Buffers](/nodejs/buffers).