Reading and Writing JSON
Collect a request body in chunks under a byte limit, parse it safely, and set Content-Type before you write any body.
res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body));JSON is text. An HTTP request body does not arrive as one string: it arrives as a stream of chunks, each a Buffer of raw bytes. Reading a JSON body means collecting those chunks, turning them into text, and calling JSON.parse.
Collecting the body
Two forms do the same work. The long form attaches listeners; the short form iterates the request, because req is an async iterable:
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => done(Buffer.concat(chunks)));
for await (const chunk of req) chunks.push(chunk);
Count the bytes as they come in and stop past a limit you choose, such as 1 MB. Without a limit, one client can make your process hold a request of any size in memory. Over the limit, answer 413. JSON.parse throws a SyntaxError on malformed input, so wrap it and answer 400.
Writing the reply
Headers go out before the body, so set Content-Type: application/json first and send the body second. Once the first byte of the body is written the headers are already on the wire, and res.setHeader then throws ERR_HTTP_HEADERS_SENT.
Do not trust Content-Length as your size check: it is a number the client sent, so count what actually arrives. And do not decode each chunk on its own. A character can be split across two chunks, so concatenate the buffers and decode once.
Example
When to use it
- Accept a JSON POST body on a built-in server with no body-parser dependency.
- Return 413 instead of growing memory when a client uploads an oversized body.
- Answer 400 with a clear code when a client sends malformed JSON.
- Set Content-Length correctly on a response containing non-ASCII text.
More examples
Checking the content type before reading
415 Unsupported Media Type is the answer to a body you are not willing to parse, and it is a clearer signal than a 400 about broken JSON. The split on `;` matters because a client sending `application/json; charset=utf-8` is correct and a naive equality check would reject it.
// req.headers keys are always lowercase, whatever case the client sent.
function isJson(req) {
const header = req.headers['content-type'] || '';
// The header may carry parameters: 'application/json; charset=utf-8'.
return header.split(';')[0].trim().toLowerCase() === 'application/json';
}
async function handlePost(req, res) {
if (!isJson(req)) {
res.writeHead(415, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'unsupported_media_type' }));
}
const body = await readJson(req);
// ...
}One sendJson helper for every endpoint
Centralising the write means Content-Type can never be forgotten on one endpoint. `Buffer.byteLength(text)` is the byte count of the encoded string, which is what Content-Length means; `text.length` counts characters and will be wrong for any non-ASCII body. Setting Content-Length also lets the response go out without chunked transfer encoding.
function sendJson(res, status, body) {
const text = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
// byteLength, not text.length: Content-Length counts bytes.
'Content-Length': Buffer.byteLength(text),
});
res.end(text);
}
// 204 No Content must not carry a body at all.
function sendNoContent(res) {
res.writeHead(204);
res.end();
}
sendJson(res, 200, { id: 42, name: 'ada' });The listener form, with an aborted client
This is the same logic as the `for await` version written out in full, and it is what you will see in most existing code. It also shows the two cases the short form handles for you: an error on the stream, and a client that disconnects before sending the whole body. Without the `'close'` and `'error'` listeners, that promise never settles and the request is leaked. Older code listens for `'aborted'` here instead; that event on `http.IncomingMessage` has been deprecated since Node.js 17.0.0, and the docs say to listen for `'close'` and check `req.complete`, which is true only once the whole body has arrived.
function readJson(req, limit = 1_000_000) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > limit) {
reject(new Error('payload_too_large'));
req.destroy(); // stop reading and close the socket
return;
}
chunks.push(chunk);
});
req.on('error', reject);
// The client vanished mid-upload: no 'end' will ever fire. 'close' always
// does, and req.complete is false when the body never finished arriving.
req.on('close', () => {
if (!req.complete) reject(new Error('client_aborted'));
});
req.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch {
reject(new Error('invalid_json'));
}
});
});
}
Discussion