Node.js File System (fs)

Almost every real Node.js program touches the disk at some point — reading a config file, writing a log, serving a static asset, or streaming a video to a browser. All of that goes through the built-in fs (file system) module. It wraps libuv’s cross-platform file I/O so the same code reads and writes files identically on Linux, macOS, and Windows, and it offers that access in three different styles: promise-based, callback-based, and synchronous.

Choosing the right style matters more than it sounds. A single blocking file read inside an HTTP request handler can freeze an entire server for every connected client at once. This lesson covers how fs really works under the hood, all three API styles, streaming large files safely, and the mistakes that trip up almost everyone the first time they use it.

Overview: How the File System Module Works

Node’s core does not talk to the operating system’s file system directly — it delegates to libuv, the C library that also drives Node’s event loop. Unlike network sockets, most operating systems do not offer true asynchronous file I/O, so libuv fakes it: it runs file operations on a fixed-size thread pool (4 threads by default, tunable with the UV_THREADPOOL_SIZE environment variable) and reports the result back to the main thread when the work finishes. That result is delivered on the event loop’s poll phase, which is why an async fs callback never fires in the middle of your synchronous code — it always waits for the current call stack to finish first.

This gives you three families of fs functions:

  • Promise-basednode:fs/promises. Use with async/await. This is the recommended default for new code.
  • Callback-basednode:fs. The original, error-first callback style (fs.readFile(path, (err, data) => {...})). Still used internally and in older codebases.
  • Synchronous — the same module, with a Sync suffix (fs.readFileSync). These block the entire event loop until the operation completes — nothing else in your process runs meanwhile, including other requests on an HTTP server.

By default, fs read functions return raw Buffer objects (binary data), not strings. Pass an encoding such as "utf8" as the second argument to get a string back instead. Working with buffers matters for binary files (images, zips); for text files, always specify the encoding you expect.

For large files, reading the whole thing into memory with readFile is wasteful and can exhaust memory entirely. Node’s streams read and write data in small chunks instead, so memory use stays flat regardless of file size. The catch is backpressure: if a writable destination is slower than the readable source, data can pile up in memory unless the stream mechanism pauses the source. The modern, safe way to connect streams is pipeline() from node:stream/promises, which handles backpressure automatically and — critically — forwards errors and cleans up open file descriptors, which the older .pipe() method does not do reliably.

Syntax

The general shape of promise-based file access looks like this:

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

async function readConfig(path) {
  const raw = await fs.readFile(path, "utf8");
  return JSON.parse(raw);
}
  • require("node:fs/promises") — the node: prefix marks this as a built-in core module.
  • fs.readFile(path, encoding) — returns a Promise that resolves with the file contents.
  • Passing "utf8" returns a string; omitting it returns a raw Buffer.
  • Wrap the await in a try/catch, or let the rejection propagate to a caller that does.

Common fs/promises methods, with their callback and sync equivalents:

Promise API Callback API Sync API Purpose
fs.readFile() fs.readFile() fs.readFileSync() Read an entire file into memory
fs.writeFile() fs.writeFile() fs.writeFileSync() Write a file, overwriting it
fs.appendFile() fs.appendFile() fs.appendFileSync() Append to a file, creating it if missing
fs.mkdir() fs.mkdir() fs.mkdirSync() Create a directory (use { recursive: true } for nested paths)
fs.readdir() fs.readdir() fs.readdirSync() List directory entries
fs.stat() fs.stat() fs.statSync() Get size, type, and timestamps for a path
fs.rm() fs.rm() fs.rmSync() Delete a file or directory
fs.copyFile() fs.copyFile() fs.copyFileSync() Copy a file
fs.createReadStream() Read a file as a stream of chunks
fs.createWriteStream() Write a file as a stream of chunks

Examples

Example 1: Reading a file with fs/promises

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

async function main() {
  try {
    const data = await fs.readFile("greeting.txt", "utf8");
    console.log(data);
  } catch (err) {
    console.error("Failed to read file:", err.message);
  }
}

main();

Output (assuming greeting.txt contains Hello from Node.js!):

Hello from Node.js!

This is the pattern you should reach for by default: import fs/promises, wrap the call in an async function, and catch errors with try/catch. Because top-level await requires an ES module, this CommonJS example wraps everything in a main() function and calls it at the bottom.

Example 2: Writing, appending, and inspecting a file

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

async function main() {
  const logPath = "app.log";

  await fs.writeFile(logPath, "Server started\n", "utf8");
  await fs.appendFile(logPath, "Listening on port 3000\n", "utf8");

  const stats = await fs.stat(logPath);
  console.log(`${logPath} is ${stats.size} bytes`);

  const contents = await fs.readFile(logPath, "utf8");
  console.log(contents);
}

main().catch((err) => {
  console.error("Logging failed:", err.message);
  process.exitCode = 1;
});

Output:

app.log is 41 bytes
Server started
Listening on port 3000

writeFile creates the file if it doesn’t exist and overwrites it if it does; appendFile adds to the end without touching existing content. fs.stat() returns an fs.Stats object with fields like size, isFile(), isDirectory(), and modification times. Note the .catch() on main() — without it, a rejected promise here would become an unhandled rejection.

Example 3: Streaming a file copy with pipeline()

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

async function copyLargeFile(source, destination) {
  const readStream = fs.createReadStream(source);
  const writeStream = fs.createWriteStream(destination);

  await pipeline(readStream, writeStream);
  console.log(`Copied ${path.basename(source)} to ${destination}`);
}

async function main() {
  try {
    await copyLargeFile("input.txt", "backup/input-copy.txt");
  } catch (err) {
    console.error("Copy failed:", err.message);
  }
}

main();

Output:

Copied input.txt to backup/input-copy.txt

Instead of loading the entire file into memory, createReadStream and createWriteStream move it in small chunks. pipeline() connects them, automatically pausing the read stream if the write stream falls behind (backpressure), and it rejects its returned promise if either side errors — something the older readStream.pipe(writeStream) pattern does not do safely.

How It Works Step by Step: Async fs and the Event Loop

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

console.log("1. Start");

fs.readFile(__filename, () => {
  console.log("3. File read callback (poll phase)");
});

console.log("2. End of synchronous code");

Output:

1. Start
2. End of synchronous code
3. File read callback (poll phase)
  1. The synchronous code runs top to bottom first: "1. Start" logs immediately.
  2. fs.readFile hands the actual disk read off to libuv’s thread pool and returns immediately — it does not wait for the file to be read.
  3. "2. End of synchronous code" logs next, because the main thread never blocked.
  4. Once the thread pool finishes reading the file, libuv queues the callback to run during the event loop’s poll phase. Only then does "3. File read callback" print.

If you swapped fs.readFile for fs.readFileSync, line 2 would never print before line 3 — the whole thread would freeze on the disk read, and on a server, every other in-flight request would freeze with it.

Common Mistakes

Mistake 1: Blocking a server with a synchronous read

const http = require("node:http");
const fs = require("node:fs");

const server = http.createServer((req, res) => {
  const data = fs.readFileSync("big-file.txt", "utf8");
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end(data);
});

server.listen(3000);

This works in a quick test but is dangerous in production: readFileSync blocks Node’s single thread, so while one request reads the file, every other connected client waits — no other request, timer, or callback can run. Use the promise API instead:

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

const server = http.createServer(async (req, res) => {
  try {
    const data = await fs.readFile("big-file.txt", "utf8");
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end(data);
  } catch (err) {
    res.writeHead(500, { "Content-Type": "text/plain" });
    res.end("Internal Server Error");
  }
});

server.listen(3000);

Sync calls are fine at startup (reading a config file before listen() is called), because nothing else is being served yet. They are the wrong choice inside any code path that runs per-request.

Mistake 2: No error listener on a stream

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

fs.createReadStream("missing-file.txt").pipe(process.stdout);

If missing-file.txt doesn’t exist, the stream emits an "error" event. With no listener attached, Node treats it as an uncaught exception and crashes the whole process. Streams and event emitters never throw synchronously on failure — you must listen for "error" explicitly:

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

const readStream = fs.createReadStream("missing-file.txt");

readStream.on("error", (err) => {
  console.error("Could not read file:", err.message);
});

readStream.pipe(process.stdout);

Better still, prefer pipeline() (shown in Example 3) over manual .pipe() calls, since it surfaces errors from every stream in the chain as a single rejected promise instead of requiring a listener on each one.

Mistake 3: Reading huge files entirely into memory

fs.readFile("video.mp4") loads the whole file into a single Buffer before your code sees any of it. For a multi-gigabyte file, that can spike memory usage enough to crash the process. Whenever a file is large or its size is unknown ahead of time, use createReadStream and process it in chunks instead of a one-shot read.

Best Practices

  • Default to fs/promises with async/await for new code; reserve the callback API for legacy code you’re maintaining.
  • Never call a *Sync function inside an HTTP request handler or any other hot path that serves concurrent work — only at startup or in one-off scripts.
  • Always specify an encoding ("utf8") when you expect text, and leave it off (to get a Buffer) when handling binary data.
  • Use pipeline() from node:stream/promises instead of chaining .pipe() calls, so errors and cleanup are handled for you.
  • Attach an "error" listener to every stream you create manually — an unhandled stream error crashes the process.
  • Use { recursive: true } with fs.mkdir() when the parent directories might not exist yet.
  • Resolve paths with node:path (e.g. path.join(__dirname, "data.json")) rather than concatenating strings, so your code works on both POSIX and Windows path separators.
  • Catch and log file system errors with the specific path and operation included — a bare "ENOENT" is much less useful than "Failed to read config.json: ENOENT".

Practice Exercises

  • Write a script that creates a directory named logs (if it doesn’t already exist) and writes a file logs/startup.txt containing the current process ID (process.pid). Use the promise API and handle errors.
  • Write a function readJsonConfig(path) that reads a JSON file with fs/promises, parses it, and throws a clear error message (including the file path) if the file is missing or the JSON is invalid.
  • Using fs.createReadStream, fs.createWriteStream, and pipeline(), write a script that copies a file passed as a command-line argument (process.argv[2]) to a new file with .bak appended to its name. Make sure a missing source file produces a friendly error instead of crashing the process.

Summary

  • The fs module comes in three flavors: promise-based (fs/promises, preferred), callback-based (fs), and synchronous (*Sync functions).
  • Async file operations run on libuv’s thread pool and their callbacks fire during the event loop’s poll phase, after all synchronous code has finished.
  • Synchronous fs calls block the entire event loop — safe at startup, unsafe inside request handlers or any concurrent hot path.
  • Streams process files in chunks to keep memory usage flat; pipeline() connects them safely, handling backpressure and forwarding errors.
  • Always attach an "error" listener to streams and wrap await calls in try/catch — file system operations fail constantly in the real world (missing files, permissions, full disks).