Reading JSON in Node.js is two lines. Doing it without subtle bugs takes a bit more care. Here's the production-grade version of every common JSON file operation.
The 90% case: read a JSON file
import { readFile } from 'node:fs/promises';
const data = JSON.parse(await readFile('config.json', 'utf-8'));
Two things matter. Pass an encoding ('utf-8') — without it, you get a Buffer and have to decode yourself. Use fs/promises, not the callback API. The error handling is cleaner and it composes with await.
What can go wrong
Three failure modes, all worth distinguishing in your error handling:
try {
const text = await readFile('config.json', 'utf-8');
const data = JSON.parse(text);
return data;
} catch (err) {
if (err.code === 'ENOENT') {
// file doesn't exist
} else if (err.code === 'EACCES') {
// permission denied
} else if (err instanceof SyntaxError) {
// file exists but isn't valid JSON
console.error(`Invalid JSON: ${err.message}`);
} else {
throw err;
}
}
The SyntaxError branch is the one that catches "the file got corrupted" or "someone hand-edited it and broke it." Worth surfacing clearly — paste the file into our validator and it'll show you the line.
The 90% case: write a JSON file
import { writeFile } from 'node:fs/promises';
await writeFile('config.json', JSON.stringify(data, null, 2));
The null, 2 arguments mean "no custom replacer, indent with 2 spaces" — produces human-readable output. For machine-consumed files, drop them: JSON.stringify(data) is the minified version.
Writing safely (atomic writes)
A plain writeFile can corrupt the file if your process is killed mid-write. The fix is well-known: write to a temp file, then rename:
import { writeFile, rename } from 'node:fs/promises';
import { dirname, basename, join } from 'node:path';
async function writeJsonAtomic(path, data) {
const tmp = join(dirname(path), \`.\${basename(path)}.tmp\`);
await writeFile(tmp, JSON.stringify(data, null, 2));
await rename(tmp, path); // atomic on POSIX
}
rename is atomic at the filesystem level on Linux and macOS. The destination file is either the old version or the new version — never partial. This pattern is what almost every config-file manager uses (npm, git, your editor's autosave).
Modifying part of a JSON file
For small files, read-modify-write is fine:
const data = JSON.parse(await readFile(path, 'utf-8'));
data.lastRun = new Date().toISOString();
await writeJsonAtomic(path, data);
If two processes can write the same file, you need locking. Use the proper-lockfile package — don't try to invent it:
import lockfile from 'proper-lockfile';
const release = await lockfile.lock(path);
try {
const data = JSON.parse(await readFile(path, 'utf-8'));
data.lastRun = new Date().toISOString();
await writeJsonAtomic(path, data);
} finally {
await release();
}
Reading JSON Lines (NDJSON)
For multi-record files, prefer JSON Lines — one JSON object per line — over a giant top-level array. Lines stream; arrays don't.
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const stream = createReadStream('events.ndjson');
const rl = createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
if (!line.trim()) continue;
const record = JSON.parse(line);
process(record);
}
This processes records as they're read — constant memory regardless of file size. Use it for logs, event streams, exports.
Reading a huge JSON array without OOM
If you can't change the source from JSON to NDJSON, use a streaming parser like stream-json:
npm install stream-json
import { createReadStream } from 'node:fs';
import StreamArray from 'stream-json/streamers/StreamArray.js';
const pipeline = createReadStream('huge.json').pipe(StreamArray.withParser());
for await (const { value } of pipeline) {
process(value); // each array element, one at a time
}
This walks a multi-gigabyte array in megabytes of memory. See our deeper guide on handling large JSON.
The require() trick (and when to avoid it)
In CommonJS, require('./config.json') works — Node parses JSON when the file extension is .json. It's also cached: subsequent requires return the same object, mutations leak globally.
For configs that load once at startup, this is fine and convenient. For data files that change at runtime, use readFile — require's cache will give you stale data.
In ESM, the equivalent is the import assertion: import config from './config.json' assert { type: 'json' } — also one-time and cached.
JSON5, JSONC, and YAML
If your config files have comments and trailing commas (because humans edit them), you can't use the built-in JSON.parse. Options: parse .json5 with the json5 package, parse .jsonc (VS Code-style) with jsonc-parser, or commit to YAML and use js-yaml. Pick one, document it, don't mix.
Wrap-up
For routine work: readFile + JSON.parse, writeFile + JSON.stringify, atomic rename for writes that matter. For big files: NDJSON if you control the format, stream-json if you don't. Wrap the failure modes in your error handler and you've covered the realistic scenarios.