Skip to content
Node.js nd fs 4 min read

Reading Files

Reading a file is the most common file system task in Node.js, but there is more than one way to do it — and the right choice depends on how large the file is and where in your program the read happens. For small to medium files you load the whole thing into memory in one shot; for large files you stream it in chunks to keep memory bounded. This page covers the promise-based readFile, the callback and synchronous variants, how encoding controls whether you get a string or a Buffer, and when to reach for createReadStream instead.

Reading a whole file with fs/promises

The modern default is readFile from node:fs/promises. It reads the entire file into memory and resolves with the contents. Pass an encoding string as the second argument to get a decoded string back; omit it to get a raw Buffer. Because it returns a promise, it pairs naturally with async/await and try/catch.

import { readFile } from "node:fs/promises";

const text = await readFile("notes.txt", "utf8");
console.log(text.toUpperCase());

Output:

REMEMBER TO BACK UP THE DATABASE

You can also pass an options object instead of a bare encoding string, which is handy when you want to specify a flag as well:

import { readFile } from "node:fs/promises";

const data = await readFile("notes.txt", { encoding: "utf8", flag: "r" });
console.log(`Read ${data.length} characters`);

Output:

Read 32 characters

Encoding: string versus Buffer

The single most common source of confusion when reading files is the encoding argument. When you supply one, Node decodes the bytes for you and hands back a String. When you leave it out, you receive a Buffer — a raw sequence of bytes — which is exactly what you want for binary data like images, archives, or anything you intend to hash or pipe elsewhere untouched.

import { readFile } from "node:fs/promises";

const buf = await readFile("logo.png"); // no encoding → Buffer
console.log(buf.length, "bytes");
console.log(buf.subarray(0, 4)); // PNG magic number

const utf = await readFile("logo.png", "utf8"); // forces (lossy) decode
console.log(typeof utf);

Output:

15043 bytes
<Buffer 89 50 4e 47>
string
CallReturn typeUse for
readFile(path)BufferBinary data (images, hashes, raw bytes)
readFile(path, "utf8")stringText files, JSON, source code
readFile(path, "base64")stringEncoding binary as text
readFile(path, "latin1")stringLegacy single-byte text

Never decode binary data as utf8. Bytes that are not valid UTF-8 are replaced with the U+FFFD replacement character, silently corrupting the file. Keep binary content in a Buffer until the moment you actually need text.

The callback and synchronous variants

The same operation exists in two other flavors. The callback API from node:fs follows Node’s error-first convention and is non-blocking like the promise version. The synchronous readFileSync returns the contents directly and throws on failure, but it blocks the event loop for the duration of the read — acceptable at startup or in a CLI script, but never inside a request handler serving concurrent traffic.

import { readFile, readFileSync } from "node:fs";

// Callback style — error-first
readFile("config.json", "utf8", (err, data) => {
  if (err) throw err;
  console.log("Loaded config:", JSON.parse(data).port);
});

// Synchronous — fine during one-time startup
const banner = readFileSync("banner.txt", "utf8");
console.log(banner.trim());

Output:

Loaded config: 3000
=== DevCraftly Service ===

In CommonJS the imports look like this, but the functions behave identically:

const { readFile, readFileSync } = require("node:fs");

Streaming large files with createReadStream

readFile buffers the entire file in memory before it resolves. For a multi-gigabyte log or video that is wasteful and may exhaust available RAM. createReadStream instead delivers the file in manageable chunks, so memory stays flat regardless of file size. This is the correct tool for processing large files line by line, piping a file to an HTTP response, or transforming data as it flows.

import { createReadStream } from "node:fs";

const stream = createReadStream("huge.log", { encoding: "utf8", highWaterMark: 64 * 1024 });

let bytes = 0;
stream.on("data", (chunk) => {
  bytes += Buffer.byteLength(chunk);
});
stream.on("end", () => console.log(`Finished — processed ${bytes} bytes`));
stream.on("error", (err) => console.error("Stream failed:", err.code));

Output:

Finished — processed 1073741824 bytes

You can also iterate a stream with for await, which gives you backpressure handling for free:

import { createReadStream } from "node:fs";

const stream = createReadStream("huge.log", "utf8");
let lines = 0;

for await (const chunk of stream) {
  lines += chunk.split("\n").length - 1;
}
console.log(`Counted ${lines} lines`);

Output:

Counted 4200000 lines

Rule of thumb: if a file comfortably fits in memory (roughly under a few hundred MB) and you need all of it at once, use readFile. If it is large, unbounded, or you can process it incrementally, use createReadStream.

Best Practices

  • Default to readFile from node:fs/promises with async/await for application code.
  • Always pass "utf8" (or another encoding) when you want a string; omit it to keep binary data as a Buffer.
  • Reserve readFileSync for startup, configuration loading, and CLI scripts — never call it in a concurrent request path.
  • Stream large or unbounded files with createReadStream to keep memory usage constant.
  • Handle errors by checking err.code (e.g. ENOENT, EACCES) rather than matching on message text.
  • Wrap stream consumption in for await ... of to get built-in backpressure instead of managing data events manually.
  • Validate and parse JSON inside a try/catch, since a malformed file throws from JSON.parse, not from the read itself.
Last updated June 14, 2026
Was this helpful?