Node.js JSON: Parse, Read, Write, and Stringify

Last updated:

Node.js ships with everything you need to parse, read, write, and stringify JSON — no third-party packages required for most use cases. JSON.parse and JSON.stringify are global built-ins; fs.promises.readFile and fs.promises.writeFile handle file I/O asynchronously. This guide covers every method from basic parsing to streaming large files, BigInt handling, ESM imports, and fetching JSON over HTTP using Node 18+'s built-in fetch.

JSON.parse in Node.js

JSON.parse(text[, reviver]) converts a JSON string into a JavaScript value. It is a built-in global — no import needed. The function throws a SyntaxError for any malformed input, including trailing commas, single-quoted strings, or undefined values. In TypeScript, JSON.parse returns any, which bypasses the type checker; use unknown and an explicit cast instead. V8's JSON parser runs approximately 1 million parses per second for 1 KB payloads in Node 20, making it fast enough for virtually all real-time workloads. The optional reviver function — the second argument — intercepts each key/value pair as the object is being assembled, enabling transformations such as converting ISO date strings back to Date objects.

// Safe parse wrapper — returns a discriminated union
function safeJsonParse<T>(text: string): { ok: true; data: T } | { ok: false; error: string } {
  try {
    return { ok: true, data: JSON.parse(text) as T }
  } catch (err) {
    return { ok: false, error: (err as SyntaxError).message }
  }
}

// Usage
const result = safeJsonParse<{ name: string }>('{"name": "Alice"}')
if (result.ok) console.log(result.data.name)  // → Alice

// Reviver: convert date strings to Date objects
const parsed = JSON.parse('{"ts":"2026-01-01T00:00:00.000Z"}', (key, value) =>
  key === 'ts' ? new Date(value) : value
)
console.log(parsed.ts instanceof Date)  // → true

Reading JSON Files

Node.js offers 3 ways to read a JSON file: synchronous require (CommonJS only), static ESM import (Node 16.14+), and async fs.promises.readFile + JSON.parse. The async approach is preferred for production code because it does not block the event loop. require('./file.json') caches the parsed object in Node's module registry — subsequent calls return the same reference without hitting disk, which is useful for static config files read once at startup. For files larger than 50 MB, skip JSON.parse entirely and use a streaming parser such as JSONStream or stream-json.

import { readFile } from 'node:fs/promises'

// Async file read (recommended — non-blocking)
const text = await readFile('./config.json', 'utf-8')
const config = JSON.parse(text) as AppConfig

// Error handling for both ENOENT and SyntaxError
try {
  const raw = await readFile('./settings.json', 'utf-8')
  const settings = JSON.parse(raw)
} catch (err) {
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
    console.error('File not found')
  } else {
    console.error('Invalid JSON:', (err as SyntaxError).message)
  }
}

// CommonJS (caches the module after first load)
const pkg = require('./package.json')  // parsed automatically

// ESM static import (Node 16.14+)
import configData from './config.json' assert { type: 'json' }

// Read multiple JSON files in parallel
const [a, b] = await Promise.all([
  readFile('./a.json', 'utf-8'),
  readFile('./b.json', 'utf-8'),
]).then(texts => texts.map(t => JSON.parse(t)))

Writing JSON Files

Writing JSON to disk uses fs.promises.writeFile paired with JSON.stringify. Pass null, 2 as the replacer and space arguments to produce human-readable indented output. For data integrity, use an atomic write: write to a temporary file first, then rename it to the target path. On POSIX systems (Linux, macOS), rename is atomic — a crash during the write leaves the original file intact rather than a partial result. The temp file and the target must reside on the same filesystem for rename to be atomic; using os.tmpdir() can break this on some systems if it resolves to a different mount. Append a NDJSON (Newline-Delimited JSON) line with appendFile for log-style streaming writes.

import { writeFile, rename, appendFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'

// Basic write — pretty-printed with 2-space indent
await writeFile('./output.json', JSON.stringify(data, null, 2), 'utf-8')

// Atomic write (prevents partial writes on crash)
async function writeJsonAtomic(filepath: string, data: unknown) {
  const tmp = join(dirname(filepath), `.json-${Date.now()}.tmp`)
  await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8')
  await rename(tmp, filepath)  // atomic on POSIX filesystems
}

// Append NDJSON line (one JSON object per line — useful for logs)
async function appendNdjson(filepath: string, record: unknown) {
  await appendFile(filepath, JSON.stringify(record) + '\n', 'utf-8')
}

JSON.stringify in Node.js

JSON.stringify(value, replacer, space) serializes a JavaScript value to a JSON string. The replacer argument filters or transforms keys: pass an array of strings to include only those keys, or a function to transform each value. The space argument controls indentation: a number (1–10) adds that many spaces per level; a string (up to 10 characters) uses it as the indent. JSON.stringify silently omits keys with undefined, Function, or Symbol values. It calls .toJSON() on any object that defines it — Date objects serialize to ISO 8601 strings this way. BigInt and circular references both throw by default; handle them with a replacer or a library like json-bigint or flatted.

// BigInt handling (JSON.stringify throws TypeError by default)
const data = { id: 9007199254740993n, name: 'item' }
const json = JSON.stringify(data, (key, value) =>
  typeof value === 'bigint' ? value.toString() : value
)
// → {"id":"9007199254740993","name":"item"}

// Date handling — toISOString() is called automatically via .toJSON()
const event = { ts: new Date('2026-01-01'), type: 'login' }
JSON.stringify(event)
// → {"ts":"2026-01-01T00:00:00.000Z","type":"login"}

// Filter keys with array replacer
const user = { id: 1, name: 'Alice', passwordHash: 'secret' }
JSON.stringify(user, ['id', 'name'])
// → {"id":1,"name":"Alice"}   (passwordHash omitted)

// Circular reference detection
import { inspect } from 'node:util'
// util.inspect handles circular refs; for JSON use the 'flatted' package:
// import { stringify } from 'flatted'
// stringify(circularObj)  → safe JSON string

HTTP JSON Requests (fetch)

Node 18 added a built-in global fetch API, removing the need for node-fetch or axios for basic HTTP requests. Call response.json() to read and parse the response body — it is equivalent to JSON.parse(await response.text()). Always check res.ok (which is true for HTTP 200–299) before parsing, because res.json() will successfully parse a {"error":"Not found"} body from a 404 response and return it without throwing. For POST requests, set Content-Type: application/json and pass JSON.stringify(payload)as the body. Node 18's fetch supports AbortController for timeouts, matching browser fetch behavior exactly.

// Node 18+ built-in fetch — GET JSON
const res = await fetch('https://api.example.com/users/1')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const user = await res.json() as User

// POST JSON
const created = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice', role: 'admin' }),
}).then(r => r.json())

// With timeout (AbortController)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 5000)  // 5-second timeout
try {
  const res = await fetch('https://api.example.com/slow', {
    signal: controller.signal,
  })
  const data = await res.json()
} finally {
  clearTimeout(timer)
}

JSON Environment Variables

process.env values are always strings, so JSON-structured environment variables must be parsed with JSON.parse. This pattern is common for passing structured config (feature flags, database replica lists, rate-limit rules) to Node services deployed on platforms like Heroku, Railway, or AWS ECS where environment variables are the primary config mechanism. Always provide a fallback — an empty object or array — so the service starts even when the variable is absent. Validate the parsed structure with a schema library (Zod, Ajv) before using it in production code. The dotenv package loads .env files into process.env, allowing JSON values in .env files: APP_CONFIG={"debug":true,"timeout":30}.

// Parse JSON from environment variable
const config = JSON.parse(process.env.APP_CONFIG ?? '{}') as AppConfig

// Reusable helper with fallback — never throws
function getEnvJson<T>(key: string, fallback: T): T {
  const raw = process.env[key]
  if (!raw) return fallback
  try { return JSON.parse(raw) as T }
  catch { return fallback }
}

// Usage
const flags = getEnvJson<Record<string, boolean>>('FEATURE_FLAGS', {})
const replicas = getEnvJson<string[]>('DB_REPLICAS', [])

// Validate with Zod after parsing
import { z } from 'zod'
const AppConfigSchema = z.object({
  debug: z.boolean(),
  timeout: z.number().min(1).max(60),
})
const appConfig = AppConfigSchema.parse(
  JSON.parse(process.env.APP_CONFIG ?? '{}')
)

Method Comparison

MethodUse caseProsConsWhen to use
require('./file.json')Small configCached, auto-parseSynchronous, module-cacheStatic config files
JSON.parse(await readFile(...))Any sizeAsync, no cacheParse overheadAPI data, user files
import x from './f.json' assert { type: 'json' }Bundled assetsTree-shakableNode 16.14+, experimentalFrontend bundling
JSONStream.parse()>50 MB filesStreaming, low memoryAdds dependencyLarge datasets

Definitions

JSON.parse(text, reviver)
Converts a JSON string into a JavaScript object; the optional reviver function transforms parsed values before they are returned.
reviver function
A callback passed as the second argument to JSON.parse that intercepts each key/value pair, enabling type coercion (e.g., strings to Date objects).
JSON.stringify(value, replacer, space)
Serializes a JavaScript value to a JSON string; replacer filters or transforms keys, space adds indentation.
assert { type: 'json' }
ESM import assertion that tells Node.js the module is JSON (prevents MIME-type confusion attacks); finalized as with { type: 'json' } in the HTML spec.
NDJSON (Newline-Delimited JSON)
A streaming format where each line is a valid JSON document; used for log streams and large dataset transfers.

FAQ

How do I parse JSON in Node.js?

Use the built-in JSON.parse(text) — no imports required. It accepts any valid JSON string and returns the corresponding JavaScript value. It throws a SyntaxError on invalid input, so wrap it in try/catch. In TypeScript, JSON.parse returns any; prefer casting to unknown and using a type guard to maintain type safety. A safe wrapper function returns a discriminated union ({"{ ok: true, data }"} or {"{ ok: false, error }"}) so callers must handle both cases. Node 20 processes approximately 1 million parses per second for 1 KB payloads.

What is the difference between require('./data.json') and JSON.parse(readFileSync(...))?

require('./data.json') parses the file once and caches the result in Node's module registry — subsequent calls return the same in-memory object. It is synchronous and blocks the event loop. It only works in CommonJS. JSON.parse(readFileSync('./data.json', 'utf-8')) reads and parses on every call with no caching, and is also synchronous. For production use, prefer the async pattern: JSON.parse(await readFile('./data.json', 'utf-8')) — it does not block the event loop and works in both CJS and ESM.

How do I read a JSON file asynchronously in Node.js?

Import readFile from 'node:fs/promises', await it with 'utf-8' encoding, then pass the result to JSON.parse. Wrap both in a try/catch to handle ENOENT (file not found) and SyntaxError (invalid JSON) separately — they have different err.code and err.name properties. For reading multiple JSON files at once, use Promise.all to run them in parallel rather than sequentially with await.

Does Node.js support JSON import with ESM?

Yes. Use import data from './config.json' assert { type: 'json' } — available since Node 16.14 (stable). In Node 20.10+ the finalized syntax is with { type: 'json' }. Dynamic import('./config.json') requires an experimental flag in Node 18 but works without one in Node 22+. For maximum compatibility across Node versions, the async readFile + JSON.parse pattern requires no flags and works in both CJS and ESM.

How do I write a JSON file in Node.js?

Use fs.promises.writeFile(path, JSON.stringify(data, null, 2), 'utf-8'). The null, 2 arguments produce pretty-printed, 2-space-indented output. For data integrity, use an atomic write: write to a temp file in the same directory, then rename it to the target path — rename is atomic on POSIX systems, so a crash during the write leaves the original file intact rather than a corrupted partial file.

How do I handle BigInt in JSON.stringify in Node.js?

By default, JSON.stringify throws TypeError: Do not know how to serialize a BigInt. Fix it with a replacer function: JSON.stringify(data, (key, value) => typeof value === 'bigint' ? value.toString() : value). Serializing as a string preserves full precision. Avoid serializing as a number — JavaScript numbers lose precision for integers larger than 2^53 - 1 (9,007,199,254,740,991). The json-bigint npm package provides a drop-in replacement that handles BigInt transparently.

What is the fastest way to parse large JSON files in Node.js?

For files under 50 MB, JSON.parse(await readFile(path, 'utf-8')) is fast and practical. For files over 50 MB, JSON.parse blocks the event loop and may exhaust heap memory. Use a streaming parser: JSONStream (pipe-based, most popular), stream-json (supports complex nesting and NDJSON), clarinet (SAX-like), or oboe.js (functional API). Streaming parsers process chunks incrementally, keeping memory proportional to individual record size rather than the entire file. You can also raise the V8 heap with --max-old-space-size=8192 if synchronous parsing of a single large file is unavoidable.

How do I parse JSON from an HTTP response in Node.js without Express?

In Node 18+, use the built-in fetch: const data = await fetch(url).then(r => r.json()). Always check res.ok before calling res.json() — a 404 or 500 response body will parse successfully but contains an error payload, not your expected data. For POST requests, set Content-Type: application/json and pass JSON.stringify(payload) as the body. For Node 12–17, use the built-in https module (collect chunks, join, parse) or the axios package which works on all versions and handles JSON automatically.

Further reading and primary sources