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.