The fs Promises API

Every version of Node’s file system module used to force you into callbacks: fs.readFile(path, (err, data) => { ... }). Once async/await became standard JavaScript, Node added a promise-based twin of the entire fs API, available as node:fs/promises. It exposes the same operations — reading, writing, renaming, deleting, listing directories — but every function returns a Promise instead of taking a callback. This is now the recommended way to work with files in modern Node.js, because it lets you write file-handling code with try/catch and await instead of nested callbacks.

Overview: How the fs Promises API Works

node:fs/promises is not a different implementation of file I/O — it’s a thin promise-returning layer over the exact same underlying mechanism as the callback-based fs module. Under the hood, Node’s file operations are not handled by the event loop’s poll phase the way network I/O is. Most operating systems don’t offer a good native async file API, so Node’s C++ layer (libuv) farms file system calls out to a fixed-size thread pool (four threads by default, controlled by the UV_THREADPOOL_SIZE environment variable). A worker thread performs the blocking system call (like read() or stat()), and when it finishes, libuv schedules the completion back onto the main thread’s event loop, which resolves your promise (or invokes your callback).

This matters practically: fs.promises.readFile() does not block the event loop, but it does compete for one of those four thread-pool slots with every other pending file operation, DNS lookup (dns.lookup), and some crypto operations. If you fire off dozens of concurrent file reads, later ones queue up waiting for a free thread — the pool doesn’t grow just because you used promises.

There are three flavors of the fs API, and it’s important to know which one you’re reaching for:

  • Synchronous (fs.readFileSync) — blocks the entire event loop until the operation finishes. Fine for one-off work at startup (e.g. loading a config file before the server starts), wrong inside a request handler.
  • Callback-based (fs.readFile) — the original async API, non-blocking, but leads to callback nesting for multi-step logic.
  • Promise-based (fs.promises / node:fs/promises) — non-blocking, and composable with async/await, Promise.all, and try/catch. This is what this lesson covers.

The promises module also introduces FileHandle objects (from fs.promises.open()), which wrap a raw file descriptor in an object with its own methods (.read(), .write(), .close(), .stat()) — useful when you need fine-grained control, like reading a file in chunks at specific offsets.

Syntax

Import the module, then await whichever operation you need inside an async function:

const fs = require("node:fs/promises");

await fs.readFile(path, options);
await fs.writeFile(path, data, options);
await fs.appendFile(path, data, options);
await fs.mkdir(path, options);
await fs.readdir(path, options);
await fs.stat(path);
await fs.rm(path, options);
Method Purpose
readFile(path, encoding) Reads an entire file into memory. Pass "utf8" for a string, or omit it for a raw Buffer.
writeFile(path, data) Creates or fully overwrites a file with data.
appendFile(path, data) Adds data to the end of an existing file, creating it if needed.
readdir(path, { withFileTypes }) Lists directory entries; with withFileTypes: true returns Dirent objects instead of plain strings.
stat(path) Returns metadata (size, timestamps, type) for a file or directory.
mkdir(path, { recursive }) Creates a directory; recursive: true creates missing parent folders too.
rm(path, { recursive, force }) Deletes a file or, with recursive: true, an entire directory tree.
open(path, flags) Returns a FileHandle for low-level, multi-step access. Must be closed manually.

Examples

Example 1: Writing and reading a file

const fs = require("node:fs/promises");

async function main() {
  await fs.writeFile("notes.txt", "Buy milk\nWalk the dog\n", "utf8");
  const data = await fs.readFile("notes.txt", "utf8");
  console.log(data);
}

main().catch((err) => {
  console.error("Something went wrong:", err.message);
});

Output:

Buy milk
Walk the dog

This writes a two-line text file, then immediately reads it back. Because both calls are awaited, the read is guaranteed to happen after the write completes — there’s no risk of a race condition, unlike mixing sync and async APIs carelessly.

Example 2: Loading a config file with sensible defaults

const fs = require("node:fs/promises");
const path = require("node:path");

async function loadConfig(configPath) {
  const defaults = { port: 3000, host: "localhost" };
  try {
    const raw = await fs.readFile(configPath, "utf8");
    return { ...defaults, ...JSON.parse(raw) };
  } catch (err) {
    if (err.code === "ENOENT") {
      console.log("No config file found, using defaults");
      return defaults;
    }
    throw err;
  }
}

async function main() {
  const configPath = path.join(__dirname, "config.json");
  const config = await loadConfig(configPath);
  console.log(config);
}

main();

Output (when config.json does not exist):

No config file found, using defaults
{ port: 3000, host: 'localhost' }

This checks the specific error code ENOENT (“no such file or directory”) to distinguish “the file is simply missing, fall back to defaults” from a real problem like a permissions error or malformed JSON, which is re-thrown instead of being silently ignored.

Example 3: Listing a directory with sizes, concurrently

const fs = require("node:fs/promises");
const path = require("node:path");

async function listWithSizes(dirPath) {
  const entries = await fs.readdir(dirPath, { withFileTypes: true });

  const details = await Promise.all(
    entries.map(async (entry) => {
      const fullPath = path.join(dirPath, entry.name);
      const stats = await fs.stat(fullPath);
      return {
        name: entry.name,
        isDirectory: entry.isDirectory(),
        size: stats.size,
      };
    })
  );

  return details;
}

async function main() {
  const files = await listWithSizes(".");
  for (const file of files) {
    const kind = file.isDirectory ? "[dir] " : "[file]";
    console.log(`${kind} ${file.name} - ${file.size} bytes`);
  }
}

main().catch((err) => {
  console.error("Failed to list directory:", err);
  process.exitCode = 1;
});

Output (varies by directory contents):

[file] app.js - 812 bytes
[dir]  node_modules - 4096 bytes
[file] package.json - 246 bytes

readdir alone only returns names (or Dirent objects). Getting a size requires a separate stat call per entry. Using Promise.all with .map() fires all those stat calls concurrently instead of one at a time, which is significantly faster for a directory with many files — though remember they still share the four-thread libuv pool underneath.

How It Works Step by Step

Walking through Example 3 in order:

  • 1. main() is called; it’s an async function, so calling it returns a promise immediately and its body starts running synchronously up to the first await.
  • 2. listWithSizes(".") is invoked; inside it, fs.readdir is called. This queues a libuv thread-pool task and returns a pending promise; execution of listWithSizes pauses at that await.
  • 3. The event loop is free to do other work while a worker thread performs the actual directory-listing syscall.
  • 4. When the syscall finishes, libuv schedules the promise resolution as a microtask. Once the current synchronous stack is clear, the microtask queue runs, and entries is populated, resuming listWithSizes.
  • 5. entries.map() synchronously creates one in-flight promise per entry (each starting its own fs.stat thread-pool task), and Promise.all waits for all of them together rather than sequentially.
  • 6. Once every stat promise resolves, Promise.all resolves with the full array, details is returned, and the outer await in main() resumes to print each line.

The key insight: await only pauses the function it’s in, never the whole process — other requests, timers, or I/O the process is handling continue running in the meantime.

Common Mistakes

1. Forgetting to await

Every fs/promises method returns a Promise. Forget the await and you get the Promise object itself, not the data, and no guarantee the operation has even started when the next line runs.

const fs = require("node:fs/promises");

function readNotes() {
  const data = fs.readFile("notes.txt", "utf8"); // missing await
  console.log(data);
}

readNotes();

Output: Promise { <pending> } instead of the file’s contents.

Fix it by marking the function async and awaiting the call:

const fs = require("node:fs/promises");

async function readNotes() {
  const data = await fs.readFile("notes.txt", "utf8");
  console.log(data);
}

readNotes();

2. Loading huge files entirely into memory

readFile/writeFile buffer the whole file in RAM. That’s fine for a small config or template, but disastrous for a multi-gigabyte log or video file — you’ll spike memory usage and stall while the whole buffer is built.

const fs = require("node:fs/promises");

async function copyBigFile(src, dest) {
  const data = await fs.readFile(src); // loads the entire file into memory
  await fs.writeFile(dest, data);
}

Use streams instead, which process the file in small chunks. Prefer pipeline() from node:stream/promises over manual .pipe() chains — plain .pipe() does not forward errors or clean up file descriptors on failure:

const { pipeline } = require("node:stream/promises");
const fs = require("node:fs");

async function copyBigFile(src, dest) {
  await pipeline(fs.createReadStream(src), fs.createWriteStream(dest));
}

3. Leaving a FileHandle open

fs.promises.open() hands you a real, limited operating-system file descriptor. If an error is thrown before you call .close(), that descriptor leaks for the life of the process.

const fs = require("node:fs/promises");

async function readChunk(path) {
  const handle = await fs.open(path, "r");
  const buffer = Buffer.alloc(100);
  await handle.read(buffer, 0, 100, 0); // if this throws, handle is never closed
  return buffer;
}

Always close it in a finally block so it runs whether or not an error occurred:

const fs = require("node:fs/promises");

async function readChunk(path) {
  const handle = await fs.open(path, "r");
  try {
    const buffer = Buffer.alloc(100);
    await handle.read(buffer, 0, 100, 0);
    return buffer;
  } finally {
    await handle.close();
  }
}

Best Practices

  • Prefer node:fs/promises with async/await over the callback API for new code; reserve callbacks for legacy code you’re maintaining.
  • Always wrap awaited fs calls in try/catch, and check err.code (ENOENT, EACCES, EEXIST) to react appropriately instead of treating every failure the same.
  • Use streams and pipeline() from node:stream/promises for large files instead of readFile/writeFile.
  • Run independent file operations concurrently with Promise.all instead of awaiting them one by one in a loop.
  • Always close any FileHandle returned by fs.promises.open() inside a finally block.
  • Import with the node: prefix (require("node:fs/promises")) so it’s unambiguous that the module is a Node built-in, not a package from npm.
  • Reserve fs.readFileSync/writeFileSync for one-time startup work (like loading a config before listening), never inside a request handler where blocking freezes every other connection.
  • Use fs.promises.rm(path, { recursive: true, force: true }) to delete directory trees; the older rmdir with a recursive option is deprecated.

Practice Exercises

  • Write a script that reads every file in the current directory with fs.promises.readdir and fs.promises.stat, run concurrently with Promise.all, and prints the combined total size in bytes of all files (skip directories).
  • Write an async function readJsonSafe(path) that reads and JSON.parses a file, returning null if the file doesn’t exist (ENOENT) but re-throwing any other error, including invalid JSON.
  • Take a callback-based snippet using fs.readFile(path, cb) and rewrite it using node:fs/promises and async/await, including proper error handling.

Summary

  • node:fs/promises mirrors the callback-based fs API but returns promises, enabling clean async/await code with try/catch error handling.
  • Under the hood, file operations run on libuv’s fixed-size thread pool (default 4 threads), not on the event loop’s poll phase like network I/O.
  • Three flavors exist — sync, callback, and promise — and mixing them carelessly, or using sync calls in a server’s request path, blocks the whole process.
  • Promise.all lets you run independent file operations concurrently instead of serially awaiting them in a loop.
  • Use streams with pipeline() instead of readFile/writeFile for large files to avoid loading everything into memory at once.
  • Always close FileHandle objects from fs.promises.open() in a finally block to avoid descriptor leaks.