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 readFilerequire'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.