Working with Directories
Every real Node.js program eventually needs to organize files: creating a folder for uploads, listing what’s in a config directory, or walking a project tree to find source files. Node’s built-in fs module gives you everything you need to create, read, and remove directories without any third-party packages. This lesson covers the full directory API — sync, callback, and promise-based — and shows you how to use it correctly in real programs.
Overview: How Node.js Handles Directories
Directories, like files, are managed through the node:fs module, which wraps libuv’s filesystem bindings. On most operating systems, filesystem operations are not naturally asynchronous at the OS level the way network I/O is, so Node.js offloads them to libuv’s thread pool (four threads by default) and reports back to the event loop via a callback or a resolved promise once the operation finishes. This is why filesystem calls, even the “async” ones, can still be relatively slow compared to timers or network events — they are queued behind other thread-pool work like DNS lookups and some crypto functions.
fs exposes three parallel APIs for every operation:
- Sync — e.g.
fs.mkdirSync(). Blocks the event loop until the operation completes. Only acceptable for one-off startup code (reading config before the server starts), never inside a request handler. - Callback — e.g.
fs.mkdir(path, callback). The original Node.js style, non-blocking, but leads to nested callbacks for multi-step logic. - Promise-based —
node:fs/promises, e.g.await fs.mkdir(path). Non-blocking and works cleanly withasync/await. This is the recommended API for new code and what this lesson uses throughout.
Directory operations differ from file operations in one important way: many of them are recursive by nature. Creating a/b/c when a doesn’t exist yet, or deleting a folder that still has files in it, requires telling Node explicitly to recurse — it will not do so by default, to prevent accidental data loss.
Syntax
The core directory methods on fs/promises follow this general shape:
await fs.mkdir(path, options);
await fs.readdir(path, options);
await fs.rm(path, options);
await fs.stat(path);
| Method | Purpose | Key options |
|---|---|---|
fs.mkdir(path, opts) |
Create a directory | recursive: true creates missing parent folders and does not throw if the folder already exists |
fs.readdir(path, opts) |
List directory contents | withFileTypes: true returns Dirent objects instead of plain strings, so you can check type without a second call |
fs.rm(path, opts) |
Delete a file or directory | recursive: true deletes contents; force: true suppresses errors if the path doesn’t exist |
fs.rmdir(path, opts) |
Delete an empty directory (legacy) | Prefer fs.rm with recursive: true for non-empty trees — rmdir‘s own recursive option is deprecated |
fs.stat(path) |
Get metadata about a path | Returns a Stats object; use .isDirectory() / .isFile() |
fs.access(path, mode) |
Check existence/permissions | Rejects if the check fails — wrap in try/catch, don’t use it just to avoid a race before another call |
Examples
Example 1: Listing directory contents
const fs = require("node:fs/promises");
async function main() {
try {
const entries = await fs.readdir(".", { withFileTypes: true });
for (const entry of entries) {
const type = entry.isDirectory() ? "DIR " : "FILE";
console.log(`${type}\t${entry.name}`);
}
} catch (err) {
console.error("Failed to read directory:", err.message);
}
}
main();
Output:
DIR node_modules
FILE package.json
FILE app.js
DIR src
Passing { withFileTypes: true } is important: it returns an array of Dirent objects, each carrying its own isDirectory() and isFile() methods. Without that option, readdir returns plain filename strings and you’d need a separate fs.stat() call per entry to know what it is — a common source of unnecessary extra filesystem round trips.
Example 2: Creating nested directories and verifying them
const fs = require("node:fs/promises");
const path = require("node:path");
async function main() {
const target = path.join(__dirname, "data", "logs", "2026");
try {
await fs.mkdir(target, { recursive: true });
console.log("Created:", target);
const stats = await fs.stat(target);
console.log("Is directory:", stats.isDirectory());
} catch (err) {
console.error("Could not create directory:", err.message);
}
}
main();
Output:
Created: /home/user/app/data/logs/2026
Is directory: true
Without recursive: true, this call would throw ENOENT because the intermediate data and logs folders don’t exist yet. With it, Node creates every missing segment of the path in one call, and — unlike the non-recursive form — does not throw if target already exists, which makes it safe to run on every server startup.
Example 3: Recursively walking a directory tree
const fs = require("node:fs/promises");
const path = require("node:path");
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else {
console.log(fullPath);
}
}
}
async function main() {
const root = path.join(__dirname, "src");
try {
await walk(root);
} catch (err) {
console.error("Walk failed:", err.message);
}
}
main();
Output:
/home/user/app/src/index.js
/home/user/app/src/routes/users.js
/home/user/app/src/routes/posts.js
This is a realistic pattern: a recursive async function that awaits readdir at each level, and awaits itself again when it finds a subdirectory. Because each await suspends only the current call, sibling directories are still processed one after another here — if you needed them walked concurrently you’d collect the recursive calls into an array and use Promise.all().
How It Works Step by Step
Take Example 2. Here is the actual order of events when main() runs:
- 1.
path.join()runs synchronously and builds the target string immediately — no I/O involved. - 2.
fs.mkdir()is called. Node hands the request to libuv, which dispatches it to a worker thread in the thread pool. Theawaitsuspendsmain()and control returns to the event loop, which is free to do other work. - 3. The worker thread performs the actual OS-level directory creation (a blocking syscall on that thread, not on the main thread).
- 4. When the syscall finishes, libuv queues completion back onto the event loop’s poll phase, which resolves the promise.
- 5. The resolved promise schedules a microtask, which resumes
main()right afterfs.mkdir(), running theconsole.logline. - 6.
fs.stat()repeats the same round trip through the thread pool beforemain()resumes again.
The key takeaway: the main thread is never blocked during either call. Other timers, incoming HTTP requests, or other promises can run while these filesystem operations are in flight on the thread pool.
Common Mistakes
Mistake 1: Using sync directory calls inside a request handler
const fs = require("node:fs");
function handleUpload(req, res) {
fs.mkdirSync(`./uploads/${req.userId}`, { recursive: true });
res.end("ok");
}
mkdirSync blocks the entire event loop until the directory is created — every other request, timer, and I/O callback in your process waits. Under real traffic this destroys throughput. Use the promise version instead:
const fs = require("node:fs/promises");
async function handleUpload(req, res) {
await fs.mkdir(`./uploads/${req.userId}`, { recursive: true });
res.end("ok");
}
Mistake 2: Forgetting recursive: true and mishandling the error
const fs = require("node:fs/promises");
async function main() {
await fs.mkdir("reports/2026/july");
}
main();
If reports or reports/2026 doesn’t already exist, this throws ENOENT: no such file or directory. It also throws EEXIST if the folder is already there. Fix both problems at once:
const fs = require("node:fs/promises");
async function main() {
await fs.mkdir("reports/2026/july", { recursive: true });
}
main();
Mistake 3: Using __dirname in an ES module
__dirname and __filename are CommonJS globals and simply don’t exist in ES modules — referencing them throws ReferenceError: __dirname is not defined. In an ESM file (.mjs, or .js with "type": "module"), use import.meta.dirname (Node 20.11+) instead:
import fs from "node:fs/promises";
import path from "node:path";
const target = path.join(import.meta.dirname, "data");
await fs.mkdir(target, { recursive: true });
console.log("Created", target);
On older Node 20 versions without import.meta.dirname, derive it manually with path.dirname(fileURLToPath(import.meta.url)).
Mistake 4: Deleting a non-empty directory without recursive
Calling fs.rmdir("cache") on a folder that still has files throws ENOTEMPTY. Use fs.rm() with both flags instead: await fs.rm("cache", { recursive: true, force: true }). The force flag also silences the error if the path never existed in the first place, which is convenient for idempotent cleanup scripts.
Best Practices
- Prefer
fs/promiseswithasync/awaitover the callback API for new code; reserve the sync API for one-time startup logic outside of request handlers. - Always pass
{ recursive: true }tofs.mkdir()when creating a directory whose parents might not exist yet — it also makes the call idempotent. - Use
{ withFileTypes: true }withfs.readdir()to avoid extrafs.stat()calls when you need to know whether each entry is a file or directory. - Use
fs.rm(path, { recursive: true, force: true })instead of the deprecated recursive mode offs.rmdir(). - Always build paths with
node:path(path.join,path.resolve) instead of string concatenation, so your code works correctly on both POSIX and Windows. - Wrap every directory operation in
try/catchand inspecterr.code(ENOENT,EACCES,EEXIST) to react appropriately instead of just logging a generic failure. - For very large or deep directory trees, consider
fs.readdir(path, { recursive: true })(Node 20.1+), which walks subdirectories for you without manual recursion.
Practice Exercises
- Write a script that creates a
backups/<today's date>directory (e.g.backups/2026-07-31) each time it runs, without erroring if it already exists. - Write a function
countFiles(dir)that recursively counts how many files (not directories) exist under a given path, and returns the total. - Write a script that lists all subdirectories (not files) directly inside the current directory, sorted alphabetically, printing one per line.
Summary
- Node’s
fsmodule offers sync, callback, and promise-based APIs for directories; prefernode:fs/promiseswithasync/awaitin modern code. - Filesystem calls run on libuv’s thread pool, so they don’t block the event loop — except the explicit
*Syncvariants, which do. fs.mkdir(path, { recursive: true })creates missing parent directories and won’t throw if the folder already exists.fs.readdir(path, { withFileTypes: true })returnsDirentobjects so you can check file vs. directory without extra calls.- Use
fs.rm(path, { recursive: true, force: true })to delete non-empty directories; the recursive option onfs.rmdir()is deprecated. __dirnamedoesn’t exist in ES modules — useimport.meta.dirnameinstead.- Always build paths with
node:pathand handle errors by checkingerr.code.
