fsPromises.watch()

Yields filesystem change events for a file or directory as an async iterator.

Signature
fsPromises.watch(filename[, options])
  • Added in Node.js v15.0.0

Parameters

ParameterTypeDescription
$filenamestring | Buffer | URLFile or directory to watch.
$optionsoptionalObject | stringOptions object, or a string that is taken as the encoding.
$options.encodingoptionalstring= 'utf8'Encoding for the yielded filename; 'buffer' yields Buffers.
$options.persistentoptionalboolean= trueIndicates whether the process should continue to run as long as files are being watched.
$options.recursiveoptionalboolean= falseIndicates whether all subdirectories should be watched, or only the current directory. Applies when a directory is specified, and only on supported platforms.
$options.signaloptionalAbortSignal= undefinedAn AbortSignal used to stop watching the file.

Return value

AsyncIterator<{ eventType: string, filename: string | Buffer }>Returns an AsyncIterator yielding objects with eventType ('rename' or 'change') and filename, the name of the file that changed.

Errors

Rejects with ENOENT if the path does not exist when watching starts, ERR_FEATURE_UNAVAILABLE_ON_PLATFORM for recursive: true on platforms that lack it, and aborts with an AbortError when the supplied signal fires.

Changelog

  • PHP v22.9.0Broadened recursive watching support.
  • PHP v19.4.0Added support for recursive watching on Linux, which previously threw ERR_FEATURE_UNAVAILABLE_ON_PLATFORM.
  • PHP v16.7.0Added a stable option, which ensures consistent sorting of returned results.

fsPromises.watch() is the async-iterator form of fs.watch(). Each yielded object carries an eventType — 'rename' when an entry appears, disappears or is renamed, 'change' when its contents or metadata change — and the filename involved.

The loop never ends on its own

A for await over a watcher blocks forever waiting for the next event. Pass an AbortSignal and abort it to break out; the loop then throws an AbortError, which you catch. There is no "done" state otherwise, and with persistent: true the watcher alone keeps the process alive.

What trips people up

  • Events are not one-per-save. Editors write, rename and touch, so a single save can produce two or three events. Debounce before acting.
  • filename can be null on some platforms, and is a path relative to the watched directory — never absolute.
  • recursive: true is supported on macOS and Windows, and on Linux from v19.4.0; older kernels and network filesystems can still fail.
  • Watching a file rather than its directory breaks when an editor replaces the file via rename — the watcher is left holding the old inode. Watch the directory instead.

Example

Example · javascript
import fs from 'node:fs/promises';

await fs.mkdir('src', { recursive: true });

// Abort after 10 seconds so the loop can finish.
const ac = new AbortController();
setTimeout(() => ac.abort(), 10_000);

try {
  for await (const event of fs.watch('src', { recursive: true, signal: ac.signal })) {
    console.log(event.eventType, event.filename);
  }
} catch (err) {
  if (err.name !== 'AbortError') throw err;
  console.log('stopped watching');
}

When to use it

  • Rebuild assets when a source file under src/ changes.
  • Reload configuration without restarting a long-running process.
  • Trigger a test run when any file in a watched tree is touched.
  • Wait for a specific drop file to appear in an inbox directory.

Frequently asked questions

How do I stop an fsPromises.watch loop?

Pass an AbortSignal in the options and call controller.abort(). The for await loop then throws an AbortError, which you catch — there is no other way to end it, since the iterator never completes on its own.

Why does fsPromises.watch fire twice for one file save?

Most editors save by writing a temp file and renaming it over the target, which produces separate 'change' and 'rename' events. Debounce events over a short window before reacting.

Why is filename null in the watch event?

Not every platform reports which entry changed, particularly on some Linux and network filesystem setups. Guard with if (filename) and fall back to rescanning the directory when it is null.

More examples

Debounce so one save triggers one rebuild

Saving one file in an editor typically emits a 'change' plus a 'rename', and sometimes a third event for a temp file. The 100 ms window collapses those into a single rebuild line listing the affected names.

Example · javascript
import fs from 'node:fs/promises';

const ac = new AbortController();
process.on('SIGINT', () => ac.abort());

let timer = null;
const pending = new Set();

function schedule(name) {
  pending.add(name);
  clearTimeout(timer);
  timer = setTimeout(() => {
    console.log('rebuild for:', [...pending].sort());
    pending.clear();
  }, 100);
}

try {
  for await (const { eventType, filename } of fs.watch('src', { signal: ac.signal })) {
    if (filename) schedule(filename);
    else console.log('event with no filename:', eventType);
  }
} catch (err) {
  if (err.name !== 'AbortError') throw err;
} finally {
  clearTimeout(timer);
  console.log('watcher stopped');
}

Wait for one file to appear, then stop

Returning from inside the for await closes the watcher, so this resolves as soon as ready.flag lands. The AbortController is what turns an otherwise infinite loop into a call with a timeout — it prints arrived: false after five quiet seconds.

Example · javascript
import fs from 'node:fs/promises';

async function waitFor(dir, target, timeoutMs) {
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), timeoutMs);
  try {
    for await (const { filename } of fs.watch(dir, { signal: ac.signal })) {
      if (filename === target) return true;
    }
  } catch (err) {
    if (err.name === 'AbortError') return false;
    throw err;
  } finally {
    clearTimeout(timer);
  }
  return false;
}

await fs.mkdir('inbox', { recursive: true });
console.log('arrived:', await waitFor('inbox', 'ready.flag', 5_000));

Watch the directory, not the file

Watching the containing directory keeps working when an editor writes config.json.tmp and renames it over the original, which silently kills a watcher attached to the file itself. The inner try absorbs the read of a half-written file, which is common between the two events.

Example · javascript
import fs from 'node:fs/promises';
import path from 'node:path';

const target = 'config.json';
const ac = new AbortController();
setTimeout(() => ac.abort(), 30_000);

try {
  // Watching '.' survives editors that replace config.json by rename.
  for await (const { eventType, filename } of fs.watch('.', { signal: ac.signal })) {
    if (filename !== target) continue;
    console.log(eventType, 'on', filename);
    try {
      const cfg = JSON.parse(await fs.readFile(path.join('.', target), 'utf8'));
      console.log('reloaded port:', cfg.port);
    } catch (err) {
      console.log('skipped a partial write:', err.code ?? err.name);
    }
  }
} catch (err) {
  if (err.name !== 'AbortError') throw err;
}

Discussion

  • Be the first to comment on this lesson.