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
| Call | Return type | Use for |
|---|---|---|
readFile(path) | Buffer | Binary data (images, hashes, raw bytes) |
readFile(path, "utf8") | string | Text files, JSON, source code |
readFile(path, "base64") | string | Encoding binary as text |
readFile(path, "latin1") | string | Legacy 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 aBufferuntil 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, usecreateReadStream.
Best Practices
- Default to
readFilefromnode:fs/promiseswithasync/awaitfor application code. - Always pass
"utf8"(or another encoding) when you want a string; omit it to keep binary data as aBuffer. - Reserve
readFileSyncfor startup, configuration loading, and CLI scripts — never call it in a concurrent request path. - Stream large or unbounded files with
createReadStreamto 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 ... ofto get built-in backpressure instead of managingdataevents manually. - Validate and parse JSON inside a
try/catch, since a malformed file throws fromJSON.parse, not from the read itself.