The fs Module Overview
The fs module is Node.js’s built-in interface to the file system, letting you read, write, watch, and inspect files and directories without any third-party dependencies. What makes it distinctive is that it exposes the same operations through three different styles: classic callbacks, modern promises, and blocking synchronous calls. Choosing the right flavor for a given situation is one of the first skills that separates fragile Node.js code from robust, scalable services. This page maps out those three APIs, explains when each is appropriate, and shows how to import the module correctly.
The three API flavors
Every meaningful operation in fs ships in three forms. They share the same underlying system calls; the difference is purely how they deliver results and how they behave with respect to the event loop.
| Flavor | Import | Returns | Blocks event loop? | Typical use |
|---|---|---|---|---|
| Promise | node:fs/promises | A Promise | No | Default for application code |
| Callback | node:fs | Nothing (callback invoked) | No | Streaming, legacy APIs, hot paths |
| Synchronous | node:fs (*Sync) | The value directly (throws on error) | Yes | Startup, CLI scripts, build tools |
Promise API (recommended default)
The promise-based API lives in a separate entry point, node:fs/promises. It returns promises, so it composes cleanly with async/await and modern error handling. For almost all new application code, this is the flavor you should reach for first.
import { readFile } from "node:fs/promises";
async function loadConfig() {
try {
const raw = await readFile("config.json", "utf8");
return JSON.parse(raw);
} catch (err) {
if (err.code === "ENOENT") {
console.warn("No config found, using defaults");
return {};
}
throw err;
}
}
console.log(await loadConfig());
Output:
{ port: 3000, logLevel: 'info' }
Callback API
The original fs API uses Node’s error-first callback convention: the first argument to your callback is an error (or null), and subsequent arguments hold the result. It is non-blocking like the promise API, but it nests awkwardly and is harder to reason about. You will still encounter it in older codebases and in some streaming scenarios.
import { readFile } from "node:fs";
readFile("config.json", "utf8", (err, data) => {
if (err) {
console.error("Read failed:", err.code);
return;
}
console.log("Loaded", data.length, "bytes");
});
Output:
Loaded 42 bytes
Prefer
fs/promisesover the callback API for new code. If you have a callback-only function, you can wrap it withutil.promisifyrather than threading callbacks by hand.
Synchronous API
Synchronous methods are suffixed with Sync. They return the result directly and throw on failure — but they block the entire event loop until the disk operation completes. In a server handling concurrent requests, a single readFileSync can stall every other connection. They are perfectly fine, however, in places where blocking does not matter: at process startup, in one-shot CLI scripts, or in build tooling.
import { readFileSync } from "node:fs";
// Fine: runs once during startup, before the server accepts traffic
const banner = readFileSync("banner.txt", "utf8");
console.log(banner);
Output:
=== DevCraftly Service v2 ===
Choosing the right flavor
A simple decision guide:
- Inside request handlers or any concurrent code → use
fs/promises. Never block the loop while serving traffic. - Bridging older callback-based libraries → use the callback API, or promisify it.
- Startup, configuration loading, CLI tools, build scripts →
*Syncis acceptable and often clearer, because there is nothing else running to block.
The cost of a synchronous call is not the call itself but its effect on everything else trying to run. When in doubt, stay asynchronous.
The node: prefix
Modern Node.js lets you import built-in modules with an explicit node: prefix. This makes it unambiguous that you mean the core module rather than a same-named package from node_modules, and it is the recommended convention going forward.
// Preferred — explicit core-module import
import { writeFile } from "node:fs/promises";
// Still works, but ambiguous
import { writeFile } from "fs/promises";
CommonJS code uses require with the same prefix:
const { writeFile } = require("node:fs/promises");
The
node:prefix is required for thenode:testrunner and some newer modules, and it slightly speeds up resolution because Node can skip thenode_moduleslookup. Adopt it everywhere for consistency.
A note on operations and options
Most fs functions accept an options argument as their second-to-last parameter. For text operations, passing an encoding string ("utf8") returns a string; omitting it returns a raw Buffer. Many write and append functions also accept { flag, mode, encoding } to control how the file is opened and what permissions it receives.
import { writeFile } from "node:fs/promises";
// Append instead of overwrite, create with rw-r--r-- permissions
await writeFile("audit.log", "started\n", { flag: "a", mode: 0o644 });
Best Practices
- Default to
node:fs/promiseswithasync/awaitfor application code; reserve*Syncfor startup and scripts. - Never call a
*Syncmethod inside an HTTP request handler or other concurrent path — it freezes the whole event loop. - Always specify an encoding (e.g.
"utf8") when you want a string back; otherwise you receive aBuffer. - Handle errors by inspecting
err.code(such asENOENT,EACCES) rather than parsing error messages. - Use the
node:prefix on every core-module import for clarity and faster resolution. - Avoid mixing callback and promise styles in the same module; pick one and stay consistent.
- Wrap legacy callback APIs with
util.promisifyinstead of hand-writing nested callbacks.