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.

Syntaxres.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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · javascript
// 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.

Example · javascript
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.

Example · javascript
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

  • Be the first to comment on this lesson.