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 withasync/await,Promise.all, andtry/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 anasyncfunction, so calling it returns a promise immediately and its body starts running synchronously up to the firstawait. - 2.
listWithSizes(".")is invoked; inside it,fs.readdiris called. This queues a libuv thread-pool task and returns a pending promise; execution oflistWithSizespauses at thatawait. - 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
entriesis populated, resuminglistWithSizes. - 5.
entries.map()synchronously creates one in-flight promise per entry (each starting its ownfs.statthread-pool task), andPromise.allwaits for all of them together rather than sequentially. - 6. Once every
statpromise resolves,Promise.allresolves with the full array,detailsis returned, and the outerawaitinmain()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/promiseswithasync/awaitover the callback API for new code; reserve callbacks for legacy code you’re maintaining. - Always wrap awaited
fscalls intry/catch, and checkerr.code(ENOENT,EACCES,EEXIST) to react appropriately instead of treating every failure the same. - Use streams and
pipeline()fromnode:stream/promisesfor large files instead ofreadFile/writeFile. - Run independent file operations concurrently with
Promise.allinstead of awaiting them one by one in a loop. - Always close any
FileHandlereturned byfs.promises.open()inside afinallyblock. - 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/writeFileSyncfor 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 olderrmdirwith a recursive option is deprecated.
Practice Exercises
- Write a script that reads every file in the current directory with
fs.promises.readdirandfs.promises.stat, run concurrently withPromise.all, and prints the combined total size in bytes of all files (skip directories). - Write an async function
readJsonSafe(path)that reads andJSON.parses a file, returningnullif 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 usingnode:fs/promisesandasync/await, including proper error handling.
Summary
node:fs/promisesmirrors the callback-basedfsAPI but returns promises, enabling cleanasync/awaitcode withtry/catcherror 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.alllets you run independent file operations concurrently instead of serially awaiting them in a loop.- Use streams with
pipeline()instead ofreadFile/writeFilefor large files to avoid loading everything into memory at once. - Always close
FileHandleobjects fromfs.promises.open()in afinallyblock to avoid descriptor leaks.
