Request and Response Objects

Every request handler you write with Node’s http module receives two objects: a request object describing what the client sent, and a response object you use to send something back. Knowing exactly what these objects are — not just which two or three methods to call on them — is the difference between copy-pasting server code and actually understanding how Node handles HTTP. Both objects sit on top of Node’s stream and event APIs, so once you understand streams, they stop feeling like a black box.

Overview: How Request and Response Objects Work

When you call http.createServer(callback), Node invokes your callback once for every incoming HTTP request, passing two arguments: req and res. Under the hood, libuv accepts the raw TCP connection and Node’s HTTP parser reads bytes off the socket. As soon as the request line and headers have been fully parsed, Node constructs an http.IncomingMessage object (that’s your req) and fires your callback — before the request body (if any) has necessarily finished arriving. The body streams in afterward, as more data arrives over the network, which is why reading a request body is always an asynchronous, stream-based operation rather than a value you can read synchronously.

The response object, res, is an instance of http.ServerResponse, which is a writable stream (technically a subclass of http.OutgoingMessage). Nothing is sent to the client until you write to it, and the response is not considered complete until you explicitly call res.end(). Node will not guess when you’re done — if you forget to end the response, the client’s connection simply hangs.

The request object (IncomingMessage)

req is a readable stream, and it also carries metadata about the request as plain properties. The core http module does not parse the query string, cookies, or JSON body for you — that parsing is exactly what frameworks like Express add on top. With bare http, you read the raw properties yourself and consume the body as a stream.

Property / Method Meaning
req.method HTTP method as a string, e.g. "GET", "POST"
req.url Path and query string exactly as sent, e.g. /greet?name=Ada
req.headers Plain object of lower-cased header names to values
req.httpVersion HTTP version string, e.g. "1.1"
req.socket The underlying net.Socket, useful for the remote IP (req.socket.remoteAddress)
'data' / 'end' events Fired as request body chunks (Buffers) arrive, then once the body is fully received

The response object (ServerResponse)

Property / Method Meaning
res.statusCode Set the numeric status code (defaults to 200) before headers are sent
res.setHeader(name, value) Set a single response header
res.writeHead(code, headers) Set the status code and headers together, in one call
res.write(chunk) Send a chunk of the body without ending the response
res.end([chunk]) Send an optional final chunk and finish the response — required on every path
res.headersSent true once headers have gone out; you cannot change them after this

Syntax

http.createServer((req, res) => {
  // req: http.IncomingMessage (readable stream + metadata)
  // res: http.ServerResponse (writable stream)

  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Hello\n");
});
  • req — read-only metadata (method, url, headers) plus a stream you consume for the body.
  • res — a stream you write status, headers, and body chunks to, then close with end().
  • writeHead(statusCode, headers) — sets status and headers in a single call; must happen before any write() or end() with a body.

Examples

Example 1: Inspecting the request

This server logs the method, URL, and headers of every request, then echoes them back as plain text.

import http from "node:http";

const server = http.createServer((req, res) => {
  console.log(`${req.method} ${req.url}`);
  console.log("Headers:", req.headers);

  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end(`You requested ${req.url} using ${req.method}\n`);
});

server.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

Output:

$ curl http://localhost:3000/hello
You requested /hello using GET

# server console:
GET /hello
Headers: { host: 'localhost:3000', 'user-agent': 'curl/8.4.0', accept: '*/*' }

Notice that req.headers is a plain object with already-lower-cased keys, and req.url includes everything after the host — path and query string together.

Example 2: Query strings and JSON responses

Because req.url is just a raw string, use the global URL class to parse it safely instead of splitting on "?" and "&" yourself.

import http from "node:http";

const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);

  if (url.pathname === "/greet") {
    const name = url.searchParams.get("name") ?? "stranger";
    const payload = { message: `Hello, ${name}!` };

    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(payload));
    return;
  }

  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "Not found" }));
});

server.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

Output:

$ curl "http://localhost:3000/greet?name=Ada"
{"message":"Hello, Ada!"}

$ curl http://localhost:3000/nope
{"error":"Not found"}

The new URL(req.url, base) constructor needs a base because req.url is relative (Node never gives you a full absolute URL). Using req.headers.host as the base is the standard trick.

Example 3: Reading a JSON request body

The request body isn’t parsed for you — req is a readable stream, so you must collect its chunks before you have the full body. This example reads a POST body, parses it as JSON, and echoes it back.

import http from "node:http";

function readBody(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
    req.on("error", reject);
  });
}

const server = http.createServer(async (req, res) => {
  if (req.method === "POST" && req.url === "/echo") {
    try {
      const rawBody = await readBody(req);
      const data = JSON.parse(rawBody);

      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ youSent: data }));
    } catch (err) {
      res.writeHead(400, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: "Invalid JSON body" }));
    }
    return;
  }

  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "Not found" }));
});

server.listen(3000);

Output:

$ curl -X POST http://localhost:3000/echo \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada"}'
{"youSent":{"name":"Ada"}}

The error listener on req matters: if the client disconnects mid-upload, the stream emits 'error' instead of 'end', and without a listener that error would crash the process.

How It Works Step by Step

  • 1. The TCP connection is accepted by libuv and handed to Node’s HTTP parser.
  • 2. Once the request line and headers are fully parsed, Node creates the req object and invokes your createServer callback synchronously with req and a fresh res.
  • 3. If there’s a body, it has not necessarily arrived yet — it streams in as separate 'data' events on req, followed by 'end' once the socket signals the body is complete.
  • 4. Your handler calls res.writeHead() / res.setHeader() to set status and headers, then res.write() zero or more times, then res.end().
  • 5. Calling res.end() flushes any buffered data to the socket and emits a 'finish' event on res.
  • 6. Depending on the Connection header and HTTP version, the underlying socket is either kept alive for the next request (HTTP/1.1 keep-alive, the default) or closed.

Common Mistakes

Mistake 1: Forgetting to call res.end()

If you only call res.write(), the response is never finished and the client hangs waiting forever.

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.write("Processing...");
  // Missing res.end() -- the client waits forever for the response to finish.
});

Fix it by always ending the response on every code path, even error paths:

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.write("Processing...");
  res.end("done\n");
});

Mistake 2: Assuming req.body exists

Node’s core http module never parses the body for you. Reading req.body directly always gives undefined, because no such property is ever set.

const server = http.createServer((req, res) => {
  console.log(req.body); // undefined -- node:http never parses the body for you
  res.end("ok");
});

Since Node 12, IncomingMessage is async-iterable, so you can collect the body with a for await loop instead of manual event listeners:

const server = http.createServer(async (req, res) => {
  const chunks = [];
  for await (const chunk of req) {
    chunks.push(chunk);
  }
  const body = Buffer.concat(chunks).toString("utf8");
  res.end(`You sent: ${body}`);
});

A third common pitfall: calling res.setHeader() or changing res.statusCode after res.writeHead() or res.write() has already run. Once headers are sent (res.headersSent === true), Node throws an ERR_HTTP_HEADERS_SENT error — always finish setting headers before writing any body content.

Best Practices

  • Always call res.end() on every branch of your handler, including error and 404 paths.
  • Attach an 'error' listener to req before reading its body — an aborted upload emits 'error', not 'end'.
  • Parse req.url with new URL(req.url, \`http://${req.headers.host}\`) instead of manual string splitting.
  • Set Content-Type explicitly with writeHead or setHeader — clients shouldn’t have to guess the response format.
  • Set headers and status before calling write() or end(); check res.headersSent if a handler might run twice on the same response.
  • For large or unbounded request bodies, enforce a size limit while accumulating chunks to avoid unbounded memory growth from a malicious client.
  • Reach for a framework like Express once you find yourself reimplementing routing, body parsing, and query parsing by hand — it wraps exactly this req/res API.

Practice Exercises

  • Write a server that responds with 405 Method Not Allowed and a JSON error body for any request that isn’t GET or POST.
  • Write a /time route that reads an optional ?tz= query parameter and returns the current time as JSON, defaulting to UTC when tz is missing.
  • Write a POST /upload route that reads the raw request body with for await (const chunk of req), and responds with the number of bytes received without ever holding more than one chunk in memory at a time.

Summary

  • req is an http.IncomingMessage — a readable stream plus metadata like method, url, and headers.
  • res is an http.ServerResponse — a writable stream you control with writeHead, write, and end.
  • The request body is not available synchronously; it must be read from the stream as 'data'/'end' events or via for await.
  • Node’s core http module parses nothing beyond headers and the request line — query strings, JSON bodies, and cookies are your responsibility (or a framework’s).
  • Every response must end with res.end() on every code path, or the client hangs.