Updated Jun 23, 2026

Handling Requests & Responses

In Phase 1 you stood up a server and watched it call your (req, res) function for every request. That function is the whole job. Everything a web server does — read what came in, decide what to send back — happens inside it. So let's get specific about the two objects you've been handed.

The mental model: read from req, write to res

Here's the picture to carry through this phase:

  • req is the incoming request. You read from it: the method, the URL, the headers, and — the awkward one — the body. It's a readable stream, which matters for the body and nothing else.
  • res is the outgoing response. You write to it: a status code, some headers, and a body. It's a writable stream.

That's the entire dance. A handler reads req and writes res. There is no built-in "give me the JSON body" and no built-in "send this object as JSON" — both directions are manual, and in this phase you'll write the two small helpers that do them. When you later see express.json() and res.json(), you'll recognize them as exactly these helpers, pre-installed.

📝 We're building a messages service throughout this guide — a list of { id, text } objects. This phase is about the plumbing for one request; Phase 5 wires it into full CRUD. For now, focus on getting data in and JSON out.

Reading the request: method, URL, headers

The easy parts come for free as plain properties on req:

const http = require('node:http');

const server = http.createServer((req, res) => {
  console.log(req.method);           // 'GET', 'POST', 'DELETE', ...
  console.log(req.url);              // '/messages?limit=5'
  console.log(req.headers['host']); // 'localhost:3000'

  res.end('ok');
});

server.listen(3000);

What just happened: req.method and req.url tell you what the client asked for, and req.headers is a plain object of every header (keys are lowercased for you, so it's always req.headers['host'], never 'Host'). No parsing required — these are populated the moment your function runs.

One trap: req.url is not a tidy path. It's everything after the host — the path and the query string smooshed together, like /messages?limit=5. Picking it apart by hand with string splits is error-prone, so don't. Node ships the URL class for exactly this:

const server = http.createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');

  console.log(url.pathname);                  // '/messages'
  console.log(url.searchParams.get('limit')); // '5'  (a string, or null if absent)

  res.end('ok');
});

What just happened: new URL(...) needs a full absolute URL, but req.url is only a path — so we hand it a throwaway base of 'http://localhost' just to satisfy the parser. We never use that base for anything; we only read url.pathname (the clean route) and url.searchParams (a tiny key/value API over the query string). searchParams.get always returns a string or null, so remember to convert when you want a number: Number(url.searchParams.get('limit')).

⚠️ Reading the body: it arrives as a stream, not a string

This is the part that surprises people coming from frameworks. When a client POSTs JSON, the body is not sitting on req waiting for you. req is a readable stream, and the body shows up in pieces ("chunks") over time. You have to listen for those chunks, stitch them together, and only then parse.

Here's the helper that does it — read it slowly, it's the heart of this phase:

function readJson(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', chunk => { body += chunk; });
    req.on('end', () => {
      try {
        resolve(body ? JSON.parse(body) : {});
      } catch (e) {
        reject(e);
      }
    });
    req.on('error', reject);
  });
}

What just happened: we wrap the stream in a Promise so callers can await readJson(req) instead of juggling events. The 'data' event fires once per chunk and we append each to a string. The 'end' event fires when the body is fully received — that's where we parse, defaulting to {} if the body was empty (a body-less POST shouldn't crash). The JSON.parse sits inside a try/catch because a client can absolutely send garbage, and a parse error should reject cleanly rather than throw out of the event callback where nothing can catch it. The 'error' event handles the stream itself dying mid-transfer.

💡 This helper is express.json(). When you write app.use(express.json()) in Express, this exact collect-chunks-then-parse logic runs before your route, and the result lands on req.body. The framework didn't invent a feature — it bundled this boilerplate so you stop rewriting it.

Using it in a handler:

const server = http.createServer(async (req, res) => {
  if (req.method === 'POST') {
    let data;
    try {
      data = await readJson(req);
    } catch {
      res.writeHead(400, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Invalid JSON' }));
      return;
    }
    console.log('client sent:', data.text);
  }
  res.end('ok');
});

What just happened: the handler is now async so we can await the body. If readJson rejects — malformed JSON, a broken connection — we catch it and answer 400 Bad Request instead of letting the whole server crash. Notice the return after sending the error: without it, execution falls through and tries to respond a second time, which throws (more on that ordering rule next).

⚠️ One more guard for the real world: this helper appends every chunk with no limit, so a malicious client could stream gigabytes and exhaust your memory. In production you'd cap body.length and reject once it crosses a threshold (a few hundred KB is plenty for JSON). Express's json() does this too, via its limit option.

Writing JSON: status, headers, body — in that order

Sending a response is three moves: set the status and headers, serialize your data, end the stream. Here's the companion helper:

function sendJson(res, status, data) {
  res.writeHead(status, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(data));
}

What just happened: res.writeHead(status, headers) sets the status line and headers in one call. JSON.stringify(data) turns your object into the wire format, and res.end(...) writes that string and closes the response. We set Content-Type: application/json so the client (and the browser's network tab) knows it's getting JSON, not plain text. Now sendJson(res, 200, { messages: [...] }) replaces four lines with one.

⚠️ Order is not optional. Headers and status must be set before you write any body. The first res.write() or res.end() "flushes the head" — it sends the status line and headers down the wire, and after that they're locked. Try to set a header afterward and Node throws the error every Node dev meets eventually:

// WRONG — throws "Cannot set headers after they are sent to the client"
res.end(JSON.stringify({ ok: true }));   // body goes out, head is now flushed
res.writeHead(200);                      // too late — head already left the building

What just happened: res.end(...) already committed the status and headers, so the later writeHead has nothing to write into. The fix is always the same: writeHead (or setHeader) first, body last. If you find yourself hitting this, it's almost always a missing return after an early response — two code paths both trying to answer the same request.

💡 res.setHeader('X', 'y') sets one header at a time and can be called repeatedly before the first write; res.writeHead(status, {...}) sets the status plus a batch of headers in one shot. Same rule binds both: nothing after the body starts flowing.

Status codes

The status code is just a number, and you can pass it straight to writeHead. The handful you'll use constantly for a JSON API:

  • 200 OK — a successful GET.
  • 201 Created — you made a new resource (a fresh message).
  • 204 No Content — success, but there's nothing to send back (e.g. a delete).
  • 400 Bad Request — the client sent something wrong (that invalid JSON).
  • 404 Not Found — no such route or resource.

If you'd rather not memorize numbers, node:http ships http.STATUS_CODES — a lookup from number to its text, e.g. http.STATUS_CODES[404] is 'Not Found'. Handy for building a generic error responder.

204 is the special one — "No Content" means literally no body, so you set the status and end immediately, writing nothing:

function sendNoContent(res) {
  res.writeHead(204);
  res.end();          // no argument — no body, by definition
}

What just happened: a 204 promises an empty body, so we call res.end() with no argument. Don't set Content-Type and don't stringify anything — there's nothing to describe. This is the right answer for a successful DELETE /messages/3: it worked, and there's nothing meaningful to return.

Recap

  • A handler reads from req (method, URL, headers, body-stream) and writes to res (status, headers, body). Both are streams; JSON is manual in both directions.
  • req.method, req.url, and req.headers are free properties. Parse req.url with new URL(req.url, 'http://localhost') to get pathname and searchParams.
  • The body is not on req — it streams in as chunks. Collect them on 'data', parse on 'end', and guard invalid JSON with try/catch. That collect-and-parse helper is what express.json() does.
  • Writing JSON is set-header, set-status, serialize, end — and headers/status must come before any body write, or you get "Cannot set headers after they are sent." A stray missing return is the usual culprit.
  • Reach for the right status: 200/201/400/404, and 204 means no body at all (writeHead(204) then res.end()).

Quick check

[
  {
    "q": "Why can't you read the request body directly off req.body in node:http?",
    "choices": ["req.body only works for GET requests", "req is a readable stream — the body arrives as chunks you must collect, then parse", "You must call req.parse() first", "node:http strips the body for security"],
    "answer": 1,
    "explain": "req is a readable stream. You listen for 'data' chunks, concatenate them, and parse on 'end'. That collect-and-parse work is exactly what express.json() bundles for you."
  },
  {
    "q": "You call res.end(JSON.stringify(data)) and then res.writeHead(200). What happens?",
    "choices": ["It works fine", "Node throws 'Cannot set headers after they are sent' — the first write already flushed the head", "The status silently defaults to 500", "writeHead overrides the body"],
    "answer": 1,
    "explain": "The first res.write/res.end flushes the status and headers. After that they're locked, so a later writeHead throws. Set status/headers before writing the body — a missing return is the usual cause."
  },
  {
    "q": "A successful DELETE /messages/3 has nothing to return. What's the right response?",
    "choices": ["200 with an empty {} body", "404 Not Found", "204 with writeHead(204) and res.end() — no body", "201 Created"],
    "answer": 2,
    "explain": "204 No Content means success with nothing to send. You set the status and call res.end() with no argument — no Content-Type, no stringify, no body."
  }
]

← Phase 1: The node:http Mental Model · Guide overview · Phase 3: Routing by Hand →

Check your understanding

1. Why can't you read the request body directly off req.body in node:http?

2. You call res.end(JSON.stringify(data)) and then res.writeHead(200). What happens?

3. A successful DELETE /messages/3 has nothing to return. What's the right response?

Was this page helpful?