So far Escena Viva only hands things out: catalog, sessions, HTML, CSS. Everything is a GET. What is missing is what turns a website into a platform: receiving. Letting somebody buy a ticket.

And here the module's asymmetry shows up. Reading req.method, req.url or req.headers is immediate, because Node hands you that data already parsed. The body, however, is not: when your handler starts running, the body may still be travelling over the network. req is a readable stream, and reading it is exactly what you learned in Module 3, with two new twists: the chunks are Buffers and cutting them wrong breaks the characters, and whoever sends them is not on your side, so you have to put a limit on them.

By the end you will have src/server/body.js and Escena Viva's first POST: POST /orders, with validation, a call into the domain and a 201 response with its Location header.

Contents

  1. req is a stream: the body arrives in chunks
  2. Why concatenating into a string breaks the characters
  3. The right way: Buffer.concat and for await...of
  4. The size limit and the 413
  5. Timeouts and aborted requests
  6. Parsing according to the Content-Type
  7. POST /orders: validate and call the domain
  8. Responding 201 with Location, or whichever error fits
  9. Idempotency: why a repeated POST duplicates orders

  1. req is a stream: the body arrives in chunks

Node invokes your handler as soon as it has finished reading the headers. The body arrives afterwards, in chunks, through the stream's events:

function handleRequest(req, res) {
  const chunks = [];

  // chunk is a Buffer. Its size is decided by the network, not by you.
  req.on('data', (chunk) => chunks.push(chunk));

  req.on('end', () => {
    const body = Buffer.concat(chunks).toString('utf8');
    console.error(`[body] ${body.length} characters in ${chunks.length} chunks`);
    res.end('received\n');
  });

  // Without this listener, a connection cut mid-transfer takes down the process.
  req.on('error', (error) => {
    console.error('[body] read failure:', error);
    res.statusCode = 400;
    res.end();
  });
}

Three things to internalize:

  • You do not control the size of each chunk. It depends on the network MTU, on the client and on the system buffers. A small body can arrive in a single data; a large one, in hundreds. Never assume a chunk is a meaningful unit.
  • If you do not read the body, it stays there. A handler that responds without consuming req leaves unread data in the socket, and with keep-alive that can desynchronize the next request on that connection. If you are going to ignore the body, consume it (req.resume()) or destroy the request.
  • req emits error. A connection cut mid-transfer produces an error on the stream, and like every EventEmitter, if you do not listen for it you lose the process.

  1. Why concatenating into a string breaks the characters

This is the bug that has been written most often in Node's history:

// WRONG. It works in your tests and fails in production with accents.
let body = '';
req.on('data', (chunk) => { body += chunk; });   // <- implicit chunk.toString()
req.on('end', () => { JSON.parse(body); });

The += converts each Buffer to a string separately, and there lies the trap we studied in lesson 03-06: in UTF-8 a character can take several bytes. ó is two bytes (0xC3 0xB3); an emoji, four. If the boundary between two chunks falls in the middle of that sequence, each half is decoded on its own and neither is a valid character: the result is � (U+FFFD), and that substitution is irreversible.

const full = Buffer.from('{"venue":"Sala Bóveda"}', 'utf8');

// We simulate the network splitting the buffer right between the two bytes of the 'ó'.
const cut = full.indexOf(0xc3) + 1;
const chunkA = full.subarray(0, cut);
const chunkB = full.subarray(cut);

console.log(chunkA.toString('utf8') + chunkB.toString('utf8'));
// {"venue":"Sala B��veda"}     <- two replacement characters
console.log(Buffer.concat([chunkA, chunkB]).toString('utf8'));
// {"venue":"Sala Bóveda"}      <- correct

The perverse part is when it fails: with small bodies there is almost never more than one chunk, so in development it always works. In production, with larger bodies and a real network, it fails now and then and only with accents, emoji or CJK characters. A JSON.parse that blows up "randomly" and a user named "Bóveda" that sometimes gets saved wrong.

The rule is absolute: accumulate Buffers and decode once, at the end. The alternative, if you need to process text as it arrives, is the StringDecoder from lesson 03-06, which holds on to the incomplete bytes between one chunk and the next.

  1. The right way: Buffer.concat and for await...of

With async/await, req is walked with for await...of, because readable streams are async iterables (lesson 03-05). It reads far better than loose events:

async function readRawBody(req) {
  const chunks = [];
  for await (const chunk of req) {
    chunks.push(chunk);
  }
  return Buffer.concat(chunks);   // a single Buffer with all the bytes
}

Advantages over the on('data') version: stream errors become an exception your try/catch picks up, end is simply the end of the loop, and the result fits the rest of your asynchronous code. This is the form we will use.

But as it stands, that function is an invitation to have your server taken down. What is missing is in the next section.

  1. The size limit and the 413

readRawBody accumulates in memory everything that arrives. If a client sends a 5 GB body, your process tries to hold it in RAM and dies out of memory. No bad faith is required: a badly written loop in a client is enough. And with bad faith, it is a denial-of-service attack that costs one line of curl.

A server always limits the body size, and it does so twice:

  • By checking Content-Length before reading anything. It is cheap and instantly rejects the obviously enormous.
  • By counting the bytes received as they arrive. Essential, because Content-Length is written by the client: it can lie, or not be there at all if the request uses Transfer-Encoding: chunked.
// src/server/body.js
// Reading a request body with a mandatory size limit.

const BYTE_LIMIT = 64 * 1024;   // 64 KB: more than enough for a JSON order

function domainError(code, message) {
  const error = new Error(message);
  error.appCode = code;
  return error;
}

async function readBody(req, byteLimit = BYTE_LIMIT) {
  // 1. Cheap filter: what the client SAYS it is going to send.
  const declared = Number(req.headers['content-length']);
  if (Number.isFinite(declared) && declared > byteLimit) {
    throw domainError('BODY_TOO_LARGE',
      `The body declares ${declared} bytes and the limit is ${byteLimit}`);
  }

  // 2. Real filter: count what actually arrives.
  const chunks = [];
  let received = 0;

  for await (const chunk of req) {
    received += chunk.length;
    if (received > byteLimit) {
      // Cut it off: otherwise the client keeps sending what we already discarded.
      req.destroy();
      throw domainError('BODY_TOO_LARGE', `The body exceeds ${byteLimit} bytes`);
    }
    chunks.push(chunk);
  }

  return Buffer.concat(chunks);
}

The req.destroy() is not a detail: without it, the client keeps transmitting and you keep receiving bytes you throw away, burning bandwidth and CPU. Destroying the request turns off the tap.

BODY_TOO_LARGE gets added to the http-errors.js table with status 413 Payload Too Large:

BODY_TOO_LARGE: 413,
UNSUPPORTED_TYPE: 415

About the limit's value: 64 KB is generous for a JSON order and ridiculous for uploading an image. The answer is not "a big limit just in case", but a different limit per route: 64 KB on the API, and several megabytes only where files really do get uploaded.

  1. Timeouts and aborted requests

There is an attack subtler than the huge body: the excruciatingly slow body. A client announces 60 KB and sends them at one byte per minute. It never exceeds the size limit, but it keeps a connection and a handler busy for hours. With a few hundred connections like that, you exhaust the server. This is the attack known as slowloris.

The defense is a timeout:

// If the request does not complete in 10 s, it gets cut off.
req.setTimeout(10_000, () => {
  console.error('[body] request too slow, cutting it off');
  req.destroy();
});

The symmetrical case is the aborted request: the user closes the tab or loses signal mid-upload. Node signals it on the stream, and it is worth detecting so you do not do useless work:

// The 'aborted' event has been deprecated since Node 16: use 'close'.
req.on('close', () => {
  if (!req.readableEnded) console.error('[body] the client aborted the upload');
});

The key distinction is req.readableEnded: if it is true, the body arrived complete and the close is the normal one afterwards; if it is false, the connection was cut early. When that happens, do not respond: there is nobody listening, and any heavy work you were about to do (writing to disk, calling another API) is time thrown away.

  1. Parsing according to the Content-Type

A body is bytes; what they mean is stated by the Content-Type header. These are the three formats you will see:

Content-Type Who sends it How it is parsed
application/json APIs, fetch, mobile apps JSON.parse inside a try/catch
application/x-www-form-urlencoded A classic HTML <form> new URLSearchParams(text)
multipart/form-data A <form> with <input type="file"> With a library, never by hand

We add to src/server/body.js:

// Returns the body already interpreted according to its Content-Type.
async function readParsedBody(req, byteLimit = BYTE_LIMIT) {
  const raw = await readBody(req, byteLimit);
  if (raw.length === 0) return {};

  // The Content-Type can carry parameters: 'application/json; charset=utf-8'.
  const type = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
  const text = raw.toString('utf8');   // a SINGLE decoding, at the end

  if (type === 'application/json') {
    try {
      return JSON.parse(text);
    } catch (error) {
      // Useful message: JSON.parse states the exact position of the failure.
      throw domainError('INVALID_JSON', `The body is not valid JSON: ${error.message}`);
    }
  }

  if (type === 'application/x-www-form-urlencoded') {
    // URLSearchParams decodes percent-encoding and '+' for us.
    return Object.fromEntries(new URLSearchParams(text));
  }

  throw domainError('UNSUPPORTED_TYPE', `Unsupported Content-Type: ${type || '(missing)'}`);
}

module.exports = { readBody, readParsedBody, BYTE_LIMIT };

Details that matter. The Content-Type is cut at the ;, because it nearly always comes with parameters. The JSON.parse goes inside a try/catch and the message is put to use: Unexpected token } in JSON at position 42 tells the client exactly where to look, and that is worth far more than an "invalid request". And Object.fromEntries over URLSearchParams loses repeated keys (it keeps the last one): if your form has multiple checkboxes, use getAll explicitly.

About multipart/form-data: it is a format with randomly generated boundaries, parts with their own headers, binary files that must not go through memory and legacy encodings. Parsing it by hand is an error of judgment: it is solved with busboy or multer, which we will see in Module 6. Knowing that you should not do it by hand is part of the craft.

  1. POST /orders: validate and call the domain

With the pieces ready, Escena Viva's first POST. The expected body is minimal: {"sessionId": "ses-001-1", "quantity": 2, "email": "[email protected]"}.

Validation is done by hand and before touching the domain. Every check throws with its error.appCode, and the central handler from 04-03 turns them into the right status:

// src/server/order-routes.js
const MAX_TICKETS_PER_ORDER = 6;

function validateOrder(body) {
  const { sessionId, quantity, email } = body;

  if (typeof sessionId !== 'string' || sessionId.trim() === '') {
    throw domainError('INVALID_PARAMETER', 'The "sessionId" field is required');
  }
  if (!Number.isInteger(quantity) || quantity < 1) {
    throw domainError('INVALID_QUANTITY', 'The "quantity" field must be a positive integer');
  }
  if (quantity > MAX_TICKETS_PER_ORDER) {
    // 422: perfectly understood, but forbidden by a business rule.
    throw domainError('ORDER_LIMIT_EXCEEDED',
      `Maximum ${MAX_TICKETS_PER_ORDER} tickets per order, ${quantity} were requested`);
  }
  if (typeof email !== 'string' || !email.includes('@')) {
    throw domainError('INVALID_PARAMETER', 'The "email" field is not valid');
  }

  return { sessionId: sessionId.trim(), quantity, email: email.trim().toLowerCase() };
}

Four criteria behind these checks:

  • Number.isInteger, not parseInt. With parseInt('2 tickets') you would get 2 and wave through nonsense. If the body is JSON, quantity must arrive as a number; a "2" string is a badly written client and deserves a 400.
  • 400 is distinguished from 422. quantity: 0 is a 400 (the value makes no sense); quantity: 8 is a 422 (the value is valid and the business rule forbids it).
  • Validation happens before calling the domain. The sooner the invalid is rejected, the less half-done state there is to undo.
  • Normalization happens at the end. Trimming spaces and lowercasing the email prevents "[email protected]" and "[email protected]" from being two different customers.

With the data already clean, the handler only orchestrates:

router.post('/orders', async (req, res) => {
  const body = await readParsedBody(req);                       // 413, 400 or 415
  const { sessionId, quantity, email } = validateOrder(body);   // 400 or 422

  const events = await getCatalog();
  const event = events.find((candidate) => candidate.findSession(sessionId));
  if (!event) {
    throw domainError('SESSION_NOT_FOUND', `Session ${sessionId} does not exist`);   // 404
  }

  // The domain decides: throws INSUFFICIENT_CAPACITY (409) if they don't fit.
  event.reserve(sessionId, quantity);

  const manager = new SalesManager(events);
  const tickets = manager.recordSale(sessionId, quantity, `ord-${Date.now()}`);

  sendJson(res, 201, { /* ...section 8... */ });
});

Notice what is not there: not a single try/catch, not a single mention of an HTTP status number. The handler throws in the domain's vocabulary and the dispatcher translates. That is the payoff of the two previous lessons.

  1. Responding 201 with Location, or whichever error fits

A POST that creates a resource responds 201 Created with the Location header pointing at where it ended up:

const orderId = `ord-${Date.now()}`;

sendJson(res, 201, {
  orderId,
  sessionId,
  quantity,
  email,
  tickets: tickets.map((ticket) => ticket.code),   // EV-2026-000001, ...
  amountCents: quantity * session.priceCents,
  availableTickets: event.availableTickets(sessionId)
}, { Location: `/orders/${orderId}` });

The Location is not decorative: it is what lets a client store the order's URL without building it out of guesswork. And the amount goes in whole cents, the project's convention since Module 1: 2 × 2500 = 5000, never 50.00 in floating point.

Every error path, in one table:

Situation error.appCode Status
Body larger than 64 KB BODY_TOO_LARGE 413
Unsupported Content-Type UNSUPPORTED_TYPE 415
Malformed JSON INVALID_JSON 400
sessionId missing or the email is not valid INVALID_PARAMETER 400
quantity is not a positive integer INVALID_QUANTITY 400
More than 6 tickets ORDER_LIMIT_EXCEEDED 422
The session does not exist SESSION_NOT_FOUND 404
Not enough tickets left INSUFFICIENT_CAPACITY 409

And the full check from the terminal:

CT='Content-Type: application/json'

# 201 Created, with Location and the ticket codes
curl -i -X POST http://localhost:3000/orders -H "$CT" \
  -d '{"sessionId":"ses-001-1","quantity":2,"email":"[email protected]"}'

curl -s -X POST http://localhost:3000/orders -H "$CT" \
  -d '{"sessionId":"ses-001-1","quantity":8,"email":"[email protected]"}'   # 422
curl -s -X POST http://localhost:3000/orders -H "$CT" \
  -d '{"sessionId":"ses-999-9","quantity":1,"email":"[email protected]"}'   # 404
curl -s -X POST http://localhost:3000/orders -H "$CT" \
  -d '{"sessionId":"ses-002-1","quantity":5,"email":"[email protected]"}'   # 409: 2 left
curl -s -X POST http://localhost:3000/orders -H "$CT" -d '{not json}'       # 400

Session ses-002-1 has a capacity of 120 and 118 sold: asking for 5 returns 409 with the domain's message, which says how many are left. That detail —an error that explains the real state— is what makes an API usable.

  1. Idempotency: why a repeated POST duplicates orders

Run the curl that creates the 2-ticket order twice. You will get two different orders and four tickets sold. That is correct by the spec: POST is not idempotent by definition, and repeating it means creating another resource.

Method Idempotent? Repeating it means
GET, HEAD Yes Reading again; nothing changes
PUT, DELETE Yes Leaving the resource in the same final state
POST No Creating another resource

The problem is that in real life requests get repeated unintentionally: the user clicks "Buy" twice, the network drops after the server processed the order but before the response arrived, or a mobile client retries automatically. On a ticketing platform that means charging twice.

The three defenses, in order of solidity:

  1. On the client: disable the button after the first click. Necessary, insufficient: it does not protect against a network retry.
  2. Idempotency key: the client generates a unique identifier per attempt and sends it in an Idempotency-Key header. The server stores it with the response; if the same key arrives again, it returns the stored response without executing anything again. This is what payment gateways do.
  3. Database transactions: reserving capacity and creating the order in one atomic operation, with a uniqueness constraint that prevents the duplicate.

Our server today can do neither 2 nor 3 properly, and it is worth being honest about why: the state lives in memory and in a JSON file on disk, with no transactions and no locks. What is more, two simultaneous requests can read the same available capacity and sell the same two seats, a classic race condition. We will solve it for real in lesson 07-06, with transactions. Until then, keep it in mind as what it is: a known limitation, not an oversight.

Common Mistakes and Tips

  • Accumulating the body into a string with +=. It breaks multibyte characters intermittently. Buffer.concat and decode at the end.
  • Reading the body with no size limit. A client exhausts your memory with one line of curl. Check Content-Length and count the bytes.
  • Trusting Content-Length alone. The client writes it and with chunked it does not even exist.
  • Not destroying the request when the limit is exceeded. You keep receiving what you already decided to throw away.
  • JSON.parse with no try/catch. A malformed body gives you a 500 instead of a 400, and one stack trace in the log per clumsy client.
  • Comparing the Content-Type with ===. It almost always carries ; charset=utf-8. Cut it at the ;.
  • Responding 200 to a creation, or parsing multipart/form-data by hand. A POST that creates returns 201 with Location; for multipart, busboy or multer.
  • Tip: validate before touching the domain and normalize at the end (trim, lowercase). Rejecting is cheaper than undoing.
  • Tip: in error messages, include the failing field and the expected value. "Maximum 6 tickets per order, 8 were requested" is worth infinitely more than "Invalid request".

Exercises

Exercise 1: demonstrating the multibyte breakage

Write src/lab/break-utf8.js that builds the Buffer for '{"venue":"Sala Bóveda","event":"Concierto de Otoño"}', splits it into 7-byte chunks, and compares two reconstructions: the one from concatenating strings and the one from Buffer.concat. Print both, count the � characters in each and try JSON.parse on both. Repeat with 5-byte and 13-byte chunks.

Exercise 2: the size limit in action

Generate a 100 KB file (node -e "process.stdout.write('x'.repeat(102400))" > /tmp/large.txt) and send it with curl -X POST --data-binary @/tmp/large.txt. Check that the response is 413. Then modify readBody so it logs to stderr how many bytes were actually received before cutting off, and explain why that number almost never matches the limit exactly.

Exercise 3: forms and JSON on the same route

Make POST /orders accept application/x-www-form-urlencoded as well, bearing in mind that in a form every value arrives as a string and quantity has to be converted to a number before validating it. Check with curl -d 'sessionId=ses-003-1&quantity=3&[email protected]' (no -H, because curl already sets that Content-Type) and make sure quantity=three still returns 400.

Solutions

Solution 1. With 7-byte chunks the cut falls inside the ó of "Bóveda" and the ñ of "Otoño":

const original = '{"venue":"Sala Bóveda","event":"Concierto de Otoño"}';
const full = Buffer.from(original, 'utf8');

for (const size of [5, 7, 13]) {
  const chunks = [];
  for (let i = 0; i < full.length; i += size) chunks.push(full.subarray(i, i + size));

  const byString = chunks.reduce((text, chunk) => text + chunk, '');
  const byBuffer = Buffer.concat(chunks).toString('utf8');
  const broken = [...byString].filter((character) => character === '�').length;

  console.log(`${size} bytes -> ${broken} broken characters | correct: ${byBuffer === original}`);
}

Buffer.concat returns the original in all three cases; string concatenation breaks characters whenever a cut falls inside a multibyte sequence, and its JSON.parse fails because � is not a valid character inside a JSON string... unless it falls inside a value, in which case it parses with no error and stores corrupt data, which is the worst scenario of all.

Solution 2. The number almost never matches the limit because the check happens after a whole chunk has been received: if the limit is 65,536 bytes and the chunk that crosses it carries 16 KB, you will have overshot by several thousand. That is the correct behavior —you cannot reject half a chunk—, and that is why the limit is chosen with headroom and not to the byte.

if (received > byteLimit) {
  console.error(`[body] cut off after ${received} bytes (limit ${byteLimit})`);
  req.destroy();
  throw domainError('BODY_TOO_LARGE', `The body exceeds ${byteLimit} bytes`);
}

Solution 3. The conversion has to be strict: Number('three') is NaN, and Number.isInteger(NaN) is false, so the existing validation already rejects it untouched.

function normalizeNumbers(body, isForm) {
  if (!isForm) return body;
  // In a form everything arrives as a string: convert what should be a number.
  return { ...body, quantity: body.quantity === undefined ? undefined : Number(body.quantity) };
}

Using Number and not parseInt is deliberate: parseInt('3 tickets') returns 3 and would accept garbage, while Number('3 tickets') returns NaN and rejects it. Session ses-003-1 has enough capacity, so the 3-ticket order returns 201.

Conclusion

Escena Viva now sells tickets. And the journey has made it clear that receiving is considerably more delicate than handing out. req is a readable stream whose chunks are Buffers of unpredictable size, so the first rule is to accumulate Buffers and decode once with Buffer.concat: concatenating into a string splits multibyte characters and produces unrecoverable � intermittently, exactly the kind of failure that never shows up in development. With for await...of the code also comes out readable and with errors integrated into try/catch.

The second rule is that whoever sends the body is not on your side: src/server/body.js checks Content-Length as a cheap filter and counts the bytes received as the real one, destroys the request when it overshoots and throws BODY_TOO_LARGE → 413. Add to that req.setTimeout against the excruciatingly slow body and abort detection with close plus readableEnded, so you never work for nobody. Parsing is decided by the Content-Type cut at the ;: JSON.parse inside try/catch with its message put to use, URLSearchParams for forms, and multipart/form-data delegated to a library because doing it by hand is an error of judgment.

And you have the first real POST: hand-written validation before touching the domain —Number.isInteger instead of parseInt, 400 for what makes no sense and 422 for what the business rule forbids, with the 6-ticket limit—, a call to reserve and to SalesManager, and a 201 with Location response and an amount in cents. All of it without a single try/catch in the handler: it throws in the domain's vocabulary and the 04-03 dispatcher translates. You also know what is still unsolved: a repeated POST duplicates the order, and two simultaneous ones can sell the same seat. The solution has a name —idempotency keys and transactions— and a date: lesson 07-06.

What is left is to flip the page over. So far Node has always been the server; in the next lesson, Consuming External APIs from Node.js, it will be the client: global fetch and http.request underneath, the classic mistake that fetch does not reject on a 404 or a 500, timeouts with AbortController, retries with exponential backoff reusing retry from Module 2, and a real Escena Viva service —src/services/currency-exchange.js— with an in-memory cache and graceful degradation when the external provider fails.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved