node:fs exposes three APIs for the same operations: promise-based (fs/promises), callback-based, and synchronous. Use the promise API for everything in a running server, the sync API only at startup, and streams whenever the file is large enough that holding it in memory is a bad idea.
The three APIs are the source of most confusion here, because every tutorial picks a different one and the names are nearly identical. readFile, readFile with a callback, and readFileSync are three different functions from three different objects.
The choice matters more than it looks. A single readFileSync in a request handler stops your server serving anything else while the disk works.
Table of contents
- The three APIs
- The operations you will actually use
- Streams, for anything large
- Paths, and the bugs they cause
- Error codes worth recognising
- How this fits the rest of the stack
- FAQ
The three APIs
// promise-based -- use this
import { readFile, writeFile } from 'node:fs/promises';
const data = await readFile('config.json', 'utf8');
// callback-based -- the original
import { readFile } from 'node:fs';
readFile('config.json', 'utf8', (err, data) => {
if (err) throw err;
});
// synchronous -- blocks everything
import { readFileSync } from 'node:fs';
const data = readFileSync('config.json', 'utf8');
Use the node: prefix. It makes the built-in explicit and cannot be shadowed by a package named fs in node_modules.
The 'utf8' argument is what separates a string from a Buffer. Omit it and you get raw bytes, which is right for images and wrong for JSON — and produces the [object Object] confusion people hit when concatenating.
Sync versions are fine in exactly two situations:
- At startup, before the server accepts connections. Loading configuration synchronously is simpler and blocks nothing that matters.
- In CLI scripts that do one thing and exit, where there is no concurrency to protect.
Everywhere else — request handlers, background jobs, anything in a long-running process — the sync API halts the event loop. Node is single-threaded for your JavaScript, so a 200ms synchronous read is 200ms during which no other request progresses.
The operations you will actually use
import {
readFile, writeFile, appendFile, rm, mkdir,
readdir, stat, access, rename, copyFile,
} from 'node:fs/promises';
import { constants } from 'node:fs';
await writeFile('out.txt', 'content'); // create or overwrite
await appendFile('log.txt', 'line\n'); // append
await mkdir('a/b/c', { recursive: true }); // like mkdir -p
await rm('dir', { recursive: true, force: true }); // like rm -rf
await rename('old.txt', 'new.txt');
await copyFile('src.txt', 'dst.txt');
{ recursive: true } on mkdir also makes it succeed when the directory already exists, which removes the need for an existence check. force: true on rm does the same for a missing target.
Listing a directory with type information in one call, rather than a stat per entry:
const entries = await readdir('.', { withFileTypes: true });
for (const e of entries) {
console.log(e.isDirectory() ? `dir ${e.name}` : `file ${e.name}`);
}
And the check people get wrong — do not test for existence before acting:
// race condition: the file can vanish between the check and the read
if (existsSync(path)) {
const data = await readFile(path, 'utf8');
}
// correct: attempt it and handle the failure
try {
const data = await readFile(path, 'utf8');
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
Node’s own documentation recommends against fs.access for this reason. Handle the error, do not pre-check.
Streams, for anything large
readFile loads the entire file into memory. For a 2GB file on a container with a 512MB limit, that is an out-of-memory crash rather than a slow operation.
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';
await pipeline(
createReadStream('large.log'),
createGzip(),
createWriteStream('large.log.gz'),
);
That processes the file in chunks with roughly constant memory use regardless of size. Use pipeline from stream/promises rather than chaining .pipe() — it propagates errors and cleans up handles properly, which manual piping does not.
For line-by-line processing:
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const rl = createInterface({
input: createReadStream('large.log'),
crlfDelay: Infinity,
});
for await (const line of rl) {
if (line.includes('ERROR')) console.log(line);
}
crlfDelay: Infinity makes it handle Windows line endings correctly, which is worth setting by default.
A reasonable rule: files under a few megabytes, readFile is simpler and fine. Anything user-supplied or unbounded, stream it — because you do not control how large it will be.
Paths, and the bugs they cause
Relative paths resolve against the process’s working directory, not the file containing the code. A script that works when run from the project root fails from anywhere else.
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM: __dirname does not exist
const __dirname = dirname(fileURLToPath(import.meta.url));
const configPath = join(__dirname, 'config.json');
Always build paths with path.join rather than string concatenation. It handles separators across platforms and normalises duplicate slashes.
The security case matters if any path segment comes from user input. Without a check, ../../etc/passwd is a valid filename:
import { resolve, relative, isAbsolute } from 'node:path';
function safeJoin(base, userPath) {
const target = resolve(base, userPath);
const rel = relative(base, target);
if (rel.startsWith('..') || isAbsolute(rel)) {
throw new Error('path traversal attempt');
}
return target;
}
Resolve first, then verify the result is still inside the base directory. Checking the input string for .. before resolving is not sufficient — URL encoding and symlinks both defeat it.
Error codes worth recognising
fs errors carry a code property, and branching on that is more reliable than parsing the message:
ENOENT— no such file or directory. The most common by a wide margin.EACCES— permission denied.EISDIR— you tried to read a directory as a file.ENOTDIR— a path component that should be a directory is not.EMFILE— too many open file descriptors. This means you are leaking handles.ENOSPC— the disk is full.EEXIST— the file already exists, from an exclusive-flag write.
try {
await readFile(path, 'utf8');
} catch (err) {
switch (err.code) {
case 'ENOENT': return defaultConfig;
case 'EACCES': throw new Error(`cannot read ${path}: permission denied`);
default: throw err;
}
}
EMFILE deserves special mention because it appears under load rather than in testing. It usually means streams are being created without being consumed or closed — every unclosed handle counts against the process limit until it is exhausted.
How this fits the rest of the stack
The recurring theme in fs is that the convenient call and the correct one diverge as soon as size or concurrency becomes real. readFileSync is fine until it is in a request handler; readFile is fine until the file is user-supplied. Both work perfectly in development and fail under load.
That gap is only visible with logs and metrics from the running service, which is where an out-of-memory crash or a descriptor leak actually shows itself. RunxBuild runs Node services from a GitHub repository with runtime logs and metrics per deploy, persistent storage that attaches to the service, and autoscaling between plans you choose — so a memory ceiling is something you can see being hit rather than infer from a restart. The RunxBuild hosting calculator shows the service, storage and bandwidth as separate figures.
Useful related references:
- Cron in Node.js: node-cron, node-schedule, and System Cron
- ioredis vs node-redis: Which to Ship With
- Deploy a Node.js API for Free on RunxBuild
- Node services on RunxBuild
FAQ
Should I use fs, fs/promises, or the sync functions?
Use fs/promises for everything in a running server. Use the sync functions only at startup before accepting connections, or in short CLI scripts. The callback API works but produces harder-to-read code than promises with async/await.
Why does readFileSync slow down my server?
Node runs your JavaScript on a single thread, so a synchronous file read blocks the event loop for its entire duration. During a 200ms sync read, no other request progresses. The promise API yields to other work while the disk operation completes.
How do I read a large file without running out of memory?
Use createReadStream with pipeline from node:stream/promises, which processes the file in chunks at roughly constant memory use. readFile loads the whole file into memory, which crashes the process for files larger than the container’s limit.
Why is __dirname undefined in my Node project?
It does not exist in ES modules. Derive it with dirname(fileURLToPath(import.meta.url)). Relative paths resolve against the process working directory rather than the file’s location, so building paths from that derived value is what makes a script runnable from anywhere.
What does EMFILE mean in Node?
Too many open file descriptors — the process has hit its limit. It usually means streams are being created without being consumed or closed, so handles leak until exhaustion. It typically appears under load rather than in testing, which is what makes it hard to catch early.