Skip to content

Building a JSON API

Unlike the URL and headers which are available immediately, the request body arrives as a stream of chunks. You must collect them manually with data and end events:

function readBody(req) {
return new Promise(function(resolve, reject) {
let raw = '';
req.on('data', function(chunk) { raw += chunk; });
req.on('end', function() { resolve(raw); });
req.on('error', reject);
});
}

Once you have the raw string, parse it with JSON.parse() inside a try/catch to handle malformed input gracefully.

The server below manages a list of items in memory. It supports:

  • GET /items — list all items
  • POST /items — add an item (reads JSON body)
  • GET /items/:id — fetch one item (returns 404 if missing)
Node.js

Needs the Node.js runtime — open in StackBlitz to run.

Open in StackBlitz to test the API further with the built-in HTTP client or a tool like curl — swap 3000 for the port printed in the console:

Terminal window
curl http://localhost:3000/items
curl -X POST http://localhost:3000/items -H 'Content-Type: application/json' -d '{"name":"TypeScript"}'
curl http://localhost:3000/items/3
curl http://localhost:3000/items/99
ScenarioStatus Code
Successful read200 OK
Resource created201 Created
Missing or invalid body field400 Bad Request
Resource not found404 Not Found
Server error500 Internal Server Error

The readBody helper above collects chunks with no limit at all — a client can stream gigabytes into a handler that just keeps concatenating, growing memory until the process falls over. A real body reader tracks the running size and bails out once it crosses a limit, calling req.destroy() to stop reading the rest of an oversized body and responding 413 Payload Too Large:

Node.js
Output (verified on Node 24)
413 413 Payload Too Large

The response is written before req.destroy() — reversing that order would tear down the socket before the 413 body ever reaches the client. Without any limit at all, this handler is the classic denial-of-service vector: one slow, oversized upload can pin memory indefinitely while nothing ever finishes reading it.

JSON.parse() throws a SyntaxError on anything that is not valid JSON. The readBody/jsonApiCode demo above already wraps that in a try, but it is worth seeing on its own, since forgetting this try/catch is one of the most common gaps in AI-generated body parsing:

Node.js
Output (verified on Node 24)
400 { error: 'malformed JSON body' }

Without the try/catch, the thrown SyntaxError inside an async request listener rejects the Promise that listener returns — Node.js does not automatically turn that into a response, so the request just hangs and the rejection becomes an unhandled rejection.

Byte length vs. string length for non-ASCII bodies

Section titled “Byte length vs. string length for non-ASCII bodies”

JSON.stringify output is a JavaScript string, and .length counts UTF-16 code units — for pure ASCII JSON that happens to match the byte count, but the moment a value contains Thai text (or emoji, or most non-Latin scripts), it does not:

const text = 'สวัสดี'; // Thai greeting - each character is multiple UTF-8 bytes
console.log('.length (UTF-16 code units):', text.length);
console.log('Buffer.byteLength (actual UTF-8 bytes):', Buffer.byteLength(text));

res.end(data) computes Content-Length correctly for you using the real byte count, so this rarely bites in code that lets res.end() do the work. It bites the moment someone hand-computes a length for a header, a Content-Length set ahead of streamed write() calls, or a body-size budget — always reach for Buffer.byteLength(str) there, never str.length.

A JSON API handler is a pipeline with three places things go wrong before you ever touch your data: the network (too much data, req.destroy() fixes it), the parser (JSON.parse throwing, a try/catch fixes it), and your own validation (a field missing or the wrong type, a check fixes it). Each stage needs its own guard and its own status code — a body limit failure is 413, a parse failure is 400, a validation failure is also 400 (with a more specific message) — collapsing any of them into a generic 500 tells the client nothing about what to fix.

A hardcoded byte limit like the 20-byte demo above is simple but arbitrary — too low and legitimate requests get rejected, too high and the protection barely helps. Production APIs usually derive the limit from the endpoint’s actual needs (a login form needs bytes, not megabytes) or delegate it to a reverse proxy in front of Node, which can reject oversized bodies before they even reach your process — at the cost of one more layer of configuration to keep in sync with your application code.

  • “Rejecting a body over the limit with res.end() is enough — no need to touch req.” The client may still be mid-upload; without req.destroy(), Node keeps reading (and buffering) bytes that will never be used, undermining the whole point of the limit.
  • “JSON.parse errors are rare enough to skip the try/catch.” Any client — a buggy integration, a hand-typed curl command, a retried request with a truncated body — can send invalid JSON. It is exactly as common as any other form of bad input, and it is uncaught by default.
  • “.length is close enough to the real byte count for a Content-Length header.” It is exactly right for ASCII and silently wrong for everything else — the failure mode (a truncated response body) only shows up once real-world text with non-ASCII characters flows through the code path.

AI-generated body readers reliably implement the data/end collection pattern correctly, and just as reliably skip the one thing that pattern needs to be safe against a hostile or buggy client: a size limit.

🐛 Spot the bug

function readBody(req) {
return new Promise(function (resolve, reject) {
let raw = '';
req.on('data', function (chunk) { raw += chunk; });
req.on('end', function () { resolve(raw); });
req.on('error', reject);
});
}
// Called directly on every incoming request, with no size check anywhere
Show the answer

This readBody will happily concatenate an unbounded number of chunks — a client that streams a multi-gigabyte body (by accident or on purpose) grows the raw string the entire time, with no upper bound, holding up memory and the event loop until the process runs out of memory or the connection is killed externally. The fix is the pattern shown earlier in this lesson: track the running byte count as chunks arrive, and once it crosses a limit, respond 413 Payload Too Large and call req.destroy() to stop reading immediately instead of trusting the client to eventually send an end event.

Why must you collect request body data using data and end events rather than reading it synchronously?
What HTTP status code should a POST endpoint return when it successfully creates a new resource?
A client sends an invalid JSON body. What is the correct response status code?
A readBody helper collects chunks into a string with no size check, called directly on every incoming request. What is the risk?
function readBody(req) {
  return new Promise(function (resolve, reject) {
    let raw = '';
    req.on('data', function (chunk) { raw += chunk; });
    req.on('end', function () { resolve(raw); });
  });
}
Why should req.destroy() be called after writing the 413 response, not before?
For the Thai string 'สวัสดี', why does Buffer.byteLength(text) return a larger number than text.length?