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:
reqis 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.resis 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 = ;
const server = http.;
server.;
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.;
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:
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 writeapp.use(express.json())in Express, this exact collect-chunks-then-parse logic runs before your route, and the result lands onreq.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.;
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:
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.; // body goes out, head is now flushed
res.; // 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:
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 tores(status, headers, body). Both are streams; JSON is manual in both directions. req.method,req.url, andreq.headersare free properties. Parsereq.urlwithnew URL(req.url, 'http://localhost')to getpathnameandsearchParams.- The body is not on
req— it streams in as chunks. Collect them on'data', parse on'end', and guard invalid JSON withtry/catch. That collect-and-parse helper is whatexpress.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
returnis the usual culprit. - Reach for the right status: 200/201/400/404, and 204 means no body at all (
writeHead(204)thenres.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?