Everything you see in Express, Next.js, or Fastify reduces to one thing: raw TCP bytes parsed by llhttp, handed to you as req and res. Organised into three parts — open any card to dive in.
03
Parts
16
Topics
03
Frameworks
~40
Minutes
Live — request pipelineclick a stage
00 — The Map
How it all connects.
Every topic on this page is a node in one dependency tree — each concept builds on the ones above it, three convergence points (the layer stack, the ultimate truth, the matrix) tie each part together. Click any node to open its deep dive; hover to trace its lineage.
Tap any node to open its detail · hover to trace relationships
part 01part 02part 03convergence
01 — The Module
The Module.
Node's http module is a structured wrapper around raw TCP. These six topics take you from the bytes on the wire up to a pooled, reusable connection.
02 — The Algorithm
The Algorithm.
Once the request is an object, the real work begins: the full transformation stack, CORS, how Express decorates the request, and the truths that survive every abstraction.
03 — Framework Compare
Framework Compare.
The same query-string problem, solved three ways — raw Node, Express, and Hono — then laid side by side with no ambiguity.
Glossary
01 The Problem It Solves
Before http.createServer, you'd write this yourself.
When your browser's fetch() sends an HTTP request across the network, it arrives at the server as raw bytes over a :
a raw byte stream — no method, no headers, no body. Just bytes. The work below is yours to write.
01
01
you build this
Open a by hand
Bind to a port, accept connections, and cope with the fact that bytes dribble in across many reads — never neatly aligned to a message.
bind() · listen()partial readsconnection state
02
02
you build this
Write the parser state machine
Track exactly where you are in the message as bytes arrive: request line, then headers, then body — framed by or .
request-line → headers → bodyframingchunked
03
03
you build this
Extract method, URL, headers, body
Turn the parsed scraps into something usable — and survive every malformed case real clients send your way.
duplicate headersmultiline valuesencodings
04
04
you enforce this
Obey HTTP's ordering rules
On the way back out: status line first, then headers, then body — and exactly one status line, or the client rejects your response.
status-line firstheaders before bodyone status line
after all of that, you finally have
{ method, url, headers, body }and you maintain
the alternativehttp.createServer((req, res) => { … })— all four stages, handled. That's the entire job of the http module.
👉 The Point
Node's http module = A structured wrapper around raw TCP that understands HTTP. It does two things: Parses incoming bytes → gives you a clean JavaScript object (the req). Formats outgoing responses → ensures you send valid HTTP back.
Layer 1: Your code — business logic. Layer 2: HTTP layer — protocol understanding. Layer 3: TCP layer — raw bytes. Each layer transforms the previous one perfectly.
04 req & res as Streams
The hardest concept. The most important one.
Why Streams?
When your browser sends data, it doesn't arrive all at once. It arrives in chunks over time:
TCP chunks arriving over time
[t=0ms]
POST /api HTTP/1.1\r\n
[t=5ms]
Host: example.com\r\n
[t=10ms]
\r\n
[t=15ms]
{"userId":
[t=20ms]
123}
⚠ Why This Matters
If Node buffered all of this into memory before giving it to you, large file uploads would crash the server. Streams give you chunks as they arrive — you process and discard.
What req Is
req (IncomingMessage) is a with HTTP metadata attached:
TypeScript
// Stream interface (you listen to events)req.on('data', chunk => { /* handle chunk — called 1 to 1000 times */ })
req.on('end', () => { /* all chunks received */ })
// HTTP metadata (you read these properties)req.method// 'GET', 'POST', etc.req.url// '/api/users?id=5'req.headers// { 'content-type': 'application/json', ... }req.httpVersion// '1.1'
Why both? Because HTTP headers are metadata (static), but the body is data (streaming).
What res Is
res (ServerResponse) is a that enforces HTTP ordering:
TypeScript
// Must set status/headers BEFORE writing bodyres.statusCode=200;
res.setHeader('Content-Type', 'application/json');
// THEN write bodyres.write('{"success": true}');
// THEN end — forgetting this hangs the browser FOREVERres.end();
⚠ Strict Ordering
HTTP headers go on the wire first, then the body. Once you call write(), headers are "locked in" — you can't change them. Once you call end(), nothing else can be sent on that connection.
Browser's fetch() has constructed HTTP and sent it as TCP packets over the network.
2
libuv Receives C library reads TCP socket
libuv (the C library under Node.js) receives the TCP packets. It doesn't wait for all bytes — it notifies Node as soon as some arrive. The chunks accumulate in a read buffer for the parser to consume.
3
llhttp Parses State machine reads bytes
Node's HTTP parser (llhttp) is a state machine that reads raw bytes and builds a structured object. It extracts the status line, headers, and marks the start of the body stream.
fetch Promise Resolves Browser event loop fires your .then()
The browser reconstructs a Response object from the bytes, the fetch resolves, and your .then() callback fires. The body is still a stream — you need response.json() to consume it.
06 Connection Reuse
TCP connections are expensive. Agent fixes that.
Opening a TCP connection requires a . Plus for HTTPS. This takes 50–200ms — per request — if you don't reuse connections.
✗ Without Agent (Old Way)
Request 1: Open socket → GET / → Close socket
Request 2: Open socket → GET / → Close socket
Request 3: Open socket → GET / → Close socket
→ 3 TCP handshakes = 3× latency
✓ With Agent (Modern)
Request 1: Open socket → GET / → Keep alive
Request 2: Reuse socket → GET / → Keep alive
Request 3: Reuse socket → GET / → Keep alive
→ 1 TCP handshake = 10–100× faster
💡 Why Second Requests Feel Faster
http.globalAgent handles this automatically. It keeps a pool of sockets to remote servers and reuses them. This is why your fetch() to the same server is faster on the second call.
07 Complete Layer Stack
Every transformation, from JavaScript to bytes and back.
Each layer perfectly transforms the previous one. No information is lost, but each layer speaks a different language.
L1
· ·
fetch(url, init) → Promise<Response>
L2
Type checking · JS → internal spec types
RequestInfo | RequestInit → InternalRequest
L3
CORS checks · · Redirect following
Algorithm → HTTP bytes
L4
protocol · hostname · pathname · search · hash
string → URL object
L5
· ·
domain → IP → encrypted tunnel
L6
Raw TCP packets ·
Buffer chunks → network
L7
· · read buffer
bytes → Node event loop
L8
State machine · method · url · headers
bytes → structured object
L9
+ HTTP metadata
req.method · req.url · req.headers
L10
req.query · req.params · req.body · routing
raw req → ergonomic API
L11
Business logic · database · response
app.post('/api', (req, res) => { ... })
Critical Insight — Query Params
Query params are always strings, at every layer. Browser: /products?page=2 → Node: req.url === "/products?page=2" → Express: req.query.page === "2" (still a string!) → Your code: parseInt(req.query.page) === 2. Express never auto-converts types.
08 CORS Preflight
A preflight is a separate HTTP request. Not an extra header.
A is not "the real request with extra headers." It is a separate HTTP request whose only job is to ask permission for the real request.
When Preflight Triggers
Condition
Triggers Preflight?
Why
Cross-origin GET, no custom headers
✗ No
"Simple" request
Cross-origin POST with application/json body
✓ Yes
Non-simple Content-Type
Any custom header (X-Api-Key, etc.)
✓ Yes
Custom headers require permission
PUT, DELETE, PATCH
✓ Yes
Non-simple method
Same origin (any method)
✗ No
CORS doesn't apply
The Two-Request Flow
Browser
shop.example.org
→
OPTIONS /api/products Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type,x-api-key
Preflight
←
204 No Content Access-Control-Allow-Origin: https://shop.example.org Access-Control-Allow-Methods: POST, GET Access-Control-Max-Age: 600
Permission
→
POST /api/products Content-Type: application/json X-Api-Key: abc123 {"search":"immune support"}
Actual
←
200 OK — {"products":[...]}
Response
Server
api.example.com
⚠ Server Receives TWO HTTP Requests
The server received OPTIONS /products (asking for permission) AND POST /products (the actual request). Both must be handled. Most frameworks do this automatically with CORS middleware.
CORS Happens in the Browser — Not Node
TypeScript
// Node server never sees a CORS problem// The browser blocks the response before your code runs// Browser-side:fetch(url)
.then(r => r.json()) // ← Blocked by browser if CORS fails
.catch(e => console.log(e)) // NetworkError — not a real error// Node never gets involved in CORS checking
💡 Preflight Is Deterministic
Given cross-origin + POST/PUT/DELETE + application/json Content-Type + any custom headers → preflight always happens. It's not a bug, not a config mistake. It's the browser doing its job.
09 Express Decoration Layer
Express doesn't mutate req. It decorates it.
Raw Node vs Express
Raw Node http
// Node gives you just a stringreq.url==="/products?page=2&cat=vit"// You must parse manuallyconst url =newURL(req.url,
`http://${req.headers.host}`);
const page =url.searchParams.get('page');
Express
// Express parses it for you
console.log(req.query);
// { category: "vitamins", page: "2" }// Route params: /users/:id
console.log(req.params);
// { id: "123" }// Body (with middleware)
console.log(req.body);
// { search: "immune support" }
How Express Decorates req
Express doesn't "mutate" the Node request. It adds properties:
req.query.page === "2" — always a string, never a number. req.query.active === "true" — always a string, never a boolean. You must parse explicitly with parseInt(), parseFloat(), or === 'true'.
10 Critical Insights
Lock these in. They come up in interviews.
Insight 01
fetch() is Not "Just an HTTP Call"
It's a browser algorithm that validates input, performs CORS preflight (a separate HTTP request), validates CORS response headers, streams the body, and returns a Promise. All before your .then().
Insight 02
Query Params Are Always Strings
From browser → Node → Express: req.query.page === "2". Always a string. Express never auto-converts types. parseInt() is your responsibility.
Insight 03
Node's req Is Not "The Request"
It's a parsed, stream-backed JavaScript representation of an HTTP message. Raw bytes → llhttp parser → IncomingMessage. The body is still a stream — never buffered by default.
Insight 04
Streaming Is the Default
Both request and response bodies are streams at every layer. Never assume the full body is in memory. Without streams: 1GB upload = 1GB in memory = server crash.
Insight 05
Headers Are Normalized, Not Changed
Browser sends "Accept: application/json" → Node receives req.headers["accept"] === "application/json". Header names are lowercased by Node, values are exact.
Insight 06
CORS Happens in the Browser
Node server never sees a CORS problem. The browser blocks the response before your JS runs. CORS middleware on the server sets the headers that tell the browser it's okay.
Insight 07
res.end() Is Not Optional
If you forget to call res.end(), the browser waits forever. No timeout. No error. Just a pending request that will eventually time out on the client side.
app.post('/api/products', async (req, res) => {
const { category, page } =req.query;
const { search } =req.body;
const results =awaitdb.query(
'SELECT * FROM products WHERE category = ? AND name LIKE ?',
[category, search]
);
res.json(results);
});
You're working at the Express abstraction layer. Below you are layers upon layers:
The Complete Transformation Pipeline
Browser fetch() — JavaScript
↓ (JavaScript → WebIDL)
Fetch Standard algorithm
↓ (algorithm → network bytes)
HTTP bytes on wire
↓ (bytes → TCP packets)
Node TCP socket (libuv)
↓ (chunks → parser state machine)
llhttp parser
↓ (bytes → method/url/headers)
IncomingMessage (req)
↓ (your handler)
ServerResponse (res)
↓ (headers + body → bytes)
HTTP bytes on wire
↓ (TCP packets → browser)
Response object
↓ (JavaScript)
Your .then() callback
World 1: JavaScript
Functions, Promises, objects fetch() → Promise → Response → data
World 2: Specification
Web IDL, Fetch Standard, HTTP spec, algorithms, validation
World 3: Network
Raw bytes, chunks, state machines, TCP packets
🎓 The Final Truth
When you understand this entire flow, you understand HTTP. Express, Next.js, Fastify, Hono — they're all just different abstractions over the same pipeline. Every abstraction leaks eventually. Now you know what's underneath when it does.
Next Frontier
Service WorkersHTTP/2 MultiplexingCaching Layergzip / Brotli
12 Query Params: 3 Worlds
Same HTTP request. Three very different developer experiences.
Every framework sits on top of Node's IncomingMessage. What separates them is what they do between raw bytes and your handler. Query param handling is where this gap is most visible.
We'll use one canonical request throughout — a realistic scenario you'll see on a TypeScript team:
GET /products/42?page=2 HTTP/1.1\r\nHost: …\r\n\r\n
13 Raw Node — Zero Abstraction
req.url is a string. Everything else is your problem.
Node's http module gives you exactly what the HTTP parser extracted: a raw URL string. It doesn't know about routes. It doesn't parse query params. It doesn't buffer the body.
TypeScript — Raw Node http
import http from'node:http';
http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
// ① req.url is exactly what came off the wire — never parsed// "/products/42/reviews?page=2&limit=10&sort=rating&filter%5Bmin%5D=4"const rawUrl =req.url??'/';
// ② You must construct a full URL to parse it (URL needs an origin)const url =newURL(rawUrl, `http://${req.headers.host}`);
// ③ URLSearchParams: get() returns string | null — never a numberconst page =Number(url.searchParams.get('page') ??'1'); // → 2const limit =Number(url.searchParams.get('limit') ??'20'); // → 10const sort =url.searchParams.get('sort') ??'created_at'; // → "rating"// ④ filter[min]=4 → key is literally "filter[min]", not a nested objectconst filterMin =url.searchParams.get('filter[min]'); // → "4"// ⑤ Route params: Node has NO router — you match manuallyconst match =url.pathname.match(/^\/products\/(\d+)\/reviews$/);
if (!match) { res.statusCode=404; res.end(); return; }
const productId =Number(match[1]); // → 42// ⑥ Body: always a stream — buffer it yourselfconst chunks: Buffer[] = [];
req.on('data', c => chunks.push(c));
req.on('end', () => {
const body = chunks.length?JSON.parse(Buffer.concat(chunks).toString())
: null;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ productId, page, limit, sort, filterMin }));
});
}).listen(3000);
①
Raw string from the parser — may include URL-encoded chars like %5B for [
②
new URL() needs a base. Use req.headers.host. Will throw if host header is missing.
③
URLSearchParams.get() always returns string | null. You coerce to numbers yourself.
④
Bracket notation stays as a literal key. No nested object. Express's qs parser handles this differently.
⑤
Node has zero routing. You match the URL yourself with regex and extract capture groups.
⑥
Body arrives in chunks. Buffer them all, concat, then parse. No shortcuts.
What you own at every step
Concern
Who handles it
Your code
URL parsing
You
new URL(req.url, base)
Query string parsing
You
url.searchParams.get('key')
Nested params filter[min]=4
You
Literal key "filter[min]" — not nested
Route matching
You
url.pathname.match(/regex/)
Route params extraction
You
match[1], match[2], …
Body buffering
You
req.on('data') + Buffer.concat()
Body parsing
You
JSON.parse(raw)
404 handling
You
if (!match) { res.statusCode = 404 }
When to use raw Node http
Writing a framework or library, building a proxy/gateway, creating a minimal Lambda handler, or when you need absolute performance with zero overhead. Not for application logic.
14 Express — The Classic
Same request. Three lines. But you need to know what happens under them.
TypeScript — Express v4
import express from'express';
const app =express();
// ① body-parser middleware — express.json() buffers + parses the stream// Must come BEFORE your routes. Attaches parsed body to req.body.
app.use(express.json());
// ② Route definition — uses path-to-regexp under the hood// :productId becomes a named capture group in a regex
app.get('/products/:productId/reviews', (req, res) => {
// ③ Route param — extracted by path-to-regexp, always a stringconst productId =Number(req.params.productId); // → 42// ④ req.query — parsed by Express's query parser (qs by default)// qs handles bracket notation: filter[min]=4 → { filter: { min: "4" } }const page =Number(req.query.page) ||1; // req.query.page === "2"const limit =Number(req.query.limit) ||20; // req.query.limit === "10"const sort =req.query.sortasstring; // req.query.sort === "rating"// ⑤ Nested bracket notation — qs parses this into a real objectconst filter =req.query.filteras { min?: string };
// filter.min === "4" (still a string — qs doesn't coerce types)// ⑥ req.body — only exists because express.json() middleware ran// Without that middleware: req.body === undefinedconst body =req.body;
res.json({ productId, page, limit, sort, filter, body });
});
app.listen(3000);
①
express.json() wraps the raw Node stream. It buffers chunks, detects Content-Type: application/json, parses, and sets req.body.
②
path-to-regexp converts :productId into /^\/products\/(?:([^\/]+?))\//i. Match groups become req.params.
③
req.params.productId is always a string. Even though it looks like a number in the URL. You cast it.
④
req.query is Express's addition — not from Node. All values are strings. qs adds bracket/dot notation support over vanilla URLSearchParams.
⑤
qs parses filter[min]=4 into a nested object { filter: { min: "4" } }. Node's native URLSearchParams treats the entire key literally.
⑥
req.body only exists if middleware ran first. Without express.json(), req.body is undefined — a very common source of bugs.
Express's qs Parser vs URLSearchParams
Express's default query parser is qs, which adds features beyond the Web standard:
With qs, ?color=red gives you a string but ?color=red&color=blue gives you an string[]. The type of req.query.color changes based on how many values are sent. TypeScript types this as string | ParsedQs | string[] | ParsedQs[] — you must always guard.
How Express Middleware Builds the Pipeline
5Your Route Handlerreq.params · req.query · req.body — all populated
1Node IncomingMessagereq.url · req.method · req.headers · Readable stream body
15 Hono — Edge-first
TypeScript-native. Built on web standard APIs. Runs anywhere.
Hono is built on the Fetch API, not on Node's IncomingMessage/ServerResponse directly. Its request object wraps a standard Request, which means it runs identically on Node, Bun, Deno, Cloudflare Workers, and any edge runtime.
TypeScript — Hono v4
import { Hono } from'hono';
// ① Hono is generic over your env type — full inference chainconst app =newHono();
// ② Route registration — trie-based router, much faster than regex chains
app.get('/products/:productId/reviews', async (c) => {
// ③ Route param — c.req.param() returns string, alwaysconst productId =Number(c.req.param('productId')); // → 42// ④ c.req.query() — uses URLSearchParams internally (Web standard)// No qs, no bracket object nesting — spec-compliant behaviorconst page =Number(c.req.query('page')) ||1; // "2" → 2const limit =Number(c.req.query('limit')) ||20; // "10" → 10const sort = c.req.query('sort') ??'created_at'; // "rating"// ⑤ filter[min]=4 — literal key, not a nested object (URLSearchParams)const filterMin = c.req.query('filter[min]'); // → "4"// ⑥ All queries at once — returns Record<string, string>const allQuery = c.req.queries(); // { page:"2", limit:"10", ... }// ⑦ Body — built-in, no middleware needed. Awaitable. Type-safe with validator.const body =await c.req.json(); // reads + parses body streamconst text =await c.req.text(); // raw stringconst form =await c.req.formData(); // FormData// ⑧ Response — c.json() returns a Web standard Response objectreturn c.json({ productId, page, limit, sort, filterMin });
});
export default app; // Works as a Cloudflare Worker as-is
①
Hono's generic parameter enables full type inference for variables, bindings, and env — no manual type casting needed with validators.
②
Hono uses a RadixTree/Trie router internally. Faster than Express's sequential regex chain — O(log n) vs O(n) for route matching.
③
Same story as Express — always a string. You coerce manually. The difference is the ergonomics: c.req.param('id') vs req.params.id.
④
c.req.query() calls new URL(req.url).searchParams.get(key) internally. Web standard — no qs, no surprises.
⑤
Unlike Express with qs, Hono doesn't parse bracket notation into nested objects. Spec-compliant: the key is literal.
⑥
c.req.queries() returns all params as a flat Record<string, string>. For multi-value: c.req.queries('color') → string[].
⑦
Body parsing is built-in, async, and uses the Web Fetch API. No middleware registration needed. req.json() is idiomatic.
⑧
Returns a Web standard Response object. Handlers are async and return responses — no side-effect res.end().
z.coerce.number() solves the "query params are always strings" problem at the schema layer. By the time you reach your handler, page is already a number and TypeScript knows it. This is the pattern you'd recommend in a senior code review.
16 Feature Matrix
Side by side. No ambiguity.
Feature
Raw Node
Express
Hono
Query param access
url.searchParams.get('k')
req.query.k
c.req.query('k')
Multi-value param
searchParams.getAll('k')
req.query.k → string[]
c.req.queries('k')
Bracket notation filter[min]=4
Literal key "filter[min]"
Nested object{ filter: { min: "4" } }
Literal key "filter[min]"
Query value type
string | null
string | ParsedQs | string[] | ParsedQs[]
string | undefined
Auto type coercion
✗ Never
✗ Never
~ With zod-validator
Route params
Manual regex match[n]
req.params.name
c.req.param('name')
Body parsing
Manual stream buffering
Middleware (express.json())
Built-in (await c.req.json())
Body available without middleware
✗
✗ req.body is undefined
✓ Always
TypeScript types
@types/node
@types/express (loose)
First-class, inferred
Query parser spec compliance
✓ URLSearchParams
~ qs (custom extension)
✓ URLSearchParams
Edge runtime compatible
✗
✗
✓ Node, Bun, Deno, CF Workers
Handler return style
res.end() side-effect
res.json() side-effect
return c.json() — pure fn
Routing algorithm
None — you write it
path-to-regexp — O(n) chain
Radix trie — O(log n)
The One Pattern to Standardise On
Regardless of framework, this pattern eliminates the entire class of "query param is a string" bugs:
The framework doesn't solve the "strings problem" — Zod (or another schema library) does. Express's qs gives you nested objects at the cost of non-standard behavior. Hono's URLSearchParams gives you spec compliance but flat strings. The correct answer in any codebase is: validate and coerce at the boundary with a schema.