JSON Internals, Serialization, and Schema Validation19 min read
Module P-13·19 min read
JSON.parse/stringify edge cases, streaming JSON for large payloads, JSON Schema with Ajv, MessagePack as a binary alternative, and the serialization cost at scale.
What this module covers: JSON is everywhere in Node.js — request bodies, response payloads, configuration files, inter-service communication. Most developers treat it as a black box and pay for it later with silent data loss, unexpected type coercions, and performance problems at scale. This module covers what JSON.parse and JSON.stringify actually do under the hood, the edge cases that corrupt data silently, streaming JSON for payloads too large to hold in memory, fast serialization with fast-json-stringify, JSON Schema validation with Ajv, and when MessagePack is worth the switch.
JSON is a text format. JSON.parse converts a string into a JavaScript object. JSON.stringify does the reverse. The key word is string — everything must pass through text.
This matters because JavaScript has types that JSON doesn't:
// Date loses its type const date = new Date('2024-05-22'); const roundTripped = JSON.parse(JSON.stringify({ date })); typeof roundTripped.date; // 'string', not 'object' roundTripped.date instanceof Date; // false
// BigInt crashes JSON.stringify({ id: 9007199254740993n }); // TypeError: Do not know how to serialize a BigInt
// Circular reference crashes const a: any = {}; const b: any = { a }; a.b = b; JSON.stringify(a); // TypeError: Converting circular structure to JSON
JSON.stringify silently dropping undefined and turning NaN/Infinity into null is like a customs form that quietly discards anything it doesn't have a box for — nothing errors, the paperwork just arrives incomplete.
The table above shows BigInt throwing on JSON.stringify — a loud, immediate failure you can't miss. A plain number above Number.MAX_SAFE_INTEGER is the dangerous version of the same problem, because it doesn't throw at all:
const wei = 9007199254740993; // one more than Number.MAX_SAFE_INTEGER (2^53 - 1) JSON.parse(JSON.stringify(wei)); // → 9007199254740992 — silently rounded to the nearest representable double, no warning
A token-transfer indexer once stored raw wei amounts as a plain JS number instead of BigInt or a string. Every time a transfer record round-tripped through an internal message queue as JSON, any amount above the safe-integer threshold silently rounded to the nearest representable double. The drift was only a few hundred wei per affected value — invisible in unit tests built around small fixture numbers — until a production reconciliation audit found balances that didn't match the chain, well after the rounding had been happening quietly in production for a while.
Large numeric IDs and monetary amounts (wei, satoshis, cents at scale) should be BigInt or a string from the moment they're read out of the database — never a plain number that happens to fit in dev with smaller test fixtures.
Every byte becomes a decimal number in an array. This bites in practice when a field that's a Buffer under the hood — a Prisma Bytes column, the output of crypto.randomBytes(), a file read with fs.readFileSync() without an encoding — passes straight into res.json() without anyone noticing its real type:
const user = { id: 1, name: 'Jatin', apiKeyHash: crypto.randomBytes(32) }; res.json(user); // apiKeyHash silently becomes {"type":"Buffer","data":[12,45,201,...]} — // a 32-byte key balloons into a 100+ element array in the response body, // and the raw key material is now sitting in an HTTP response
Nothing errors. The response just gets bigger, uglier, and — if the buffer held anything sensitive (key material, a password hash, an internal token) — leakier than intended. The fix is the same discipline as any other field you don't want serialised as-is: convert explicitly (apiKeyHash.toString('base64')) or exclude it via the replacer whitelist pattern shown below.
Deep-cloning with JSON.parse(JSON.stringify(x)) inherits every problem in this section — Dates become strings, Map/Set become {}, undefined disappears. Node 17+ ships a built-in structuredClone() that performs a real deep clone using the structured clone algorithm instead of a JSON round-trip:
const original = { createdAt: new Date(), tags: new Set(['a', 'b']), metadata: new Map([['key', 'value']]), count: undefined, };
const jsonClone = JSON.parse(JSON.stringify(original)); // jsonClone.createdAt is a string; jsonClone.tags and jsonClone.metadata are {}; count is gone
const realClone = structuredClone(original); // realClone.createdAt instanceof Date === true // realClone.tags instanceof Set === true // realClone.metadata instanceof Map === true // 'count' in realClone === true (preserved, value undefined)
structuredClone even handles circular references natively — something JSON.stringify can't do at all without the WeakSet workaround below. It does have limits: it throws on functions, and cloning a custom class instance gives you back a plain object with the same own properties, not an instance of that class — the prototype chain doesn't survive. For plain data (API payloads, config objects, anything already JSON-shaped), it's the better default; reach for a real cloning library only when you need class instances or functions preserved too.
BigInt appears frequently when working with PostgreSQL's BIGSERIAL primary keys or blockchain IDs that exceed Number.MAX_SAFE_INTEGER (2⁵³ - 1 ≈ 9 quadrillion):
JSON.parse itself is not the vulnerability. Parsing '{"__proto__":{"isAdmin":true}}' produces an object with an own, enumerable property literally named __proto__ — it does not walk up and mutate Object.prototype:
const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}'); Object.getPrototypeOf(parsed) === Object.prototype; // true — untouched parsed.__proto__; // { isAdmin: true } — a plain data property here ({}).isAdmin; // undefined — not polluted by JSON.parse alone
The danger shows up one step later, in code that takes that parsed object and merges it into something else — a config loader, an options-merging helper, any hand-rolled "deep extend":
// A common, naive deep-merge — the kind hand-rolled in a dozen internal utils function merge(target: any, source: any) { for (const key in source) { if (typeof source[key] === 'object' && source[key] !== null) { target[key] = target[key] || {}; merge(target[key], source[key]); // recurses straight into __proto__ } else { target[key] = source[key]; } } return target; }
({}).isAdmin; // true — every plain object in the process now inherits isAdmin
target[key] = ... with key === '__proto__' triggers the real prototype accessor — bracket-notation assignment behaves differently from the object construction JSON.parse does internally — so the merge walks up and mutates Object.prototype itself. From that point on, every plain object in the process, including ones created in completely unrelated request handlers, inherits isAdmin: true, which can silently defeat an if (user.isAdmin) check anywhere in the codebase. This is the exact class of bug behind real CVEs in lodash.merge, jQuery.extend, and several config-merging npm packages.
Mitigations:
Validate before merging. A schema with additionalProperties: false (the Ajv section below) rejects unexpected keys like __proto__ and constructor before they ever reach a merge function.
Strip dangerous keys in a reviver, as a defense-in-depth layer on any JSON.parse of untrusted input:
// In your controller — faster than JSON.stringify for high-throughput routes res.setHeader('Content-Type', 'application/json'); res.end(serializeUser(user));
Use this on high-traffic endpoints where you know the exact response shape. Not worth the complexity for infrequently called routes.
Loading a 100,000-row query result into memory, calling JSON.stringify, and writing the result is a memory spike waiting to happen. Streaming processes rows as they arrive from the database:
npm install JSONStream
// src/controllers/export.controller.ts import { asyncHandler } from '../utils/asyncHandler.js'; import { Readable } from 'stream'; import JSONStream from 'JSONStream'; import { pipeline } from 'stream/promises'; import prisma from '../db/prisma.js';
// Prisma Client has no built-in streaming query method — there is no // `findManyStream`. Prisma does not expose a database-level cursor you can // pipe from directly. The real workaround is cursor-based pagination: // fetch fixed-size pages with `take` + `cursor`, and push each page's rows // into a Readable manually so the full result set is never held in memory. const PAGE_SIZE = 500; let cursor: number | undefined;
if (page.length === 0) { this.push(null); // no more rows — end the stream return; }
cursor = page[page.length - 1].id; for (const row of page) this.push(row); }, });
// Wrap individual objects into a JSON array, streamed const jsonStringifier = JSONStream.stringify('[', ',', ']');
await pipeline(userStream, jsonStringifier, res); // Memory usage: bounded by PAGE_SIZE, not the total row count — // only one page of rows is ever held in memory at a time. });
Unlike raw pg, Prisma doesn't give you a true database-level cursor to pipe from — the Readable above simulates one by paginating. When a true DB-level stream matters (very large exports, tight memory budgets), reach for pg-query-stream with raw SQL instead:
npm install pg-query-stream
import QueryStream from 'pg-query-stream'; import { pool } from '../db/pool.js'; import JSONStream from 'JSONStream'; import { pipeline } from 'stream/promises';
Ajv (Another JSON Schema validator) is the fastest JSON Schema validator for Node.js. JSON Schema is the standard for describing the structure of JSON data — more portable than Zod (which is TypeScript-specific) and used widely in OpenAPI and form validation.
npm install ajv ajv-formats
import Ajv from 'ajv'; import addFormats from 'ajv-formats';
const ajv = new Ajv({ allErrors: true }); // collect all errors, not just the first addFormats(ajv); // adds 'email', 'date-time', 'uri', 'uuid' formats
// Compile once — reuse the compiled validator const validateCreateUser = ajv.compile(createUserSchema);
// Use in middleware or directly function validate(schema: object) { const validate = ajv.compile(schema); return (req, res, next) => { if (validate(req.body)) return next();
Ajv vs Zod: Zod infers TypeScript types and is designed for TypeScript projects. Ajv follows the JSON Schema standard and is faster — it compiles schemas to optimised validation functions. For Node.js APIs already using Zod (as in P-4), stick with Zod. Ajv shines when you need JSON Schema portability (shared schemas between frontend/backend) or raw performance.
MessagePack is a binary serialisation format that is 20–50% smaller than JSON and faster to serialise/deserialise. It supports types that JSON doesn't: integers, floats, binary data, timestamps.
When to consider it:
High-frequency inter-service communication (microservices)
Real-time applications where payload size affects latency
Storing structured data in Redis (smaller footprint)
npm install @msgpack/msgpack
import { encode, decode } from '@msgpack/msgpack';
// Serialise const data = { userId: 42, timestamp: new Date(), values: [1, 2, 3] }; const packed = encode(data); // Uint8Array — binary, smaller than JSON string
// Deserialise const unpacked = decode(packed) as typeof data;
For most REST APIs serving browsers, JSON is the right choice — browsers speak JSON natively, the bandwidth difference is negligible, and debuggability is far better. MessagePack is the right choice for Node-to-Node communication where you control both ends and need to squeeze out latency.
JSON.parse parses the entire string synchronously. On the main thread. A 1MB request body takes ~5ms. At 1,000 req/s, that's 5 seconds of JSON parsing per second on one thread.
Strategies for hot paths:
Validate size before parsing — reject oversized bodies early (express.json({ limit: '10kb' }))
Use fast-json-stringify for serialisation on high-throughput routes
Cache parsed results when the same JSON is parsed repeatedly (e.g., static configuration)
Move heavy parsing to a worker thread for CPU-intensive transformations
// Naive version — spawns a brand-new OS thread on every single call import { Worker } from 'worker_threads';
function parseInWorker(json: string): Promise<unknown> { return new Promise((resolve, reject) => { const worker = new Worker(__filename, { workerData: { json } }); worker.on('message', resolve); worker.on('error', reject); }); }
Spawning a Worker per call looks fine in a demo but costs real time on every request — a new thread means a new V8 isolate, a new event loop, and module re-evaluation, typically single-digit milliseconds of pure startup overhead before any actual parsing happens. At any real request volume, that overhead dominates the time you were trying to save. The fix is a small, fixed pool of workers that stay alive for the process's lifetime, with each request handed to whichever worker is free — startup cost is paid once at boot, not per request:
npm install piscina
// src/workers/json-transform.worker.ts — the actual work, run inside pool threads module.exports = (json: string) => { return expensiveTransformation(json); };
// src/utils/jsonWorkerPool.ts — one pool, created once at module load, reused for every call import Piscina from 'piscina'; import path from 'path';
const pool = new Piscina({ filename: path.resolve(__dirname, '../workers/json-transform.worker.js'), minThreads: 2, maxThreads: 4, // size to CPUs available to the container, not the host });
export function parseInWorker(json: string): Promise<unknown> { return pool.run(json); // dispatched to an already-running worker — no thread spawn per call }
Piscina manages the pool's lifecycle and queues tasks when every worker is busy. A hand-rolled fixed pool (an array of pre-spawned Worker instances with a round-robin or least-busy dispatcher) works too — the point is reusing threads across calls, not creating one per request.
undefined, NaN, Infinity, BigInt, Date, Map, Set all behave unexpectedly with JSON.stringify. Know the conversion table.
BigInt throws; serialise it as a string. Dates become strings; use a reviver to restore them. Circular references throw; use a replacer with a WeakSet.
replacer as an array is the cleanest way to whitelist response fields — prevents sensitive data leaking from the serialisation layer.
fast-json-stringify pre-compiles serialisers from schemas — 4× faster than built-in JSON.stringify for high-throughput, fixed-shape responses.
Stream JSON for large exports using JSONStream.stringify piped through Prisma/pg stream results — constant memory regardless of result set size.
Ajv is fastest for JSON Schema validation; portable across languages and tools. Use it when you need JSON Schema portability. Use Zod when you want TypeScript inference.
MessagePack is 20–50% smaller and faster than JSON. Worth it for Node-to-Node communication. Not worth it for browser-facing APIs.
Next: Dockerizing Node.js applications for production — multi-stage builds, Alpine vs slim images, non-root users, health checks, and the production readiness checklist for containerised deployments.
Knowledge Check
When dealing with a large result set from a database in Node.js, what is the best way to return it as JSON to avoid high memory consumption?
Which statement correctly describes the difference between Zod and Ajv in Node.js?
What is a primary reason to consider MessagePack over JSON in a Node.js microservice architecture?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.