Node.js
Node.js — JSON.parse on an unbounded request body
Written and reviewed by Sahil Srivastav
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryWhat this error actually means
JSON.parse is synchronous and requires a complete string. If a handler buffers an arbitrary request body and then parses it, the sender controls both the amount of retained input and the amount of work executed on the event loop. The fatal heap message shown is one possible outcome; a service can become unresponsive long before memory is exhausted.
Wire bytes are not the whole memory cost. Buffered chunks, a concatenated Buffer, the decoded string and the parsed object graph can overlap in lifetime. A compressed body can expand dramatically before parsing. There is no universal safe multiplier because the JSON structure, string encoding and parser representation affect allocation.
A payload limit must act before the dangerous allocation. Checking req.body after middleware parsed it, or comparing JSON.stringify(parsed).length against a threshold, is too late. Per-request limits also need a concurrency budget: many individually valid bodies can still exhaust one process when parsing and downstream work retain them simultaneously.
Causes, most common first
- 1The body is buffered without a byte counter. A data listener pushes every chunk into an array and concatenates only on end. By then the entire payload is already retained. A limit checked after Buffer.concat cannot prevent the allocation that crosses the memory budget.
- 2The limit trusts only Content-Length. Chunked requests may not provide a length, and untrusted metadata is not an enforcement mechanism. A declared length can support early rejection, but the receiver must count actual accepted bytes and enforce its own boundary while reading.
- 3Compression or a large object graph amplifies input. A small compressed transfer can produce a much larger decoded document. Many small objects also carry representation overhead beyond their source text. Limits at the reverse proxy and application parser may apply to different representations; document which bytes each counts.
- 4The same input survives through several stages. The request retains its raw body for signatures, a decoded string for logging and parsed objects for business logic. Downstream queues prolong those lifetimes. An endpoint can remain memory-heavy even after adding a parser limit if admission and retained representations remain unbounded.
When you see it
- A large POST delays health checks and unrelated routes on the same instance
- Memory spikes during parsing even though the final stored record is small
- Requests without Content-Length bypass an application’s header-only size check
- Compressed input uses little bandwidth but produces large decoded bodies
How to diagnose it
Step 1
Locate the first body consumer
Inspect middleware ordering and all manual data listeners. A route-specific parser limit does nothing if an earlier global parser already accepted the body. Identify whether the proxy, decompressor, parser and application each impose independent bounds.
rg -n "express\.json|bodyParser|JSON\.parse|Buffer\.concat|req\.on\(" srcStep 2
Measure bytes, parse duration and loop delay together
In staging, increase input size while recording RSS, heapUsed, accepted bytes and event-loop delay. Keep requests bounded so the experiment does not take down shared services. Record concurrency as well as per-request size to expose overlapping allocations.
Step 3
Test the exact boundary and framing variants
Send just-under and just-over-limit fixtures, a request without a declared length and compressed input if compression is supported. Confirm rejection happens before business logic, and ensure error logs do not include the complete rejected payload.
Step 4
Separate syntax failures from resource exhaustion
Malformed JSON should produce a controlled client error. Valid but oversized JSON needs a size rejection before parsing. Do not catch every parsing or allocation failure and continue as if the body were an empty object; that can turn rejected input into unintended writes.
The fix
Use a maintained parser with an explicit route-appropriate body limit before any larger global parser. Reject unsupported content encodings where compression is unnecessary. If compression is required, verify and test the decoded-byte limit as well as the ingress wire limit.
For manual streaming input, count bytes before retaining each chunk and stop processing once the budget would be exceeded. Define how the server rejects or closes the remaining request so it does not keep draining unlimited attacker-controlled data. Header checks are an optimisation in addition to counting, not a replacement.
Validate the parsed schema with bounded arrays, strings and collection sizes. For genuinely large imports, use a streaming representation such as bounded NDJSON records and validate each record incrementally. A streaming parser still needs limits on individual tokens and records.
Limit concurrent large-body requests and release raw representations once no longer needed. Signature verification may require original bytes; retain them only for that defined interval. Moving JSON.parse to a worker can protect event-loop responsiveness but does not make an unlimited payload safe for process memory.
import express from "express";
const app = express();
// Register this before any broader body parser. Choose the bound from
// this endpoint’s documented schema and measured concurrency budget.
app.post("/events", express.json({ limit: "256kb", inflate: false }),
(req, res) => {
const event = validateBoundedEvent(req.body);
acceptEvent(event);
return res.status(202).json({ accepted: true });
});
app.use((error, req, res, next) => {
if (res.headersSent) return next(error);
if (error.type === "entity.too.large") {
return res.status(413).json({ error: "body exceeds endpoint limit" });
}
return next(error);
});How to stop it coming back
- Document body and field limits as part of each endpoint’s schema
- Test limits before and after decompression and at concurrent peak load
- Avoid request-body logging and unnecessary copies of rejected payloads
- Track size rejection rates separately from parser syntax errors and server failures
FAQ
Is Content-Length enough to enforce the limit?
No. Some requests do not supply it, and enforcement must concern bytes actually accepted. Use a trustworthy parser or bounded reader that counts input while consuming it, with early header rejection as an additional check.
Can I catch heap exhaustion from JSON.parse?
Treat fatal V8 heap exhaustion as a process failure, not an ordinary validation exception. Bound input and concurrency before allocation. A catch for malformed JSON handles syntax errors; it is not a memory isolation boundary.
Would a worker thread eliminate this denial-of-service risk?
It can move synchronous parsing off the main loop, but workers still consume memory and CPU, and sending data can add copies. Bound payload size, worker count and queued tasks even when parsing is isolated.
Related
Other errors engineers hit next to this one
- Retry storm: thundering herd after a dependency failure
- Outbound call has no timeout and exhausts workers
- Distributed lock lease expired while the holder was still working
- Clock skew: timestamp ordering or token expiry is inconsistent
- Exactly-once claim fails at an external side effect
- Dead-letter queue growing without an alert
- Idempotency key reused with a different request body
- Read-after-write returned stale data from a replica