Skip to main content

Validating Deeply Nested JSON Payloads in Node.js

Production Node.js services that accept recursive or structurally deep JSON payloads intermittently crash with RangeError: Maximum call stack size exceeded or return slow 500 responses under moderate load. This guide extends the Handling Complex Nested Objects in API Schemas cluster with exact diagnostic steps and production-grade fixes: a pre-parse size guard, a depth-check function, an Ajv 8 compiled validator with bounded schema constraints, and a Zod 3.23 recursive schema with an explicit depth limit.

Defensive validation pipeline for deeply nested JSON A request body passes through four sequential guards: a size limit on the raw string, JSON.parse, a depth-check function, and the compiled Ajv or Zod validator. Each stage has an early-reject path that returns a 400 before proceeding to the next stage. Size guard Content-Length JSON.parse raw → object Depth check ≤ 32 levels Compiled validator Handler safe data 413 400 400 422 Defensive validation pipeline each gate rejects early — the compiled validator only sees safe, bounded input

Symptom

Services intermittently crash with the following trace when processing recursive payloads or imports from clients that generate deeply nested JSON (comment trees, org charts, nested rule groups, GraphQL-style batched mutations):

RangeError: Maximum call stack size exceeded
    at Object.validate (node_modules/ajv/dist/compile/validate/index.js:45:12)
    at Layer.handle [as handle_request] (node_modules/express/lib/router/layer.js:95:5)

Observable signals before the crash:

  • Request latency climbs from a baseline of ~12 ms to >2 000 ms per validation cycle.
  • Event Loop Utilization (ELU) from perf_hooks.monitorEventLoopDelay sustains above 95 % during ingestion peaks, which triggers downstream circuit breakers.
  • PM2 or Docker container logs show the same RangeError repeating across worker processes with no clear correlation to payload content type — only to payload size or structural depth.
  • Memory usage stays flat (this is a stack issue, not a heap leak).

The crashes correlate directly with payloads that exceed roughly 50 levels of nesting — the exact threshold depends on V8 version and function frame size, but 50 is the practical danger zone.

Root Cause

Default JSON Schema validators — Ajv, Zod, Joi — resolve object graphs via recursive function calls. V8 enforces a call stack ceiling of approximately 10 000–15 000 frames depending on function size. A payload nested 60 or 80 levels deep, each holding an array of children, generates that many frames before the deepest leaf is reached, exhausting the stack.

Three compounding factors make this worse in practice:

Validators compile to recursive code. Ajv 8 compiles a JSON Schema into a JavaScript function. That function calls itself (or a sibling function) for each nested $ref or recursive schema branch. The compiled code is fast, but it inherits V8’s recursion limit.

JSON.parse runs synchronously on the main thread. Before the validator even runs, the built-in JSON.parse deserializes the raw string into an object tree. On very deep payloads this also consumes stack frames, and because it blocks the event loop, all concurrent I/O stalls.

Schemas without maxItems or depth guards are unbounded. A recursive $ref back to the parent schema is valid OpenAPI and JSON Schema. Without a runtime depth cap, the validator will attempt to traverse whatever depth the client sends.

The result is that a single malformed or adversarial request can knock out a worker process entirely.

Step-by-Step Fix

Step 1 — Add a raw-body size guard before JSON.parse

The cheapest rejection is on the raw string before any parsing. In Express, express.json() accepts a limit option that rejects oversized bodies with HTTP 413 before they reach your handlers. Set it explicitly rather than relying on the default 100 kb:

// app.ts — Ajv 8.17, Express 4.x, Node.js 20+
import express from "express";

const app = express();

// Reject bodies larger than 500 KB before JSON.parse runs.
// Why this works: the middleware reads Content-Length and aborts
// before deserializing, keeping JSON.parse off the stack entirely.
app.use(express.json({ limit: "500kb" }));

For Fastify, the equivalent is bodyLimit on the server instance:

// server.ts — Fastify 4.x
import Fastify from "fastify";

const fastify = Fastify({ bodyLimit: 512_000 }); // bytes

This prevents the most obvious denial-of-service vector — a client sending a 10 MB JSON blob — but does not protect against a small payload that is extremely deep.

Step 2 — Write a depth-check function and run it before validation

After JSON.parse produces an object, count nesting levels before handing the result to any validator. The check must be iterative or bounded-recursive — a naive recursive walker suffers the same stack problem it is meant to prevent.

// depth.ts

/**
 * Returns the maximum nesting depth of a JSON value.
 * Uses an explicit stack to avoid recursive call-frame exhaustion.
 * Why this works: the while-loop replaces recursive calls with heap
 * allocations (the `stack` array), which do not consume call frames.
 */
export function measureDepth(value: unknown): number {
  if (typeof value !== "object" || value === null) return 0;

  let maxDepth = 0;
  // Each entry is [node, currentDepth]
  const stack: Array<[unknown, number]> = [[value, 1]];

  while (stack.length > 0) {
    const [node, depth] = stack.pop()!;
    if (depth > maxDepth) maxDepth = depth;

    if (typeof node === "object" && node !== null) {
      const children = Array.isArray(node)
        ? node
        : Object.values(node as Record<string, unknown>);

      for (const child of children) {
        if (typeof child === "object" && child !== null) {
          stack.push([child, depth + 1]);
        }
      }
    }
  }

  return maxDepth;
}

export const MAX_DEPTH = 32; // document in your OpenAPI spec as x-depth-limit

export function assertDepth(value: unknown): void {
  const d = measureDepth(value);
  if (d > MAX_DEPTH) {
    const err = new Error(
      `Payload nesting depth ${d} exceeds maximum of ${MAX_DEPTH}`
    );
    (err as NodeJS.ErrnoException).code = "PAYLOAD_TOO_DEEP";
    throw err;
  }
}

Wire the guard into your Express middleware chain after body parsing:

// middleware/depthGuard.ts
import { Request, Response, NextFunction } from "express";
import { assertDepth } from "../depth";

export function depthGuard(req: Request, res: Response, next: NextFunction) {
  try {
    assertDepth(req.body);
    next();
  } catch (err: unknown) {
    if (
      err instanceof Error &&
      (err as NodeJS.ErrnoException).code === "PAYLOAD_TOO_DEEP"
    ) {
      return res.status(400).json({
        code: "PAYLOAD_TOO_DEEP",
        message: err.message,
      });
    }
    next(err);
  }
}

// app.ts
app.use(express.json({ limit: "500kb" }));
app.use(depthGuard);           // runs before any route validator

The iterative measureDepth function above runs in O(n) time where n is the total number of nodes, and uses O(n) heap — acceptable because rejecting an oversized payload is not on your hot path.

Step 3 — Compile an Ajv 8 validator once at startup

Ajv 8 compiles a JSON Schema into an optimized JavaScript function. Calling ajv.compile() is expensive — it parses the schema, resolves $ref chains, and generates code. Recompiling inside a request handler wastes CPU and defeats the entire point of Ajv’s design. Compile once at module scope and reuse the function.

// validators/order.ts
import Ajv from "ajv";           // ajv 8.17
import addFormats from "ajv-formats"; // ajv-formats 3.0

const ajv = new Ajv({
  strict: true,     // error on unknown keywords — catches schema typos
  allErrors: false, // stop at first error: O(1) exit for rejection paths
  discriminator: true, // enable fast oneOf branch selection via discriminator
});
addFormats(ajv);

// The schema for a recursive tree of comment nodes.
// maxItems at each level caps breadth; depth is capped at runtime (Step 2).
const commentSchema = {
  $schema: "https://json-schema.org/draft/2020-12",
  $defs: {
    Comment: {
      type: "object",
      required: ["id", "body"],
      additionalProperties: false,
      properties: {
        id:       { type: "string", format: "uuid" },
        body:     { type: "string", maxLength: 10_000 },
        author:   { type: "string" },
        children: {
          type: "array",
          maxItems: 50,           // bound breadth at every level
          items: { $ref: "#/$defs/Comment" }, // deliberate self-reference
        },
      },
    },
  },
  $ref: "#/$defs/Comment",
};

// Compile ONCE. This function is reused for every request.
// Why this works: compilation generates a JS function Ajv caches;
// subsequent calls to validateComment() run in nanoseconds.
export const validateComment = ajv.compile(commentSchema);
// routes/comments.ts
import { validateComment } from "../validators/order";
import { formatAjvErrors } from "../errors"; // see Verification section

router.post("/comments", async (req, res) => {
  // req.body is already depth-checked by depthGuard middleware (Step 2)
  if (!validateComment(req.body)) {
    return res.status(422).json(
      formatAjvErrors(validateComment.errors ?? [])
    );
  }
  // req.body is now typed-safe for business logic
  await commentService.create(req.body);
  return res.status(201).end();
});

additionalProperties: false must appear at every $defs level — setting it only on the root lets unknown keys slip through nested objects undetected. Set allErrors: false for gating paths; flip to true only where a human needs the full error list.

Step 4 — Model recursive schemas in Zod with z.lazy() and a depth guard

If your project uses Zod for runtime validation rather than Ajv, model recursive types with z.lazy() to defer self-reference evaluation. Chain a .refine() that calls the same iterative measureDepth function from Step 2, so the depth guard is part of the schema contract and cannot be bypassed by a caller who forgets to apply middleware.

// schemas/comment.ts — Zod 3.23
import { z } from "zod";
import { measureDepth, MAX_DEPTH } from "../depth";

// Declare the TypeScript type first so z.lazy() has a concrete type to wrap.
type Comment = {
  id: string;
  body: string;
  author?: string;
  children?: Comment[];
};

// z.lazy() defers evaluation of the self-reference until parse time.
// Why this works: without lazy(), the object schema would try to reference
// CommentSchema before it is fully constructed, producing a ReferenceError.
const CommentShape: z.ZodType<Comment> = z.lazy(() =>
  z
    .object({
      id:       z.string().uuid(),
      body:     z.string().max(10_000),
      author:   z.string().optional(),
      children: z.array(CommentShape).max(50).optional(),
    })
    .strict() // additionalProperties: false equivalent
);

// Wrap with a depth guard so the contract is self-enforcing.
export const CommentSchema = CommentShape.refine(
  (val) => measureDepth(val) <= MAX_DEPTH,
  {
    message: `Payload exceeds maximum nesting depth of ${MAX_DEPTH}`,
    path: [],
  }
);

export type Comment = z.infer<typeof CommentSchema>;

Use safeParse in request handlers so errors are returned rather than thrown. See modeling polymorphic types with oneOf in OpenAPI when the comment node itself needs to be polymorphic (for example, distinguishing plain text from rich-text nodes via a discriminator).

// routes/comments.ts — Zod path
import { CommentSchema } from "../schemas/comment";

router.post("/comments", (req, res) => {
  // Body has already passed the size guard and middleware depth guard.
  // The .refine() inside CommentSchema provides a second depth assertion
  // that fires even if the middleware is bypassed in tests or scripts.
  const result = CommentSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(422).json({
      code: "VALIDATION_FAILED",
      fieldErrors: result.error.flatten().fieldErrors,
    });
  }
  return commentService.create(result.data);
});

Before / After

The table below contrasts the before state (ad-hoc inline validation compiled per request, no depth limit) with the after state (depth guard + compiled once, bounded schema):

// BEFORE — compiled inside handler, no depth limit, crashes on deep payloads
router.post("/comments", async (req, res) => {
  const ajv = new Ajv();                   // new instance per request
  const schema = buildSchema();            // re-parsed per request
  const valid = ajv.validate(schema, req.body); // RangeError at ~50 levels
  if (!valid) return res.status(422).json(ajv.errors);
  await commentService.create(req.body);
});

// AFTER — compiled once at startup, depth guard applied before validator
// (depthGuard middleware and express.json limit are in app.ts)
const validateComment = ajv.compile(commentSchema); // compiled ONCE

router.post("/comments", async (req, res) => {
  // depth already checked by middleware; validator only sees bounded input
  if (!validateComment(req.body)) {
    return res.status(422).json(formatAjvErrors(validateComment.errors ?? []));
  }
  await commentService.create(req.body);
});

Key differences:

  • new Ajv() and ajv.validate() move out of the handler; the compiled function replaces them.
  • The schema is no longer rebuilt per request.
  • The validator never sees a payload deeper than MAX_DEPTH — the middleware rejects it first.

Verification

Run a targeted load test before and after the fix. The autocannon command below sends valid payloads; craft a separate deep-payload fixture to confirm the guard fires:

# Install autocannon if not present
npm install -g autocannon

# Load test — 50 concurrent connections, 10 seconds
npx autocannon -c 50 -d 10 \
  -m POST \
  -H 'Content-Type: application/json' \
  -b '{"id":"550e8400-e29b-41d4-a716-446655440000","body":"root","children":[{"id":"660e8400-e29b-41d4-a716-446655440001","body":"child"}]}' \
  http://localhost:3000/comments

Expected output after the fix:

Stat         Avg     Stdev   Max
Latency (ms) 8.1     1.4     22
Req/sec      5802    311     6100
Bytes/sec    1.9 MB  104 kB  2.0 MB

0 non-2xx or 3xx responses

To verify the depth guard rejects bad payloads:

// test/depth-guard.test.ts — Vitest
import { describe, it, expect } from "vitest";
import { measureDepth, MAX_DEPTH } from "../src/depth";
import { CommentSchema } from "../src/schemas/comment";

// Build a comment nested N levels deep
function buildDeep(n: number): unknown {
  let node: unknown = { id: crypto.randomUUID(), body: "leaf" };
  for (let i = 0; i < n - 1; i++) {
    node = { id: crypto.randomUUID(), body: "parent", children: [node] };
  }
  return node;
}

describe("depth guard", () => {
  it("measures depth correctly", () => {
    expect(measureDepth(buildDeep(5))).toBe(5);
    expect(measureDepth(buildDeep(32))).toBe(32);
  });

  it("rejects payloads exceeding MAX_DEPTH", () => {
    const result = CommentSchema.safeParse(buildDeep(MAX_DEPTH + 1));
    expect(result.success).toBe(false);
    expect(result.error?.issues[0].message).toContain("exceeds maximum");
  });

  it("accepts a valid nested payload within the limit", () => {
    const result = CommentSchema.safeParse(buildDeep(10));
    expect(result.success).toBe(true);
  });
});
npx vitest run test/depth-guard.test.ts
# ✓ depth guard > measures depth correctly
# ✓ depth guard > rejects payloads exceeding MAX_DEPTH
# ✓ depth guard > accepts a valid nested payload within the limit

Add a formatAjvErrors helper so the error response shape is stable and matches your service-wide contract (see Designing Robust Error Response Contracts):

// errors.ts
import type { ErrorObject } from "ajv"; // ajv 8.17

export interface ValidationError {
  field: string;
  message: string;
  schemaPath: string;
}

export function formatAjvErrors(errors: ErrorObject[]): {
  code: string;
  errors: ValidationError[];
} {
  return {
    code: "VALIDATION_FAILED",
    errors: errors.map((e) => ({
      field: e.instancePath || "/",
      message: e.message ?? "validation error",
      schemaPath: e.schemaPath,
    })),
  };
}

Edge Cases and Caveats

Circular object references crash measureDepth. The iterative walker above does not guard against circular references in JavaScript objects (e.g. a.child = a). These cannot arrive via JSON.parse — the spec forbids circular JSON — but they can appear when you construct payloads in tests or scripts. If your input can be a mutable JS object rather than a parsed JSON body, add a WeakSet seen-guard:

export function measureDepth(value: unknown, seen = new WeakSet()): number {
  if (typeof value !== "object" || value === null) return 0;
  if (seen.has(value as object)) return 0; // break the cycle
  seen.add(value as object);
  // ... rest of the iterative walker, passing `seen` into child visits
}

Validation performance vs. DoS surface. Compiling Ajv validators once at startup and capping depth eliminates the RangeError, but does not protect against a breadth attack: 50 children × 50 grandchildren × 50 great-grandchildren = 125 000 nodes, all at depth 3. Combine the depth cap with maxItems at every array level in your schema so the total node count stays bounded. A schema with maxItems: 50 and MAX_DEPTH: 32 caps the maximum node count at 50^32, which is astronomically large — set realistic values based on your legitimate data shape. For most APIs maxItems: 100 per level and MAX_DEPTH: 20 is adequate.

z.lazy() and the compiled validator interact. Zod 3.23’s z.lazy() re-evaluates the schema factory on each safeParse call, which adds overhead compared to a compiled Ajv validator. For high-throughput paths where every millisecond matters, prefer the Ajv approach (Step 3) and derive TypeScript types from the same schema using openapi-typescript 7, rather than using Zod as the primary validator. Use Zod’s z.lazy() approach where type safety and composability matter more than raw throughput.

Frequently Asked Questions

What causes RangeError: Maximum call stack size exceeded during JSON validation?

Recursive or deeply nested payloads force validators to traverse the object graph via recursive function calls. V8 enforces a call stack ceiling of roughly 10 000–15 000 frames. Payloads exceeding ~50 levels of nesting exhaust this budget and throw a RangeError before validation completes.

Does Ajv 8 have a built-in maxDepth option?

No. Ajv 8 has no native maxDepth keyword. Enforce depth limits by pre-checking the payload before calling the compiled validator, or by keeping your schema non-recursive and using maxItems to cap breadth at each array level.

Should I validate the depth before or after JSON.parse?

After JSON.parse but before calling the validator. JSON.parse itself can stack-overflow on extremely deep input on some runtimes; for untrusted input consider a size guard on the raw string before parsing, then a depth guard on the parsed object tree before validating.

Does z.lazy() handle depth limits automatically in Zod?

No. z.lazy() defers self-reference evaluation so Zod can model recursive schemas, but it does not cap traversal depth. You must add an explicit depth check — either a .refine() that counts nesting levels or a pre-parse guard function — before calling .parse() or .safeParse().

Can worker_threads fully prevent event-loop blocking from validation?

Yes — offloading to a worker thread moves the synchronous computation off the main thread entirely. Use a fixed-size pool (piscina is the standard library for this) rather than spawning a new Worker per request, which creates unacceptable overhead at scale.

What is the right maxDepth value to use in production?

20–32 is the practical range for most APIs. Set the limit based on your schema’s legitimate maximum depth — count the deepest valid nesting in your contract and add a small buffer. Document the limit in your OpenAPI spec as an x-depth-limit extension so consumers know the constraint.