Directories, Glob & Watch

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-03, and every example below was run on all three lines unless the text says otherwise (on macOS; the Linux and Windows behaviour quoted comes from the documentation, not from a run). Files are the subject of File System; this page is about the other kind of entry, the directory: listing it, creating and removing trees, copying them, making scratch space, selecting files by pattern, and being told when something inside changes. All of it lives in the node:fs module, which is Stability 2 (Stable), and in its promise twin node:fs/promises. The individual APIs differ slightly, so the table below says which is which. The examples use promises throughout; every function also exists as a callback form (fs.readdir(path, cb)) and a synchronous form (fs.readdirSync(path)), and the sync form is acceptable only at start-up, in scripts and in tools, never in a request handler (see Don’t Block the Event Loop). The running scenario is the Bookshelf application from Introduction & Architecture, which keeps CSV imports and cover images in folders.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

readdir() with recursive and withFileTypes

Stable

withFileTypes since v10.10.0; recursive since v20.1.0 and v18.17.0, so present on every supported line.

fs.Dirent and dirent.parentPath

Stable

parentPath added in v21.4.0, v20.12.0 and v18.20.0, and marked stable in v24.0.0 and v22.17.0.

opendir() and fs.Dir

Stable

recursive option since v20.1.0 and v18.17.0.

mkdir() with recursive

Stable

With recursive: true the result is the first directory path created, or undefined when nothing had to be created.

rm()

Stable

Added in v14.14.0; replaces the removed recursive mode of rmdir().

rmdir()

Stable

The recursive option was removed in v25.0.0; on Node.js 24 and 22 it still works but prints deprecation warning DEP0147.

cp()

Stable

No longer experimental since v22.3.0.

mkdtemp()

Stable

prefix accepts a buffer or a URL since v20.6.0 and v18.19.0.

mkdtempDisposable() and mkdtempDisposableSync()

Stable

Added in v24.4.0; absent on Node.js 22.

glob(), globSync()

Stable

Added in v22.0.0; stable since v24.0.0 and v22.17.0; exclude accepts glob patterns since v23.7.0 and v22.14.0; followSymlinks since v26.1.0; maxDepth since v26.9.0.

watch()

Stable

Recursive watching on Linux since v19.1.0; throwIfNoEntry since v26.1.0; the ignore option is documented without a version note.

watchFile(), unwatchFile()

Stable

Polling; the default interval is 5007 ms.

Listing a directory: readdir

readdir() returns the names of the entries of one directory, without . and ... The order is whatever the operating system returns, so sort when the order matters. Two options change what you get back. withFileTypes: true returns fs.Dirent objects instead of strings, and recursive: true descends into every subdirectory and returns paths relative to the directory you asked about.

Names, recursive names and Dirent objects
import { mkdir, writeFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';

const data = 'data';
await mkdir(join(data, 'covers', 'small'), { recursive: true });
for (const f of ['books.csv', 'authors.csv', 'notes.txt', 'covers/dune.png', 'covers/small/dune.png']) {
  await writeFile(join(data, f), f);
}

console.log(await readdir(data));
console.log((await readdir(data, { recursive: true })).sort());

for (const entry of await readdir(data, { withFileTypes: true, recursive: true })) {
  const kind = entry.isDirectory() ? 'dir ' : 'file';
  console.log(kind, join(entry.parentPath, entry.name));
}
Output (the order of the last loop is the operating system’s; sorted here)
[ 'authors.csv', 'books.csv', 'covers', 'notes.txt' ]
[
  'authors.csv',
  'books.csv',
  'covers',
  'covers/dune.png',
  'covers/small',
  'covers/small/dune.png',
  'notes.txt'
]
file data/authors.csv
file data/books.csv
dir  data/covers
file data/covers/dune.png
dir  data/covers/small
file data/covers/small/dune.png
file data/notes.txt

Four details matter in practice:

  • Dirent is a cheap file type. A Dirent carries the name and the type the operating system reported while reading the directory, with isFile(), isDirectory(), isSymbolicLink() and the device, FIFO and socket tests. Using it avoids one stat() call per entry. The documentation warns that some file systems report a type that differs from what lstat() returns, and that Node.js calls lstat() itself only when the reported type is unknown; call lstat() when you need an exact answer.

  • dirent.parentPath is the directory that contains the entry. With a recursive listing it differs per entry, which is why the example joins parentPath and name. Older code and older articles use dirent.path, which was removed in Node.js 24.0 (deprecation DEP0178) because it was inconsistent across APIs; use parentPath.

  • Recursive listings follow symbolic links to directories. Observed on Node.js 26, 24 and 22: a link link pointing at a makes readdir('.', { recursive: true }) return link/b/c/t.js as well as a/b/c/t.js. The documentation does not describe this, so keep symlink loops out of trees you list recursively, or walk the tree yourself with opendir() and decide per entry.

  • Encoding. encoding: 'buffer' returns names as Buffer objects, the right choice when file names are not valid UTF-8.

Streaming a large directory with opendir

readdir() builds the whole array in memory before you see the first name. For a directory with millions of entries, opendir() returns an fs.Dir, an async iterable that reads the directory in chunks (bufferSize, default 32 entries). Leaving the for await loop closes the handle; if you break out of a manual dir.read() loop, call dir.close() yourself.

Walking a tree lazily with opendir
import { opendir } from 'node:fs/promises';
import { join } from 'node:path';

let count = 0;
for await (const entry of await opendir('data', { recursive: true })) {
  if (entry.isFile() && entry.name.endsWith('.png')) {
    count += 1;
    console.log(join(entry.parentPath, entry.name));
  }
}
console.log(count, 'png files');

Creating directories: mkdir and the equivalent of mkdir -p

Without options mkdir() creates one directory and fails with ENOENT when the parent is missing and with EEXIST when the directory is already there. With recursive: true it behaves like mkdir -p: it creates every missing parent, and an existing directory is not an error. The promise resolves to the first directory path that was actually created, or undefined when everything already existed, which tells you whether you did the work.

mkdir -p and its return value
import { mkdir } from 'node:fs/promises';

const first = await mkdir('data/covers/small', { recursive: true });
console.log(first); // the first directory created, for example data/covers; undefined if all existed
console.log(await mkdir('data/covers/small', { recursive: true })); // undefined

try {
  await mkdir('data', { recursive: false });
} catch (err) {
  console.log(err.code); // EEXIST
}

The mode option sets the permission bits (default 0o777, reduced by the process umask) and is not supported on Windows. Prefer mkdir({ recursive: true }) to calling existsSync() first: the check and the creation are two steps, so another process can win the race in between.

Removing: rm and the end of recursive rmdir

rm() is the modern removal call, modelled on the POSIX rm utility. It removes files and directories; with { recursive: true, force: true } it is the equivalent of rm -rf. force: true swallows the error for a path that does not exist, and in recursive mode maxRetries and retryDelay (default 100 ms) retry on EBUSY, EMFILE, ENFILE, ENOTEMPTY and EPERM, which helps on Windows where antivirus software or an open handle can briefly pin a file.

rmdir() removes only an empty directory. Its recursive option was deprecated for years in favour of rm() and was removed in Node.js 25.0.0.

rm, rm -rf and the rmdir trap
import { rm, rmdir } from 'node:fs/promises';

try {
  await rm('backup'); // a directory without recursive
} catch (err) {
  console.log(err.code); // ERR_FS_EISDIR
}

await rm('backup', { recursive: true });                // like rm -r; ENOENT if missing
await rm('backup', { recursive: true, force: true });   // like rm -rf; silent if missing

try {
  await rmdir('data');   // rmdir only removes empty directories
} catch (err) {
  console.log(err.code); // ENOTEMPTY
}

Never pass a path built from user input to a recursive rm() without resolving it and checking that it stays inside the directory you intended; see OS, Path & URL for path.resolve() and path.relative().

Copying trees: cp

cp() copies a file or a whole directory structure. For directories you must pass recursive: true; without it Node.js rejects the call with ERR_FS_EISDIR (observed on every line). Copying a directory behaves like cp dir1/ dir2/, so globs are not expanded.

Table 2. Option defaults of cp()
Option Meaning Default

recursive

Copy directories recursively.

false

force

Overwrite existing files; when false, existing destinations are skipped silently unless errorOnExist is set.

true

errorOnExist

With force: false, fail when the destination exists.

false

filter

Function called with src and dest, returns (or fulfils with) true to copy. Returning false for a directory skips its whole content.

none

dereference

Copy what a symlink points at instead of the link.

false

verbatimSymlinks

Skip path resolution for symlink targets.

false

preserveTimestamps

Keep the timestamps of the source.

false

mode

Modifiers as in copyFile(), for example copy-on-write.

0

Copy a tree without the images
import { cp, readdir } from 'node:fs/promises';

await cp('data', 'backup', {
  recursive: true,
  filter: (src) => !src.endsWith('.png'),
});
console.log((await readdir('backup', { recursive: true })).sort());

try {
  await cp('data', 'backup2'); // no recursive
} catch (err) {
  console.log(err.code); // ERR_FS_EISDIR
}
Output
[ 'authors.csv', 'books.csv', 'covers', 'covers/small', 'notes.txt' ]
ERR_FS_EISDIR

Scratch space: mkdtemp and mkdtempDisposable

mkdtemp(prefix) creates a directory with a unique name made of your prefix plus six random characters, and returns its path. The prefix must include the directory in which to create it, so build it with os.tmpdir(); if you want a directory inside another one, the prefix has to end with a path separator. Avoid trailing X characters in the prefix, because the BSDs may replace them and return more than six random characters. The random suffix makes the name unpredictable, which is safer for intermediate files than a fixed name such as /tmp/report.csv.

Cleaning up is your job. The classic pattern is try/finally with rm(). Since Node.js 24.4 mkdtempDisposable() returns an object with a path, a remove() method and an [Symbol.asyncDispose], so that await using removes the directory and its contents when the scope ends.

Temporary directory with try/finally (Node.js 22, 24 and 26)
import { mkdtemp, rm, writeFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
try {
  await writeFile(join(dir, 'import.csv'), 'title,author\n');
  console.log(await readdir(dir)); // [ 'import.csv' ]
} finally {
  await rm(dir, { recursive: true, force: true });
}
Temporary directory with await using (Node.js 24.4 and later)
import { mkdtempDisposable, writeFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

{
  await using dir = await mkdtempDisposable(join(tmpdir(), 'bookshelf-'));
  await writeFile(join(dir.path, 'import.csv'), 'title,author\n');
  console.log(await readdir(dir.path)); // [ 'import.csv' ]
} // the directory and its content are removed here

On Node.js 22 the second example does not run: mkdtempDisposable is not exported and the await using syntax is a syntax error there (observed on 22.23.3), so use the first form on that line. The pattern is handy in tests, to get an isolated folder per test.

Selecting files by pattern: fs.glob

fs.glob() (callback), fs.globSync() and fsPromises.glob() expand a glob pattern, or an array of patterns, against the file system. The promise version returns an async iterator, the sync one an array. They were added in Node.js 22.0 and became Stable in 24.0 and 22.17, so on every supported line you can use them without a flag, and without the glob or fast-glob packages for the common cases.

Table 3. Options of glob()
Option Meaning Default

cwd

Directory the pattern is relative to; a path or a URL (URL since v24.1.0 and v22.17.0). Results are relative to it.

process.cwd()

exclude

A function that returns true to drop an entry, or an array of glob patterns to exclude. Negation patterns such as !foo.js are not supported.

none

withFileTypes

Yield Dirent objects instead of strings.

false

followSymlinks

Follow links to directories while expanding ** (v26.1.0 and later); link cycles are not traversed.

false

maxDepth

Maximum number of directory levels, cwd being depth 0 (v26.9.0 and later).

Infinity

Globbing with exclusions
import { glob, globSync } from 'node:fs';
import { glob as globAsync } from 'node:fs/promises';

// async iterator
for await (const path of globAsync('data/**/*.png')) {
  console.log(path);
}

// arrays of patterns, exclude patterns, a base directory
const csvAndText = await Array.fromAsync(
  globAsync(['data/*.csv', 'data/*.txt'], { exclude: ['**/authors.*'] }),
);
console.log(csvAndText); // [ 'data/books.csv', 'data/notes.txt' ]

// synchronous, brace expansion
console.log(globSync('data/{books,notes}.*').sort());

// callback style
glob('data/*.csv', (err, matches) => console.log(err, matches.sort()));
Output
data/covers/dune.png
data/covers/small/dune.png
[ 'data/books.csv', 'data/notes.txt' ]
[ 'data/books.csv', 'data/notes.txt' ]
null [ 'data/authors.csv', 'data/books.csv' ]

Some behaviour I observed rather than read in the documentation, so verify it on the line you target:

  • On Node.js 24 and 22 the maxDepth option is accepted and silently ignored, so */.js with maxDepth: 1 still returns every depth; on Node.js 26.9 and later it limits the walk.

  • With followSymlinks: true on Node.js 26, a pattern reaches a linked directory under its link name as well as its real name, so the same file can be returned twice. On 24 and 22 the option is ignored. A pattern that names the link explicitly (link//*.js) descends into it on every line.

  • An exclude function that returns true for a directory prunes the traversal below it; with withFileTypes: true the pruned directory itself can still be yielded.

If you need negation or full minimatch semantics, a package is still the answer; for "all the *.csv files under `imports/`" the built-in is enough.

Watching for changes: fs.watch

fs.watch(path, options, listener) asks the operating system to notify Node.js when a file or directory changes, so there is no polling and no wasted CPU while nothing happens. fsPromises.watch() exposes the same notifications as an async iterator of { eventType, filename } objects, which composes well with AbortSignal (see Async Context & Cancellation). eventType is 'rename' or 'change'; on most platforms 'rename' means a name appeared or disappeared in the directory, while 'change' means content changed. The listener of the callback form is not the 'change' event of the returned fs.FSWatcher, even though the names are alike.

Table 4. Options of watch()
Option Meaning Default

persistent

Keep the process alive while the watcher is active; watcher.unref() has the same effect later.

true

recursive

Watch subdirectories too, only on platforms that support it (the documentation limits it to supported platforms; Linux, AIX and IBM i gained it in v19.1.0).

false

encoding

Encoding of the file name passed to the listener.

'utf8'

signal

An AbortSignal that closes the watcher.

none

throwIfNoEntry

Throw when the path does not exist (callback API, v26.1.0 and later).

true

ignore

Glob string, RegExp, function or array of them to skip events; documented without a version note.

none

maxQueue, overflow

Promise API only: how many events are buffered between iterations of the iterator (default 2048) and whether overflow is 'ignore' (drop with a warning) or 'error'.

2048, 'ignore'

Watching a folder with the promise API, with cancellation
import { watch } from 'node:fs/promises';

const ac = new AbortController();
setTimeout(() => ac.abort(), 10_000);

try {
  for await (const { eventType, filename } of watch('data', { recursive: true, signal: ac.signal })) {
    console.log(eventType, filename); // filename may be null
  }
} catch (err) {
  if (err.name !== 'AbortError') throw err;
}

Platform caveats

The documentation is blunt: the fs.watch API is not 100% consistent across platforms and is unavailable in some situations. The practical rules are these.

Table 5. What differs between platforms
Topic Behaviour

Backends

Linux uses inotify, BSD kqueue, macOS kqueue for files and FSEvents for directories, SunOS event ports, Windows ReadDirectoryChangesW, AIX AHAFS (which must be enabled); it is not supported on IBM i.

Network and virtual file systems

Watching can be unreliable, and in some cases impossible, on NFS, SMB and on host file systems shared with virtualization software such as Vagrant or Docker. Bind mounts in a container are the usual victim: see Storage: Volumes and Bind Mounts and poll instead.

Missing filename

The name is supported only on Linux, macOS, Windows and AIX, and even there it is not guaranteed, so always handle null.

Inodes

On Linux and macOS the watcher follows the inode. If the watched path is deleted and recreated, the watcher reports the delete and keeps watching the old inode; it does not see the new file. Editors that save by writing a new file and renaming it over the old one trigger exactly this.

Windows

No events are emitted if the watched directory is moved or renamed, and deleting it reports an EPERM error. Because Windows watches the directory rather than the file, a file can be substituted and events then refer to the new file with the same name.

Security

The API gives no protection against malicious file system changes, so do not treat an event as proof of what is now on disk.

Event counts

One save can produce several events, and several writes may be coalesced into one. On macOS, three writes and a rename inside a recursively watched folder produced rename events for the new names and a single change for the folder itself, but no per-file change events (observed); do not count events, re-read state.

Because of the last row, an event is a hint: debounce it, then look at the file system. The next example is how the Bookshelf import folder can wait until a CSV file has been quiet for a moment before processing it.

Debounced folder watcher for new CSV files
import { watch } from 'node:fs/promises';
import { join, extname } from 'node:path';

export async function watchIncoming(dir, onCsv, { signal, quietMs = 150 } = {}) {
  const pending = new Map(); // filename -> timer
  try {
    for await (const { filename } of watch(dir, { signal })) {
      if (!filename || extname(filename) !== '.csv') continue; // filename can be null
      clearTimeout(pending.get(filename));
      pending.set(filename, setTimeout(() => {
        pending.delete(filename);
        onCsv(join(dir, filename));
      }, quietMs));
    }
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  } finally {
    for (const timer of pending.values()) clearTimeout(timer);
  }
}

Running it against a temporary folder, writing books.csv twice and readme.txt once, printed a single ready: books.csv line on Node.js 26 and 22, then stopped when the signal aborted.

The callback form is the same idea with fs.watch('dir', { ignore: '*/.tmp' }, (eventType, filename) ⇒ { …​ }), returning an fs.FSWatcher that you stop with watcher.close(). In my runs the ignore option filtered events on Node.js 26 and 24 and was silently not applied on 22.23.3, so on the Maintenance line filter by file name inside the listener.

fs.watchFile: polling as the fallback

fs.watchFile(path, options, listener) does not use the operating system’s notifications. It calls stat() on the file every interval milliseconds (default 5007) and invokes the listener with the current and the previous fs.Stats. That is slower and less reliable than fs.watch, and the documentation says to prefer watch() when possible; its value is that it works wherever stat() works, including network file systems and bind mounts where inotify events never arrive. Stop it with fs.unwatchFile(path). The listener fires on access as well as modification, so compare curr.mtimeMs with prev.mtimeMs to detect a real change.

Polling one file and detecting a modification
import { watchFile, unwatchFile } from 'node:fs';

watchFile('data/books.csv', { interval: 1000 }, (curr, prev) => {
  if (curr.mtimeMs !== prev.mtimeMs) {
    console.log(`books.csv changed: ${prev.size} -> ${curr.size} bytes`);
  }
});

// later
unwatchFile('data/books.csv');

When the file does not exist, the listener is called once with every field zeroed (dates at the Unix epoch) and again when the file is created; I observed [ 0, 0 ] for size and mtimeMs on all three lines.

Table 6. fs.watch compared with fs.watchFile
Aspect fs.watch fs.watchFile

Mechanism

Operating system notifications (inotify, kqueue, FSEvents, ReadDirectoryChangesW)

stat() polling every interval ms

Watches

A file or a directory, optionally recursive

A single file

Cost

Almost none while idle

One stat() per file per interval, continuously

Detail of the change

Event type and (maybe) file name

Full before and after Stats

Reliability

Platform dependent, see the caveats

Works anywhere stat() works, but can miss changes between polls

Network and container mounts

Often silent

The workable fallback

Watching your own code: node --watch

When the goal is "restart my program when its source changes", you do not need fs.watch at all. node --watch app.js restarts the process when the entry point or any required or imported module changes, and --watch-path chooses explicit paths instead (and switches off watching of imported modules). Watch mode is Stable since Node.js 22.0 and 20.13, and it can also watch tests (node --test --watch). See Running Scripts, the CLI & the REPL. Under the hood it relies on the same operating system facilities and so shares their caveats.

What changed recently

Table 7. Changes relevant to directories, glob and watch
Version Change

v26.9.0

glob() gains maxDepth.

v26.1.0

glob() gains followSymlinks; fs.watch() gains throwIfNoEntry.

v25.0.0

The recursive option of rmdir() is removed; use rm().

v24.4.0

mkdtempDisposable() and mkdtempDisposableSync() are added.

v24.0.0 and v22.17.0

fs.glob() and dirent.parentPath are marked stable; cwd accepts a URL from v24.1.0.

v23.7.0 and v22.14.0

glob() exclude accepts an array of glob patterns.

v22.3.0

fs.cp() is no longer experimental.

v22.0.0 and v20.13.0

node --watch is stable.

v19.1.0

fs.watch() supports recursive on Linux, AIX and IBM i.

v20.1.0 and v18.17.0

recursive is added to readdir() and opendir().

Where the books are outdated

Neither of the two reference books deals with these APIs: a search of their text finds no mention of readdir, fs.glob, fs.watch or watchFile, so the details above come from the official documentation. They do touch on watching your own code. Node.js Projects (Jonathan Wexler) teaches installing and running the third-party nodemon package for restarts; the built-in node --watch, which both that book and Efficient Node.js (Samer Buna) also mention, covers that need without a dependency. Tutorials of the same generation often reach for rimraf, mkdirp, glob and chokidar: rm() with recursive, mkdir() with recursive and fs.glob() replace the first three, and chokidar is still reasonable when you need consistent cross-platform recursive events.

References