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
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
Stable |
|
|
Stable |
|
|
Stable |
|
|
Stable |
With |
|
Stable |
Added in v14.14.0; replaces the removed recursive mode of |
|
Stable |
The |
|
Stable |
No longer experimental since v22.3.0. |
|
Stable |
|
|
Stable |
Added in v24.4.0; absent on Node.js 22. |
|
Stable |
Added in v22.0.0; stable since v24.0.0 and v22.17.0; |
|
Stable |
Recursive watching on Linux since v19.1.0; |
|
Stable |
Polling; the default interval is 5007 ms. |
Source: Node.js API: File system
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.
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));
}
[ '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
Direntcarries the name and the type the operating system reported while reading the directory, withisFile(),isDirectory(),isSymbolicLink()and the device, FIFO and socket tests. Using it avoids onestat()call per entry. The documentation warns that some file systems report a type that differs from whatlstat()returns, and that Node.js callslstat()itself only when the reported type is unknown; calllstat()when you need an exact answer. -
dirent.parentPathis the directory that contains the entry. With a recursive listing it differs per entry, which is why the example joinsparentPathandname. Older code and older articles usedirent.path, which was removed in Node.js 24.0 (deprecation DEP0178) because it was inconsistent across APIs; useparentPath. -
Recursive listings follow symbolic links to directories. Observed on Node.js 26, 24 and 22: a link
linkpointing atamakesreaddir('.', { recursive: true })returnlink/b/c/t.jsas well asa/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 withopendir()and decide per entry. -
Encoding.
encoding: 'buffer'returns names asBufferobjects, 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.
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');
Source: Node.js API: File system
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.
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
}
Source: Node.js API: File system
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.
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
}
Source: Node.js API: File system
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.
| Option | Meaning | Default |
|---|---|---|
|
Copy directories recursively. |
|
|
Overwrite existing files; when |
|
|
With |
|
|
Function called with |
none |
|
Copy what a symlink points at instead of the link. |
|
|
Skip path resolution for symlink targets. |
|
|
Keep the timestamps of the source. |
|
|
Modifiers as in |
|
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
}
[ 'authors.csv', 'books.csv', 'covers', 'covers/small', 'notes.txt' ]
ERR_FS_EISDIR
Source: Node.js API: fsPromises.cp()
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.
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 });
}
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.
| Option | Meaning | Default |
|---|---|---|
|
Directory the pattern is relative to; a path or a |
|
|
A function that returns |
none |
|
Yield |
|
|
Follow links to directories while expanding |
|
|
Maximum number of directory levels, |
|
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()));
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' ]
Source: Node.js API: File system
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
maxDepthoption is accepted and silently ignored, so*/.jswithmaxDepth: 1still returns every depth; on Node.js 26.9 and later it limits the walk. -
With
followSymlinks: trueon Node.js 26, apattern 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
excludefunction that returnstruefor a directory prunes the traversal below it; withwithFileTypes: truethe 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.
| Option | Meaning | Default |
|---|---|---|
|
Keep the process alive while the watcher is active; |
|
|
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). |
|
|
Encoding of the file name passed to the listener. |
|
|
An |
none |
|
Throw when the path does not exist (callback API, v26.1.0 and later). |
|
|
Glob string, |
none |
|
Promise API only: how many events are buffered between iterations of the iterator (default 2048) and whether overflow is |
2048, |
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;
}
Source: Node.js API: File system
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.
| Topic | Behaviour |
|---|---|
Backends |
Linux uses inotify, BSD kqueue, macOS kqueue for files and FSEvents for directories, SunOS event ports, Windows |
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 |
The name is supported only on Linux, macOS, Windows and AIX, and even there it is not guaranteed, so always handle |
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 |
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 |
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.
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.
Source: Node.js API: File system
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.
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');
Source: Node.js API: File system
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.
| Aspect | fs.watch |
fs.watchFile |
|---|---|---|
Mechanism |
Operating system notifications (inotify, kqueue, FSEvents, |
|
Watches |
A file or a directory, optionally recursive |
A single file |
Cost |
Almost none while idle |
One |
Detail of the change |
Event type and (maybe) file name |
Full before and after |
Reliability |
Platform dependent, see the caveats |
Works anywhere |
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
| Version | Change |
|---|---|
v26.9.0 |
|
v26.1.0 |
|
v25.0.0 |
The |
v24.4.0 |
|
v24.0.0 and v22.17.0 |
|
v23.7.0 and v22.14.0 |
|
v22.3.0 |
|
v22.0.0 and v20.13.0 |
|
v19.1.0 |
|
v20.1.0 and v18.17.0 |
|
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
-
Node.js Learn: Working with folders in Node.js, https://nodejs.org/learn/manipulating-files/working-with-folders-in-nodejs
-
Node.js Learn: Node.js file stats, https://nodejs.org/learn/manipulating-files/nodejs-file-stats
-
Node.js API: File system, https://nodejs.org/docs/latest/api/fs.html
-
Node.js API: fsPromises.cp(), https://nodejs.org/docs/latest/api/fs.html#fspromisescpsrc-dest-options
-
Node.js API: fsPromises.mkdtemp(), https://nodejs.org/docs/latest/api/fs.html#fspromisesmkdtempprefix-options
-
Node.js API: fsPromises.mkdtempDisposable(), https://nodejs.org/docs/latest/api/fs.html#fspromisesmkdtempdisposableprefix-options
-
Node.js API: fsPromises.rm(), https://nodejs.org/docs/latest/api/fs.html#fspromisesrmpath-options
-
Node.js API: Command-line options (
--watch,--watch-path), https://nodejs.org/docs/latest/api/cli.html -
libuv: File system events (the backend of
fs.watch), https://docs.libuv.org/en/v1.x/fs_event.html -
libuv: File system polling (the backend of
fs.watchFile), https://docs.libuv.org/en/v1.x/fs_poll.html -
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/