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-basednode:fs/promises, e.g. await fs.mkdir(path). Non-blocking and works cleanly with async/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. The await suspends main() 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 after fs.mkdir(), running the console.log line.
  • 6. fs.stat() repeats the same round trip through the thread pool before main() 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/promises with async/await over the callback API for new code; reserve the sync API for one-time startup logic outside of request handlers.
  • Always pass { recursive: true } to fs.mkdir() when creating a directory whose parents might not exist yet — it also makes the call idempotent.
  • Use { withFileTypes: true } with fs.readdir() to avoid extra fs.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 of fs.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/catch and inspect err.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 fs module offers sync, callback, and promise-based APIs for directories; prefer node:fs/promises with async/await in modern code.
  • Filesystem calls run on libuv’s thread pool, so they don’t block the event loop — except the explicit *Sync variants, 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 }) returns Dirent objects 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 on fs.rmdir() is deprecated.
  • __dirname doesn’t exist in ES modules — use import.meta.dirname instead.
  • Always build paths with node:path and handle errors by checking err.code.