DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/Node.js/HTTP Server Fundamentals
intermediate25 min read·Updated Jun 2025

HTTP Server Fundamentals

req and res aren't special HTTP-only objects — req is a Readable stream and res is a Writable stream, the exact same primitives from Streams & Buffers wearing HTTP clothing. Understanding that turns 'how does Express work' from a black box into a direct application of mechanics you already know.

httpcreateServerrequest-responsekeep-alivestreams

Knowledge Check

20 questions · pass at 70%

0/20
easymcq

1. What are req and res, in terms of their underlying type?


Interview Questions

2 questions

Cheatsheet

Download-ready reference
Cheatsheet
http.createServer((req, res) => { ... }).listen(port);

req (IncomingMessage) = a REAL Readable stream
res (ServerResponse)  = a REAL Writable stream
  → SAME backpressure/data/end/drain mechanics as Streams & Buffers

⚠️ NO native req.body — body arrives via:
  req.on('data', chunk => ...)   // Buffer chunks
  req.on('end', () => ...)        // body fully received
  (or: req.pipe(destination) for automatic backpressure handling)

res.writeHead(status, headers)  → set status + headers (once, before body)
res.write(chunk)                 → send a chunk (streams to client immediately)
res.end([chunk])                 → finish the response
  ⚠️ forgetting res.end() → request hangs, client never completes

KEEP-ALIVE (HTTP/1.1 default): TCP socket REUSED across requests from
  the same client — avoids repeated handshake overhead.

Hand-rolled routing = method + url lookup table.
  Frameworks (Express, etc.) ADD: path params, body parsing,
  middleware chaining — built ON TOP of these same primitives,
  not a replacement for them.

Large uploads: PIPE to destination, don't buffer the whole body in
  memory — set a size limit to prevent memory exhaustion.

Related topics

error handlingStreams & BuffersPro

On this page

25m
Knowledge CheckInterview QuestionsCheatsheet

Concept#

The beginner framing: before reaching for Express or any framework, it's worth seeing what Node actually hands you natively — a working HTTP server in about five lines, with no dependencies.

const http = require("http");
 
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Hello, world!");
});
 
server.listen(3000);

The precise mental model: http.createServer() returns a Server instance that listens for TCP connections and, for each incoming HTTP request, invokes your callback with two objects — req (an IncomingMessage) and res (a ServerResponse). The single most important fact about both, confirmed by checking their actual prototype chains in this app's installed Node v23.11.0: req is a Readable stream, and res is a Writable stream — the exact same stream primitives from Streams & Buffers, not a separate HTTP-specific abstraction.

console.log(req instanceof require("stream").Readable); // true
console.log(res instanceof require("stream").Writable); // true

The request lifecycle#

A request's full lifecycle: socket → parse → handler → response → reuse
step 1 / 5
const server = http.createServer((req, res) => {
// ...
});
server.listen(3000);
Socket
waiting
TCP connection open
→
Parse
idle
no data yet
→
Handler
idle
not invoked
→
Response
idle
nothing sent

A client opens a TCP connection to the server. Node's HTTP parser is attached to the socket, waiting for bytes to arrive — nothing else happens until data shows up.

A client's TCP connection arrives; Node's built-in HTTP parser incrementally builds req as bytes come in, starting with the request line and headers; your handler runs once headers are parsed (the body, if any, may still be arriving); res.write()/res.end() send the response back over the same socket. With HTTP/1.1 keep-alive (the default), that socket is then reused for the client's next request rather than torn down — avoiding a fresh TCP handshake per request, a meaningful latency win under repeated requests from the same client.

req as a stream: bodies arrive in chunks#

const server = http.createServer((req, res) => {
  let body = "";
  req.on("data", (chunk) => { body += chunk; });
  req.on("end", () => {
    const parsed = JSON.


There is no req.body built into Node — that convenience is exactly what body-parsing middleware (Express's express.json(), for instance) provides. Natively, a request body arrives as a sequence of 'data' events carrying Buffer chunks, terminated by an 'end' event — precisely the Readable-stream contract from the streams topic, now applied to HTTP.

req is a Readable stream — a large upload still respects backpressure
step 1 / 3
http.createServer((req, res) => {
let received = 0;
req.on('data', (chunk) => {
received += chunk.length;
});
});
Client upload
active
sending large file
→
req (Readable)
active
chunk by chunk
→
Handler
waiting
accumulating bytes

A client uploads a large file. `req` is a Readable stream — the body does NOT arrive all at once; it arrives as a sequence of 'data' events, each carrying one chunk, exactly like any other readable stream.

For a large upload, the same backpressure mechanics apply: if the handler pipes the body somewhere slow (disk, a remote API), req.pipe(destination) handles pause/resume automatically, exactly as it does for any other stream pair.

The same write()-returns-false / 'drain' mechanism applies to res, the writable side
step 1 / 5
const ok = writable.write(chunk1);
// ok === true — buffer has room, keep writing
Producer
writing
pushing data
Internal bufferhighWaterMark: 16kb
chunk1
Consumer
idle
not reading yet

A fast producer writes chunk1. The internal buffer has plenty of room below the highWaterMark, so write() returns true — the producer keeps going immediately.

Hand-rolled routing: why frameworks exist#

const server = http.createServer((req, res) => {
  if (req.method === "GET" && req.url === "/users") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify([{ id: 1, name: "Ada"





This works, but it makes obvious exactly what a framework buys you: route pattern matching (/users/:id), automatic body parsing, middleware chaining, consistent error handling — all of it is built on top of these same createServer/req/res primitives. Express, in particular, doesn't replace this model; it wraps it.


Try It#

Predict the output before checking the solution.

const server = http.createServer((req, res) => {
  res.write("first chunk ");
  setTimeout(() => {
    res.write("second chunk ");
    res.end("done");
  }, 100);
});
server.listen(

Does the client receive the response as one piece, or does something else happen?

Solution

The client's fetch still resolves with the full concatenated body — "first chunk second chunk done" — because HTTP response streaming is transparent to the caller unless the client is specifically reading the response as a stream itself. But on the wire, the three res.write()/res.end() calls are sent as separate chunks (using chunked transfer-encoding, since no Content-Length was set upfront) — the server did NOT wait for all the data to be ready before starting to send. This is the practical benefit of res being a real stream: you can start sending a response before the entire payload exists.


Implement It Yourself#

Build a minimal router that dispatches by method + path, without any framework:

function createRouter() {
  const routes = [];
 
  return {
    add(method, path, handler) {
      routes.push({ method, path, handler });
    },
    handle(req, res) {
      const match = routes.find((r) => r.method 















This is the essential shape of what every Node HTTP framework does at its core — a lookup table matching method+path to a handler function, dispatched from inside the one createServer callback.


Under the Hood#

req/res being real Readable/Writable streams is a direct, literal application of Streams & Buffers — the backpressure mechanics, the 'data'/'end'/'drain' events, and .pipe() all apply unchanged. And every request handler invocation is itself scheduled and executed as part of the event loop's phases (see Event Loop & libuv) — specifically surfacing through the poll phase once the underlying socket I/O is ready. How to handle a handler that throws or rejects mid-request is covered in depth in Error Handling & Async Context.


Common Mistakes#

1. Assuming req.body exists natively#

const server = http.createServer((req, res) => {
  console.log(req.body); // ❌ undefined — Node has no built-in body parsing
});

Node parses headers and exposes the body as a raw stream — JSON/form parsing is something you (or a framework) must do explicitly by consuming req's 'data'/'end' events.

2. Forgetting to call res.end()#

const server = http.createServer((req, res) => {
  res.write("partial response");
  // ❌ no res.end() — the connection just hangs, client never gets a complete response
});

Without res.end(), the response is never considered complete — the client's request appears to hang indefinitely.

3. Buffering an entire large request body in memory before processing it#

let body = "";
req.on("data", (chunk) => { body += chunk; }); // ❌ no size limit — a huge upload exhausts memory

For uploads of unbounded or very large size, accumulating the entire body in a string/array before processing defeats the purpose of streaming — pipe directly to the eventual destination (disk, a parser that itself streams) instead, and enforce a maximum size.


Best Practices#

  • Treat req/res as the streams they are — use .pipe() for large bodies/responses instead of manually buffering everything in memory first.
  • Always pair res.write() calls with an eventual res.end(), even in early-return error paths.
  • Reach for a framework once routing/middleware complexity grows — hand-rolled routing is valuable for understanding the mechanics, but a real application benefits from the tested, standard abstractions (Express, etc.) built on these same primitives.
  • Set a body-size limit when manually accumulating a request body, to prevent a malicious or mistaken huge upload from exhausting server memory.

Performance Tips#

  • HTTP/1.1 keep-alive (default) reuses the underlying TCP connection across multiple requests from the same client — avoiding a fresh TCP handshake (and TLS handshake, if HTTPS) per request is a real, measurable latency improvement under repeated traffic.
  • Streaming a response with res.write() calls as data becomes available (rather than buffering the entire response in memory first, then calling res.end() once) lets the client start receiving bytes sooner — directly applying the same time-to-first-byte benefit that motivates streaming generally.

parse
(body);
res.end(`Received: ${parsed.name}`);
});
});
}]));
} else if (req.method === "POST" && req.url === "/users") {
// ...body parsing, then respond
} else {
res.writeHead(404).end("Not found");
}
});
3000
);
// client does: fetch('http://localhost:3000').then(r => r.text()).then(console.log)
===
req.method
&&
r.path
===
req.url);
if (!match) {
res.writeHead(404).end("Not found");
return;
}
match.handler(req, res);
},
};
}
const router = createRouter();
router.add("GET", "/users", (req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: 1, name: "Ada" }]));
});
const server = http.createServer((req, res) => router.handle(req, res));