Reading Files

Reading a file means asking the operating system to hand your program the bytes stored at some path on disk, and Node.js gives you three different ways to do it. Which one you choose matters more than it looks, because picking the wrong one can silently make an entire server slow to a crawl under load. This lesson covers all three read APIs in node:fs, how each one interacts with Node’s event loop, and how to read files correctly — from a tiny config file to a multi-gigabyte log.

Overview: How File Reading Works in Node.js

Every file read ultimately becomes a system call to the operating system’s file APIs. Node.js exposes three flavors of this in the built-in node:fs module:

  • Synchronousfs.readFileSync(). Blocks the entire JavaScript thread until the read finishes.
  • Callback-basedfs.readFile(). The classic Node.js error-first callback API.
  • Promise-basedfsPromises.readFile() from node:fs/promises. The same operation, usable with async/await.

The synchronous version runs directly on Node’s single JavaScript thread: nothing else — no other request, no timer, no I/O callback — can run until it finishes. The callback and promise versions instead hand the actual disk I/O off to libuv‘s internal thread pool (four threads by default, controlled by the UV_THREADPOOL_SIZE environment variable). A worker thread does the blocking read, and once it’s done, libuv queues the completion so your callback (or the promise’s continuation) runs on the main thread during the event loop’s poll phase. This is why async reads don’t block anything else — the main thread is free to keep handling other requests, timers, and events while the disk I/O happens elsewhere.

All three APIs return the file contents as a Buffer by default — raw bytes, not a string. If you pass an encoding option (most commonly "utf8"), Node decodes the buffer into a string for you before handing it back. If you omit it, you get a Buffer object, and calling string methods like .toUpperCase() on it will throw.

All of these APIs read the entire file into memory in one shot. That’s simple and fine for config files, small JSON payloads, and typical text files. It is the wrong tool for files that are large or unbounded in size (multi-gigabyte logs, video files, anything you can’t guarantee fits comfortably in RAM) — for those, use a stream instead, which reads the file in small chunks. This lesson’s last example shows streaming for that case.

Syntax

The three read functions share the same shape: a path, optional options, and (for the async forms) a way to get the result back.

fs.readFileSync(path, options)
fs.readFile(path, options, callback)
fsPromises.readFile(path, options)
Part Meaning
path A file path string, a Buffer, a URL, or an open file descriptor (number).
options Either an encoding string like "utf8", or an object: { encoding, flag, signal }. Optional — omit it (or pass null) to get a raw Buffer.
options.encoding e.g. "utf8", "ascii", "base64". When set, the result is a string instead of a Buffer.
options.flag File system flag, default "r" (read, error if it doesn’t exist). Rarely changed for reads.
options.signal An AbortSignal — lets you cancel a pending read (promise/callback forms only).
callback Callback-only. Called as (err, data) — Node.js’s standard error-first pattern.

The synchronous form returns the data directly (or throws on error). The promise form returns a Promise that resolves with the data or rejects with an error — use it with await inside a try/catch.

Examples

Example 1: Reading a JSON config file with promises

This is the recommended default for most application code: readable, non-blocking, and errors are caught naturally with try/catch.

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

async function loadConfig(configPath) {
  try {
    const raw = await fs.readFile(configPath, "utf8");
    return JSON.parse(raw);
  } catch (err) {
    if (err.code === "ENOENT") {
      throw new Error(`Config file not found at ${configPath}`);
    }
    throw err;
  }
}

async function main() {
  const config = await loadConfig("./config.json");
  console.log("Loaded config:", config);
}

main();

Output:

Loaded config: { port: 3000, host: 'localhost' }

(assuming config.json contains {"port": 3000, "host": "localhost"}). The err.code check is important: Node’s file system errors carry a stable code string like ENOENT (no such file), EACCES (permission denied), or EISDIR (path is a directory) — check err.code rather than parsing err.message, which isn’t guaranteed to stay the same across Node versions.

Example 2: Synchronous read at startup

readFileSync is the right call in exactly one common situation: reading something once, before your program starts doing real work, when there’s nothing else for the event loop to be doing yet anyway.

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

const settingsPath = path.join(__dirname, "settings.json");
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));

console.log(`Starting server on port ${settings.port}`);

Output:

Starting server on port 3000

This blocks the thread for the (typically few milliseconds) it takes to read a small settings file, but that happens once at boot, before the server is listening for any requests — nobody is waiting on the event loop yet, so blocking it briefly costs nothing. Using readFileSync anywhere a request could already be in flight is a mistake — see Common Mistakes below.

Example 3: The callback API

You’ll still see this style in older code and in some libraries that predate promises. It’s worth recognizing even though new code should prefer promises.

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

fs.readFile("./notes.txt", "utf8", (err, data) => {
  if (err) {
    console.error("Could not read notes.txt:", err.message);
    return;
  }
  console.log("File contents:", data);
});

console.log("Reading file...");

Output:

Reading file...
File contents: Buy milk, walk the dog.

Notice the order: "Reading file..." logs before the file contents, even though fs.readFile is called first in the source. fs.readFile queues the work and returns immediately; the rest of the synchronous script (the second console.log) finishes first, and the callback only runs later, once libuv reports the read is done.

Example 4: Streaming a large file instead of reading it whole

For files that could be large — a log file that grows for months, a CSV export, anything not bounded in size — avoid readFile entirely and read in chunks with a stream. This example counts the lines in a log file without ever holding the whole thing in memory.

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

async function countLines(filePath) {
  const fileStream = fs.createReadStream(filePath, { encoding: "utf8" });
  fileStream.on("error", (err) => {
    throw err;
  });

  const rl = readline.createInterface({
    input: fileStream,
    crlfDelay: Infinity,
  });

  let count = 0;
  for await (const _line of rl) {
    count++;
  }
  return count;
}

async function main() {
  const total = await countLines("./access.log");
  console.log(`Line count: ${total}`);
}

main();

Output:

Line count: 48213

createReadStream reads the file in small chunks (64 KB by default), so memory use stays flat no matter how big the file is. readline.createInterface layers line-splitting on top of the stream, and the for await...of loop consumes it one line at a time. This is more code than a single readFile call, but it’s the only approach that scales to files bigger than available memory.

Under the Hood: What Happens When You Call readFile

For the async forms (fs.readFile / fsPromises.readFile), here’s the actual order of events:

  1. Your code calls fs.readFile(path, options, callback) (or await fsPromises.readFile(...)).
  2. Node hands the request to libuv, which queues it for its thread pool. The call returns immediately — no data yet.
  3. Your script keeps running synchronously past that line, and the event loop continues processing whatever else is scheduled (other callbacks, timers, incoming requests).
  4. A libuv worker thread performs the actual blocking read() system call against the file system, off the main thread.
  5. When the read finishes, libuv marks the operation complete and, once the main thread reaches the event loop’s poll phase, invokes your callback (or resolves the promise) with the result.
  6. Your callback runs on the main thread like any other JavaScript — synchronously, to completion, before the loop moves on.

The synchronous form skips all of this: the blocking read happens directly on the main thread, and nothing else scheduled on the loop runs until it’s done. That’s the entire difference, and it’s why one line of code (dropping Sync from the method name) can change your server’s behavior under load dramatically.

Common Mistakes

Mistake 1: Using a synchronous read inside a request handler

This blocks the event loop for every other in-flight request while one disk read finishes.

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

http.createServer((req, res) => {
  const data = fs.readFileSync("./big-report.csv", "utf8");
  res.writeHead(200, { "Content-Type": "text/csv" });
  res.end(data);
}).listen(3000);

Every request now waits in line behind whichever one is currently doing the synchronous disk read — under real traffic, this destroys throughput. Use the promise API instead so the read happens off the main thread and other requests keep being served:

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

http.createServer(async (req, res) => {
  try {
    const data = await fs.readFile("./big-report.csv", "utf8");
    res.writeHead(200, { "Content-Type": "text/csv" });
    res.end(data);
  } catch (err) {
    res.writeHead(500, { "Content-Type": "text/plain" });
    res.end("Failed to read report");
  }
}).listen(3000);

Mistake 2: Forgetting the encoding and getting a Buffer

Without an encoding, every read function returns a Buffer, not a string — string methods don’t exist on it.

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

fs.readFile("./greeting.txt", (err, data) => {
  if (err) throw err;
  console.log(data.toUpperCase());
});

This throws TypeError: data.toUpperCase is not a function, because data is a Buffer. Pass an encoding to get a string back:

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

fs.readFile("./greeting.txt", "utf8", (err, data) => {
  if (err) throw err;
  console.log(data.toUpperCase());
});

Mistake 3: Not checking the error before using the data

Skipping the err check in a callback means you’ll try to use data even when the read failed and data is undefined.

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

fs.readFile("./config.json", "utf8", (err, data) => {
  const config = JSON.parse(data);
  console.log(config);
});

If the file is missing, this crashes with SyntaxError: Unexpected token u in JSON at position 0 — a confusing error that hides the real problem. Always check err first and return early:

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

fs.readFile("./config.json", "utf8", (err, data) => {
  if (err) {
    console.error("Failed to read config:", err.message);
    return;
  }
  const config = JSON.parse(data);
  console.log(config);
});

Best Practices

  • Default to fs/promises with async/await for new code — it reads clearly and errors are caught with ordinary try/catch.
  • Reserve readFileSync for one-time startup work (loading config before the server starts listening) or short CLI scripts — never inside a request handler or any code that runs while other work might be in flight.
  • Always pass an explicit encoding (usually "utf8") when you want text; leave it off only when you actually need raw bytes (images, binary formats).
  • Check err.code ("ENOENT", "EACCES", etc.) rather than matching on err.message, which can change between Node versions.
  • For files whose size you don’t control — logs, uploads, exports — use a stream (fs.createReadStream) instead of any readFile variant, so memory use doesn’t scale with file size.
  • Never swallow read errors silently; at minimum log them, and in server code return a proper error response instead of letting the process crash unhandled.
  • Resolve paths with node:path (e.g. path.join(__dirname, "data.json")) rather than string-concatenating paths, so your code works regardless of the current working directory it’s run from.

Practice Exercises

  • Write a script that reads a package.json file with fs/promises and prints just the name and version fields. Handle the case where the file doesn’t exist with a clear error message.
  • Write two versions of a function that reads a text file and counts how many times the word "error" appears: one using fs.readFileSync, one using fsPromises.readFile. Compare how each affects code structure.
  • Using fs.createReadStream and readline, write a script that reads a log file and prints only the lines containing the word "ERROR", without loading the whole file into memory at once.

Summary

  • Node.js offers three ways to read a file: synchronous (readFileSync), callback-based (readFile), and promise-based (fsPromises.readFile).
  • Synchronous reads block the entire JavaScript thread; async reads offload the actual disk I/O to libuv’s thread pool and resume your code via the event loop’s poll phase.
  • All read functions return a Buffer unless you pass an encoding option, in which case you get a string.
  • All of them load the entire file into memory — for large or unbounded files, use a stream instead.
  • Prefer fs/promises for application code; use readFileSync only for one-time startup work; always handle read errors explicitly.