Enforce a Request Body Size Limit
Cap JSON and raw payloads at the parser, reject oversized requests with a stable 413, and prove the limit before a single byte is buffered.
Task
Reject oversized request bodies before they are buffered, and return a stable 413 response that clients can handle without retrying forever.
When to use this
One malformed client streams a multi-gigabyte JSON body, the Node process allocates it into memory, and the container is killed while every other tenant on the node loses service.
Prerequisites
- Node.js 22 with Express 4.18 or newer
- curl for sending oversized payloads
- A reverse proxy or load balancer you can configure
Steps
1. Set an explicit limit on every body parser and scope it to the route family that needs it.
import express from 'express';
const app = express();
app.use('/api', express.json({ limit: '100kb', type: 'application/json' }));
app.use('/uploads', express.raw({ limit: '5mb', type: 'application/octet-stream' }));
Expected output: JSON routes accept bodies up to 100 KB and uploads up to 5 MB; neither parser is unbounded.
2. Reject a declared Content-Length above the limit before the request stream is read.
const MAX_BYTES = 100 * 1024;
app.use('/api', (req, res, next) => {
const declared = Number(req.headers['content-length'] ?? 0);
if (declared > MAX_BYTES) {
res.set('Connection', 'close');
return res.status(413).json({ error: 'payload_too_large', limitBytes: MAX_BYTES });
}
next();
});
Expected output: A request declaring 999999999 bytes is refused immediately instead of being buffered.
3. Map parser errors to a JSON 413 so clients see the same contract for chunked and declared bodies.
app.use((err, req, res, next) => {
if (err?.type === 'entity.too.large') {
return res.status(413).json({ error: 'payload_too_large', limitBytes: MAX_BYTES });
}
next(err);
});
Expected output: Both rejection paths return status 413 with the same JSON shape.
4. Send a payload just over the limit and confirm the response.
head -c 200000 /dev/zero | curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' -X POST --data-binary @- -H 'Content-Type: application/json' http://localhost:3000/api/ordersExpected output: 413 0.02s, with no matching memory spike in the process metrics.
Troubleshooting
Problem
The 413 only arrives after the client finished uploading
Solution
The limit is probably checked inside the handler. Reject on Content-Length first and keep the parser limit as the backstop for chunked requests.
Problem
The proxy answers 502 instead of 413
Solution
Set the same ceiling on the proxy (nginx client_max_body_size or the cloud load balancer) so it does not buffer or reset the connection first.
Limitations
- A route that skips the body parser still accepts arbitrarily large bodies; apply the guard router-wide.
- Compressed request bodies are measured after decompression, so limits must account for the decompressed size.
Why a parser limit is not optional
Express parses JSON as soon as a route matches. Without an explicit limit, express.json() falls back to 100 KB, but any custom parser, raw body, or framework wrapper can remove that ceiling. The dangerous case is not a slow upload: it is a single request that claims a huge payload and makes the process allocate before authentication or validation runs. That turns a trivial client bug into a node-wide outage.
Choose per route family
Different endpoints deserve different budgets. JSON commands are small, so 100 KB is generous; CSV imports or image uploads need megabytes. Put the limit on the parser, not in the handler, because the handler only runs after buffering. When a payload is genuinely large, move it to object storage with a pre-signed upload and pass a reference through the API instead of the bytes.
app.use('/api', express.json({ limit: '100kb' }));
app.use('/imports', express.raw({ limit: '5mb', type: 'text/csv' }));
Make rejection observable
A 413 is a client error, so it should not page anyone, but a sudden rise means an integration broke. Count rejections by route and declared size, and keep the limit in one exported constant so tests and documentation cannot drift.
Verify before shipping
Test both paths: one request with an honest Content-Length over the limit and one chunked body that only exceeds the limit while streaming. The first should fail fast without reading, the second should be cancelled mid-stream with a 413. Assert the response body shape as well, because clients often branch on the error code rather than the status alone.