Skip to content
Node.js nd fs 4 min read

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.

FlavorImportReturnsBlocks event loop?Typical use
Promisenode:fs/promisesA PromiseNoDefault for application code
Callbacknode:fsNothing (callback invoked)NoStreaming, legacy APIs, hot paths
Synchronousnode:fs (*Sync)The value directly (throws on error)YesStartup, CLI scripts, build tools

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/promises over the callback API for new code. If you have a callback-only function, you can wrap it with util.promisify rather 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*Sync is 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 the node:test runner and some newer modules, and it slightly speeds up resolution because Node can skip the node_modules lookup. 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/promises with async/await for application code; reserve *Sync for startup and scripts.
  • Never call a *Sync method 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 a Buffer.
  • Handle errors by inspecting err.code (such as ENOENT, 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.promisify instead of hand-writing nested callbacks.
Last updated June 14, 2026
Was this helpful?