Node.js From Zero

An HTTP Server From Scratch

The node:http module, routing by hand, reading bodies, headers and status codes, then why a framework helps.

Lesson 0414 min read#nodejs#http#server

On this page

  1. Words used in this lesson
  2. The smallest server
  3. Building the to-do API
  4. Trying every route with curl
  5. Why the event loop makes this fast (and fragile)
  6. Keep-alive: reuse the connection
  7. Timeouts: protect the server from slow clients
  8. Streaming responses
  9. Where this by-hand approach hurts
  10. Try it
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: an HTTP server in Node is one function that gets a request object and a response object, plus a listening socket. Everything else a framework gives you (routing, JSON parsing, error handling) is code you can write yourself in about 100 lines. Writing it once, by hand, is the best way to understand what Express and Fastify are doing for you.

So far in this series we learned how Node runs code (lesson 0), how the event loop schedules it (lesson 1), how to use files and buffers (lesson 2) and streams (lesson 3). Now we put it together into a small JSON API for to-do items, using only the built-in node:http module. We will test every route with curl and paste the real output, then look at connections: keep-alive, and the timeouts that protect a server from slow or dead clients. The next lesson, Express and Fastify, replaces our hand-written parts with a framework, and you will see exactly which parts those are.

You do not need to know HTTP in depth. We use only: a method (GET, POST), a path (/todos), headers, an optional body, and a status code in the reply. For the design side of an API (naming, versioning, error formats) see REST API Design Best Practices; this lesson is about how a Node server works.

What I ran / checked. Everything ran on Linux with Node.js v22.22.0, curl for the requests, and Node's built-in fetch for client code. Output blocks are real pasted output, with dates and ids from my runs. Times in milliseconds vary by machine. Sources I read (in my own words): the Node.js http API docs (raw markdown from the nodejs/node repository, v22.x branch), especially server.keepAliveTimeout, server.keepAliveTimeoutBuffer (added in v22.19.0, default 1000 ms), server.headersTimeout, server.requestTimeout, server.timeout, server.maxRequestsPerSocket, server.closeIdleConnections() and the connectionsCheckingInterval option; the Node.js learn guide on the event loop; and goldbergyoni/nodebestpractices for general server habits. Could not read: I did not read the RFCs for HTTP/1.1 (RFC 9110 and 9112) in this session, so statements about the protocol come from the Node docs and from what I saw on the wire. Not covered or not run: HTTPS/TLS, HTTP/2, load testing tools (none were installed on my machine, so I make no requests-per-second claims), and Windows. I did not verify GitHub star counts.

Words used in this lesson

  • HTTP is the text protocol that browsers and servers use to talk. A client sends a request and the server sends a response.
  • Request has a method, a path, headers and sometimes a body.
  • Response has a status code, headers and sometimes a body.
  • Status code is a number that says what happened: 200 OK, 201 created, 400 bad request, 404 not found, 500 server error.
  • Header is a name and value line that describes the message, like content-type: application/json.
  • Body is the data part of the message, such as a JSON document.
  • Route is a pair of method and path pattern, like GET /todos/:id, that maps to a handler function.
  • Handler is the function that runs for a matching route.
  • Port is a number (0 to 65535) that tells the operating system which program on the machine should get the connection.
  • Keep-alive means reusing one TCP connection for many requests, instead of opening a new one each time.
  • Timeout is the maximum time you wait for something before giving up.
  • Slowloris is an attack where a client sends a request very slowly to keep connections open and use up the server.
  • JSON is a text format for data: objects, arrays, strings, numbers.
  • Middleware is a function that runs before your handler, for example to parse JSON. We build the idea by hand here and meet frameworks next lesson.

The smallest server

JavaScript
import http from "node:http";http.createServer((req, res) => { res.end("Hello\n"); }).listen(3108, () => console.log("up"));
TEXT
$ curl -si localhost:3108HTTP/1.1 200 OKDate: Sun, 11 Oct 2026 18:42:52 GMTConnection: keep-aliveKeep-Alive: timeout=5Content-Length: 6Hello

(-s means silent and -i means include the response headers.) Six lines of code gave us a working server. Let us read the reply, because it teaches a lot:

  • HTTP/1.1 200 OK is the status line. Node used the default status 200.
  • Date was added by Node.
  • Connection: keep-alive and Keep-Alive: timeout=5 mean "I will keep this connection open for 5 seconds of silence, so you can send another request". More on this later.
  • Content-Length: 6 was added by Node because the body was one small string ended with res.end(). Six bytes: Hello plus the newline.

The function we gave to createServer runs once per request. req is an IncomingMessage (a Readable stream, as we learned in lesson 3). res is a ServerResponse (a Writable stream). listen(3108) opens the port.

If the port is busy, Node tells you with an error event:

TEXT
EADDRINUSE - listen EADDRINUSE: address already in use 0.0.0.0:3107

And listen(0) asks the operating system for any free port (port 0 gave { address: '0.0.0.0', family: 'IPv4', port: 46285 }). That is handy in tests, where you read the real port from server.address().port.

What is in a request

JavaScript
const url = new URL(req.url, `http://${req.headers.host}`);res.setHeader("content-type", "application/json");res.end(JSON.stringify({  method: req.method, url: req.url, path: url.pathname,  query: Object.fromEntries(url.searchParams), host: req.headers.host,  userAgent: req.headers["user-agent"], httpVersion: req.httpVersion,}, null, 2));

I called it with the built-in fetch:

TEXT
{  "method": "GET",  "url": "/echo?name=ada&n=2",  "path": "/echo",  "query": {    "name": "ada",    "n": "2"  },  "host": "localhost:3106",  "userAgent": "node",  "httpVersion": "1.1"}
  • req.url is the path plus the query string. It is not a full URL. To split it, build a URL object with any base (the base is needed only to make it valid) and use .pathname and .searchParams.
  • Query values are always strings: n is "2", not 2. Convert and validate it yourself.
  • req.headers has all header names in lower case. I sent X-Request-Id and Content-Type with fetch and the server saw these keys:
TEXT
{ "host": "localhost:3110", "connection": "keep-alive", "x-request-id": "abc-1", "content-type": "application/json", "accept": "*/*", "accept-language": "*", "sec-fetch-mode": "cors", "user-agent": "node", "accept-encoding": "gzip, deflate"}

Always read headers by their lower-case name: req.headers["x-request-id"].

Building the to-do API

We will build five routes:

Method and pathWhat it doesSuccess status
GET /healthtells a monitor the server is alive200
GET /todoslist all to-dos200
POST /todoscreate one from a JSON body201
GET /todos/:idget one200
DELETE /todos/:iddelete one204

The data lives in a Map in memory. It resets when the server restarts. That is fine for learning.

Piece 1: helpers for JSON replies and errors

JavaScript
class HttpError extends Error {  constructor(status, message) { super(message); this.status = status; }}function sendJson(res, status, data, extraHeaders = {}) {  const body = JSON.stringify(data);  res.writeHead(status, {    "content-type": "application/json; charset=utf-8",    "content-length": Buffer.byteLength(body),    ...extraHeaders,  });  res.end(body);}
  • HttpError is a small error type that carries a status. Anywhere in our code we can throw new HttpError(404, "no such todo"), and one central place turns it into a reply. That avoids writing res.writeHead(...) in twenty places.
  • sendJson sets the status and headers together with writeHead, then ends the response with the body. Notice Buffer.byteLength(body): Content-Length counts bytes, not characters (we saw in lesson 2 that "é".length is 1 but takes 2 bytes). The Node docs say the same.

Piece 2: reading a JSON body safely

A request body is a stream (lesson 3). We read it chunk by chunk, count the bytes, and stop if it gets too big:

JavaScript
const MAX_BODY = 1024; // bytes; tiny so we can test itasync function readJson(req) {  const type = req.headers["content-type"] ?? "";  if (!type.startsWith("application/json")) throw new HttpError(415, "send content-type: application/json");  const chunks = [];  let size = 0;  for await (const chunk of req) {    size += chunk.length;    if (size > MAX_BODY) throw new HttpError(413, `body bigger than ${MAX_BODY} bytes`);    chunks.push(chunk);  }  try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); }  catch { throw new HttpError(400, "body is not valid JSON"); }}

Four checks are packed in here, each with its own status code:

  1. Wrong content-type gives 415 Unsupported Media Type.
  2. A body over the limit gives 413 Payload Too Large. We stop reading as soon as the count passes the limit, so an attacker cannot make us buffer 10 GB. Always put a limit on bodies. A server with no limit is one big upload from a crash.
  3. Bad JSON gives 400 Bad Request.
  4. Valid JSON that is the wrong shape is checked later in the route (422).

We collect the pieces in an array and join them with Buffer.concat once, then decode once, because (lesson 2) decoding chunk by chunk can break multi-byte characters.

Piece 3: a route table and a matcher

JavaScript
const routes = [  ["GET", "/health", async () => ({ status: 200, body: { ok: true } })],  ["GET", "/todos", async () => ({ status: 200, body: [...todos.values()] })],  ["POST", "/todos", async ({ req }) => {    const data = await readJson(req);    if (typeof data?.title !== "string" || data.title.trim() === "") throw new HttpError(422, "title must be a non-empty string");    const todo = { id: randomUUID().slice(0, 8), title: data.title.trim(), done: false };    todos.set(todo.id, todo);    return { status: 201, body: todo, headers: { location: `/todos/${todo.id}` } };  }],  ["GET", "/todos/:id", async ({ params }) => {    const t = todos.get(params.id);    if (!t) throw new HttpError(404, "no such todo");    return { status: 200, body: t };  }],  ["DELETE", "/todos/:id", async ({ params }) => {    if (!todos.delete(params.id)) throw new HttpError(404, "no such todo");    return { status: 204 };  }],];

Each route is [method, pattern, handler]. A handler returns a plain object describing the reply (status, body, headers) or throws an HttpError. The :id part marks a parameter. This design keeps handlers simple and testable: they do not touch res at all.

The matcher compares the path segment by segment:

JavaScript
function match(method, pathname) {  let pathMatched = false;  for (const [m, pattern, handler] of routes) {    const p = pattern.split("/"), a = pathname.split("/");    if (p.length !== a.length) continue;    const params = {};    const ok = p.every((seg, i) => seg.startsWith(":")      ? ((params[seg.slice(1)] = decodeURIComponent(a[i])), true)      : seg === a[i]);    if (!ok) continue;    pathMatched = true;    if (m === method) return { handler, params };  }  return { pathMatched };}

It also remembers whether the path matched some route with a different method. That lets us tell apart 404 (no such path) and 405 (the path exists, but not for this method). It is a small thing that makes an API friendlier.

decodeURIComponent throws a URIError on bad input like %E0%A4%A. In my version above that error would end up as a 500. A careful version catches it and returns 400. This is the kind of detail frameworks handle for you.

Piece 4: the server itself, with one central error handler

JavaScript
const server = http.createServer(async (req, res) => {  const started = performance.now();  try {    const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);    const found = match(req.method, url.pathname);    if (!found.handler) {      if (found.pathMatched) throw new HttpError(405, "method not allowed");      throw new HttpError(404, "not found");    }    const result = await found.handler({ req, params: found.params, query: url.searchParams });    if (result.status === 204) { res.writeHead(204); res.end(); }    else sendJson(res, result.status, result.body, result.headers);  } catch (err) {    if (err instanceof HttpError) sendJson(res, err.status, { error: err.message });    else { console.error("unexpected:", err); sendJson(res, 500, { error: "internal error" }); }  } finally {    console.log(`${req.method} ${req.url} -> ${res.statusCode} ${(performance.now() - started).toFixed(1)}ms`);  }});server.listen(3103, () => console.log("listening on http://localhost:3103"));

Notes on the design:

  • The handler is async, so every error, sync or async, lands in the one catch. This is the same idea as the central error handler in Error Handling: Operational vs Programmer Errors.
  • Errors we expect (HttpError) are shown to the client with their message. Errors we did not expect (a bug) are logged on the server, and the client gets a bland 500 internal error. Never send stack traces or internal messages to the client. They help attackers.
  • The finally writes one access log line for every request with the method, URL, status and time.
  • If the catch runs after res.writeHead has already been called (for example an error in the middle of a streamed reply), calling sendJson again would throw ERR_HTTP_HEADERS_SENT. Real servers check res.headersSent first. Our small server never writes headers before the handler finishes, so it is fine here.

Trying every route with curl

I started the server and used a small shell helper c that runs curl -s -i and shows the status line and headers. Dates and ids will differ for you.

Health check and create:

TEXT
$ curl localhost:3103/healthHTTP/1.1 200 OKcontent-type: application/json; charset=utf-8content-length: 11Date: Sun, 11 Oct 2026 18:42:12 GMTConnection: keep-aliveKeep-Alive: timeout=5{"ok":true}$ curl -X POST localhost:3103/todos -H 'content-type: application/json' -d '{"title":"learn node"}'HTTP/1.1 201 Createdcontent-type: application/json; charset=utf-8content-length: 51location: /todos/492ff77eDate: Sun, 11 Oct 2026 18:42:12 GMTConnection: keep-aliveKeep-Alive: timeout=5{"id":"492ff77e","title":"learn node","done":false}

201 Created with a location header that says where the new thing lives: this is the REST habit for creates.

List and read one:

TEXT
$ curl localhost:3103/todosHTTP/1.1 200 OKcontent-type: application/json; charset=utf-8content-length: 53...[{"id":"492ff77e","title":"learn node","done":false}]$ curl localhost:3103/todos/492ff77eHTTP/1.1 200 OK...{"id":"492ff77e","title":"learn node","done":false}

(I cut the repeated headers with ....)

The error cases. This is the part that decides whether an API is pleasant to use:

TEXT
$ curl -X POST localhost:3103/todos -H 'content-type: application/json' -d '{bad'HTTP/1.1 400 Bad Request...{"error":"body is not valid JSON"}$ curl -X POST localhost:3103/todos -H 'content-type: application/json' -d '{"title":""}'HTTP/1.1 422 Unprocessable Entity...{"error":"title must be a non-empty string"}$ curl -X POST localhost:3103/todos -d 'title=x'HTTP/1.1 415 Unsupported Media Type...{"error":"send content-type: application/json"}$ curl -X POST localhost:3103/todos -H 'content-type: application/json' -d "{\"title\":\"$(head -c 2000 /dev/zero | tr '\0' a)\"}"HTTP/1.1 413 Payload Too Large...{"error":"body bigger than 1024 bytes"}$ curl -X PUT localhost:3103/todosHTTP/1.1 405 Method Not Allowed...{"error":"method not allowed"}$ curl localhost:3103/nopeHTTP/1.1 404 Not Found...{"error":"not found"}

Delete, twice:

TEXT
$ curl -X DELETE localhost:3103/todos/492ff77eHTTP/1.1 204 No ContentDate: Sun, 11 Oct 2026 18:42:12 GMTConnection: keep-aliveKeep-Alive: timeout=5$ curl -X DELETE localhost:3103/todos/492ff77eHTTP/1.1 404 Not Found...{"error":"no such todo"}

A 204 has no body, so there is no content-type. The second delete finds nothing and gives 404. (Whether a repeated delete should be 404 or 204 is a design choice. REST guides argue both ways.)

And the access log the server printed:

TEXT
listening on http://localhost:3103GET /health -> 200 6.9msPOST /todos -> 201 2.8msGET /todos -> 200 1.4msGET /todos -> 200 1.3msGET /todos/492ff77e -> 200 4.2msPOST /todos -> 400 2.9msPOST /todos -> 422 0.8msPOST /todos -> 415 0.7msPOST /todos -> 413 2.5msPUT /todos -> 405 2.8msGET /nope -> 404 2.5msDELETE /todos/492ff77e -> 204 2.6msDELETE /todos/492ff77e -> 404 1.5ms

Each request took a few milliseconds. The first one (6.9 ms) is slower because Node was warming up code paths for the first time.

A few honest gaps in this small server

It is good to know what a real server would add. None of these are shown above because they are not in the code:

  • The 405 reply should include an Allow header listing the allowed methods.
  • It does not set security headers, rate limits or CORS rules.
  • GET /todos/:id returns whatever is in the map; there is no authentication.
  • It never handles SIGTERM for a clean shutdown (lesson 6).
  • A 413 reply is sent while the rest of the big body may still be arriving. Node and curl handled this fine in my test, but production servers decide whether to close the connection after a 413.
  • It does not use res.on("close") to notice that the client went away before we answered.

Why the event loop makes this fast (and fragile)

Our handler is async, and it awaits things. While one request waits for a database (or here, for a sleep), the thread serves other requests. I tested it with a handler that waits 100 ms, as a stand-in for a database call, and sent 1, 10 and 50 requests at the same moment with a keep-alive agent:

TEXT
 1 parallel requests, each waits 100 ms in the handler: total 124 ms10 parallel requests, each waits 100 ms in the handler: total 110 ms50 parallel requests, each waits 100 ms in the handler: total 132 ms

Fifty requests that each wait 100 ms took 132 ms in total, not 5 seconds. The waits overlap, because waiting is free in Node (lesson 1). Now remember the other result from lesson 1: if the handler does 2 seconds of synchronous work, a 5 ms request stalls for 1.8 seconds. In a server, "the event loop" and "all your users" are the same thing. The next two sections are about keeping the connection layer healthy.

Keep-alive: reuse the connection

HTTP runs on top of TCP. Opening a TCP connection takes a round trip (and a TLS handshake takes more). Keep-alive lets a client send many requests over one connection. In HTTP/1.1 it is the default. You saw Connection: keep-alive and Keep-Alive: timeout=5 in every reply above.

curl shows it when you give it two URLs in one command:

TEXT
$ curl -sv localhost:3103/health localhost:3103/todos 2>&1 | grep -E "Connected to|Re-using|^< HTTP|Connection #|Keep-Alive"* Connected to localhost (127.0.0.1) port 3103< HTTP/1.1 200 OK< Keep-Alive: timeout=5* Connection #0 to host localhost left intact* Re-using existing connection with host localhost< HTTP/1.1 200 OK< Keep-Alive: timeout=5* Connection #0 to host localhost left intact

One Connected to line, then Re-using existing connection for the second request. A client can opt out with Connection: close, and then the server closes after the reply:

TEXT
$ curl -si -H 'Connection: close' localhost:3103/healthHTTP/1.1 200 OK...Connection: close

Measuring what keep-alive saves

I made 200 sequential requests to a trivial server, once with an agent that has keep-alive off and once with it on, and counted how many TCP connections the server saw (server.on("connection")):

TEXT
keepAlive false: 200 requests used 200 connections, 312 mskeepAlive true : 200 requests used 1 connection(s), 114 ms

The saving is in the connection count (200 versus 1). On localhost there is no network latency, so the time difference (312 ms against 114 ms in this run) is small and only a hint. Over a real network, each avoided connection also avoids at least one round trip. For this reason clients that call your API often (another service, a server-side fetch) should use keep-alive. The fetch built into Node already does so.

Timeouts: protect the server from slow clients

A server holds a connection open while it waits. A client that is slow, or malicious, or dead, can hold many of them. Node has several timeouts. From the docs:

SettingWhat it limitsDefault in v22 docs
server.keepAliveTimeoutHow long an idle keep-alive connection stays open after a response5000 ms
server.keepAliveTimeoutBufferExtra time added to the socket timeout beyond keepAliveTimeout (added in v22.19.0)1000 ms
server.headersTimeoutTime allowed to receive the complete request headersthe smaller of requestTimeout and 60000 ms
server.requestTimeoutTime allowed to receive the entire request300000 ms (5 minutes)
server.timeoutPlain socket inactivity timeout0 (none)
connectionsCheckingInterval (option)How often Node checks for expired header/request timeouts30000 ms
server.maxRequestsPerSocketRequests allowed on one keep-alive connection0 (no limit)

The docs say headersTimeout and requestTimeout should be set to non-zero values to protect against slow-request attacks when the server is deployed without a reverse proxy in front.

I tested three situations against a server with short settings so I could watch them: keepAliveTimeout: 1000, headersTimeout: 500, requestTimeout: 1500, and connectionsCheckingInterval: 100 (the last one so that checks happen often; the default of 30 seconds would make my test slow). I used a raw TCP socket (node:net) as the "client", so I could send incomplete requests:

JavaScript
const server = http.createServer(  { keepAliveTimeout: 1000, headersTimeout: 500, requestTimeout: 1500, connectionsCheckingInterval: 100 },  (req, res) => res.end("hello\n"));// 1) idle keep-alive: one full request, then silenceawait raw("idle keep-alive", [[0, "GET / HTTP/1.1\r\nHost: x\r\n\r\n"]]);// 2) slow headers: send half a header and stop (a "slowloris" style client)await raw("slow headers ", [[0, "GET / HTTP/1.1\r\nHost: x\r\n"]]);// 3) headers fine, body promised but never sentawait raw("slow body    ", [[0, "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nabc"]]);
TEXT
[idle keep-alive] 11 ms server sent: "HTTP/1.1 200 OK"[idle keep-alive] 2011 ms connection closed by server[slow headers ] 504 ms server sent: "HTTP/1.1 408 Request Timeout"[slow headers ] 506 ms connection closed by server[slow body    ] 5 ms server sent: "HTTP/1.1 200 OK"[slow body    ] 1506 ms server sent: "HTTP/1.1 408 Request Timeout"[slow body    ] 1507 ms connection closed by server

Let us read each case.

  1. Idle keep-alive. The full request was answered at 11 ms. Then the connection sat idle and the server closed it at 2011 ms, not at 1000 ms. The reason is the buffer: the real socket timeout is keepAliveTimeout + keepAliveTimeoutBuffer = 1000 + 1000 ms. The docs say this extra second reduces ECONNRESET errors, where a client sends a request on a connection that the server is just closing. (This setting exists in the Node version I ran; older versions close at the plain keepAliveTimeout.)
  2. Slow headers. The client sent half of the headers and stopped. At about 504 ms (the headersTimeout of 500) the server sent 408 Request Timeout and closed the connection. The request never reached our handler.
  3. Slow body. The headers were complete, so we got 200 OK at 5 ms: my handler never reads the body, it just answers. The client promised 100 bytes and sent 3. At 1506 ms (the requestTimeout of 1500) the server sent a 408 and closed. I did not test a handler that waits for the whole body (like readJson) against this case. The docs say the server answers 408 and closes the connection in that situation, so I expect the for await loop over the body to end with an error, but check it in your own setup.

Do not leave these timeouts at zero or "infinite" on a public server. A client that opens thousands of connections and sends one byte per minute can keep them all open. The defaults in Node 22 are already non-zero (headers 60 s at most, whole request 5 minutes), but for an API that takes small JSON bodies, a request timeout of 5 minutes is generous. Lower it to match your real needs. Most production setups also put a reverse proxy (nginx, a cloud load balancer) in front, which adds its own timeouts. Make the timeouts of the proxy and of Node fit together: a common production bug is a proxy that keeps connections open longer than Node's keepAliveTimeout, so the proxy sends a request on a connection Node has just closed, and the user gets a 502.

Closing the server cleanly

server.close() stops accepting new connections and lets the current ones finish. I wrote a test with a handler that takes 2 seconds and a client that left after 300 ms, then called close():

TEXT
client: AbortErrorserver: client went away, aborted = true | res.writableEnded = falseserver.close callback after 2805 ms

Reading it: the client aborted (AbortError) and the server's req.on("close") fired with aborted = true, so a handler can notice that the client left (and stop expensive work). The close callback only came after 2805 ms, because the server waits for the slow handler to finish (it was still asleep), and for any idle keep-alive connections to be closed. The docs note that from Node 19 on, server.close() also closes idle connections by itself, so you no longer need to call closeIdleConnections() just for that. A graceful shutdown needs more than this (a timeout, health checks failing first, closing the database); lesson 6 puts it together.

Streaming responses

We already saw in lesson 3 that you can pipe a file to res. You can also write a response in parts. Here is a route that sends three lines, 200 ms apart:

JavaScript
if (url.pathname === "/stream") {  res.writeHead(200, { "content-type": "text/plain" });  for (let i = 1; i <= 3; i++) { res.write(`part ${i}\n`); await sleep(200); }  return res.end("done\n");}

And a client that reads the body as it arrives:

JavaScript
const r = await fetch("http://localhost:3106/stream");console.log("fetch status", r.status, "| content-type", r.headers.get("content-type"), "| transfer-encoding", r.headers.get("transfer-encoding"));const t0 = performance.now();for await (const chunk of r.body) console.log(`  ${(performance.now() - t0).toFixed(0)} ms got ${JSON.stringify(Buffer.from(chunk).toString())}`);
TEXT
fetch status 200 | content-type text/plain | transfer-encoding chunked  0 ms got "part 1\n"  200 ms got "part 2\n"  402 ms got "part 3\n"  602 ms got "done\n"

The client printed each part when it arrived. Because we did not know the total size, Node used Transfer-Encoding: chunked. This is how streaming APIs, log tails and similar features work. (Here r.body from fetch is a web stream, and for await works on it, as noted in lesson 3.)

A redirect is just a status code and a header:

TEXT
redirect: 302 /echo?from=redirect

(That line is the status and the location header my route returned, read by fetch with redirect: "manual".)

Where this by-hand approach hurts

We wrote about 100 lines, and the result is decent. But look at what we did by hand, and what is missing:

  • A router: our matcher is linear and cannot do wildcards, nested routers or regular expressions.
  • Body parsing for JSON only. No forms, no file uploads.
  • Validation of the body shape: one typeof check. Real APIs need schemas.
  • Cross-cutting features such as logging with request ids, CORS, authentication, rate limiting and compression, which should run for many routes.
  • Careful error cases: bad URL encoding, headers already sent, a client that left.
  • Static files, caching headers, ETags.

A framework is a tested version of these pieces plus a way to share code between routes (middleware). In the next lesson we rebuild this exact API in Express 5 and in Fastify, and compare. The by-hand version is not "wrong". For a tiny internal service, or to see what is going on, it is perfectly fine. But for a normal product API, the framework saves time and prevents bugs.

Try it

  1. Add GET /todos?done=true to the server: filter by the query parameter. Remember that query values are strings.
  2. Add PATCH /todos/:id that accepts { "done": true } and updates the item. Return 404 if missing, 422 if done is not a boolean.
  3. Make the 405 reply include an Allow header with the methods that are allowed for that path.
  4. Add a request id: use the x-request-id header if given, otherwise make one with randomUUID(). Put it in the access log and in every reply as a header.
  5. Use server.requestTimeout = 2000 and test it with a raw net client that sends a Content-Length: 100 body but never finishes. Does your readJson fail? What status does the client get?
  6. Write a tiny client with fetch that creates three to-dos in parallel with Promise.all, and then lists them. Print the status codes.

Answers

  1. const done = query.get("done"); let list = [...todos.values()]; if (done !== null) list = list.filter(t => t.done === (done === "true"));. If done is neither "true" nor "false", return 400.
  2. Use readJson, check typeof data.done === "boolean" (else throw new HttpError(422, "done must be true or false")), find the todo (else 404), then t.done = data.done and return the todo with 200.
  3. Keep the routes table and, when pathMatched is true, collect the methods of all routes whose pattern matched. Reply with res.setHeader("allow", methods.join(", ")). In sendJson this is the extraHeaders argument.
  4. At the top of the handler: const id = req.headers["x-request-id"] ?? randomUUID(); res.setHeader("x-request-id", id); and add ${id} to the log line. Note that you should not trust an incoming id blindly in logs without limiting its length; an attacker can put anything in a header.
  5. I did not run this exact exercise. Based on the 408 behaviour shown above, expect the server to send 408 after the request timeout (at the next connection check) and your for await (const chunk of req) loop to end with an error that goes to the central catch. Whether you can still write a reply depends on whether the connection is already closed, which is why real code checks res.headersSent and res.writableEnded.
  6. const r = await Promise.all(["a","b","c"].map(t => fetch(url, { method: "POST", headers: {"content-type": "application/json"}, body: JSON.stringify({ title: t }) }))) then print r.map(x => x.status). You should see [201, 201, 201].

Interview questions

Walk me through what happens when a request arrives at a Node http server. (Must know)

The operating system accepts the TCP connection and libuv tells Node. Node's HTTP parser reads the request line and headers from the socket. Once the headers are complete it creates a req (an IncomingMessage, a readable stream for the body) and a res (a ServerResponse, a writable stream) and calls my request handler. My code reads the body if it needs one, does its work (awaiting a database, say, which frees the thread for other requests), then writes the status, headers and body to res and calls end(). Node then keeps the connection open for the next request or closes it, depending on keep-alive.

How do you read a JSON body with only node:http? What can go wrong? (Must know)

The body is a stream, so I read the chunks with for await (const chunk of req), collect them, join them with Buffer.concat, decode with UTF-8 once, then JSON.parse. Things that go wrong: no size limit (a huge upload uses all the memory, so I count bytes and stop with 413), wrong content type (415), invalid JSON (400 and not a crash), a valid JSON that is the wrong shape (422), and decoding each chunk separately, which can break multi-byte characters.

What is the difference between 400, 404, 405, 413, 415 and 422? (Must know)

400: the request is malformed, for example invalid JSON. 404: no such resource or path. 405: the path exists but not for this method (and I should send an Allow header). 413: the body is bigger than I accept. 415: I do not support the content type sent. 422: the syntax is fine but the content fails validation, such as an empty title. Some teams use 400 for all validation errors, which is fine if the API documents it. The important part is using the codes consistently and returning a body that says what is wrong.

What is keep-alive and why does it matter? (Must know)

Keep-alive reuses a TCP connection for many HTTP requests, so each request does not pay for a new connection (and for TLS, a new handshake). In my test, 200 requests used 200 connections without it and 1 connection with it. The server closes an idle connection after keepAliveTimeout (5 seconds by default). I have to make sure that the timeouts of any proxy in front of Node are consistent with that value, or users see random 502 or ECONNRESET errors when a connection closes just as a request is sent on it.

What is a slowloris attack and how does Node defend against it? (Senior signal)

The attacker opens many connections and sends the request very slowly, a few bytes at a time, so the server keeps each one open and runs out of capacity. Node limits this with headersTimeout (time to receive all headers) and requestTimeout (time to receive the whole request). In my test, a client that sent half a header was cut off with 408 Request Timeout at 504 ms when the limit was 500 ms, and one that promised a body and did not send it got a 408 at 1506 ms for a limit of 1500 ms. I keep these non-zero, tune them down for APIs that take small requests, and usually also have a reverse proxy in front.

Why did my server close an idle connection after 2 seconds when keepAliveTimeout was 1 second? (Trap)

In recent Node 22 versions the socket timeout is keepAliveTimeout plus keepAliveTimeoutBuffer, and the buffer defaults to 1000 ms (it was added in v22.19.0). My test closed at 2011 ms with a keep-alive timeout of 1000. The buffer reduces ECONNRESET errors. So if I need an exact value, I have to set both.

How would you structure routing without a framework? (Senior signal)

A table of [method, pattern, handler], a matcher that compares path segments and extracts parameters, and handlers that return a reply object or throw an HttpError. One central function turns that result or error into a response, so no handler touches res directly. The matcher remembers if the path matched but the method did not, to give 405 instead of 404. I would keep this for small services. Past a handful of routes I would use a framework, because it adds tested routing, body parsing, validation hooks and shared middleware.

A request handler throws after the response has already started. What happens? (Trap)

If headers were already sent, calling writeHead or setHeader again throws ERR_HTTP_HEADERS_SENT, and I can no longer change the status. The best I can do is log the error and destroy the response so the client sees a broken connection (and not a truncated reply that looks complete). So before sending an error reply I check res.headersSent. This is a good reason to prepare all data first, and only then start writing.

Why must you set Content-Length with Buffer.byteLength, not string.length? (Trap)

Content-Length is a count of bytes. string.length counts UTF-16 units, so any non-ASCII text gives a wrong value: "é" has length 1 but is 2 bytes in UTF-8. If the header is too small, the client cuts the body off; if it is too large, the client waits for data that never comes. The Node docs say the same. If I let res.end(string) set it, Node computes it correctly for me.

A fast route becomes slow whenever one specific route is called. What do you check? (Senior signal)

I check whether that route does synchronous work (a big loop, a sync file read, a heavy JSON.parse, a slow regular expression, sync crypto). All routes share one thread, so a long synchronous task makes every other route wait, as in my test where a 5 ms route took 1.8 seconds while a 2-second synchronous route was running. I confirm it by measuring event loop delay, then fix it by making it async, chunking it, or moving it to a worker thread.

How can a handler know that the client has disconnected, and why care? (Senior signal)

I listen for close on req (or on res) and check whether the response finished. In my test, req.on("close") fired with req.aborted === true after the client aborted, while res.writableEnded was still false. If the handler is doing expensive work (a big query, generating a report), I can use that signal to stop. It matters most for streaming responses and long operations. Also server.close() waits for requests in flight, so slow handlers delay shutdown.

Should an http server in production face the internet directly? (Senior signal)

Usually not. A reverse proxy or load balancer in front handles TLS, slow clients, buffering, compression, request size limits and routing to several Node processes, so Node can focus on the application. If Node does face clients directly, I set headersTimeout, requestTimeout, body limits and maxHeadersCount deliberately, and make sure that timeouts across all layers are consistent.

Key takeaways

  • An HTTP server is one function (req, res). req is a readable stream and res is a writable stream. Read headers in lower case, and treat query values as strings.
  • Wrap the handler in one try/catch. Known errors carry a status (HttpError); unknown ones are logged and returned as a plain 500.
  • Always limit body size (413), check the content type (415), and handle bad JSON (400). Use Buffer.byteLength for Content-Length.
  • 404 means no path, 405 means wrong method on a real path. Keep status codes consistent.
  • Async handlers overlap their waiting: 50 requests with a 100 ms wait finished in 132 ms. One synchronous route freezes all.
  • Keep-alive reuses connections: 1 connection versus 200 in my test. Timeouts (headersTimeout, requestTimeout, keepAliveTimeout plus its buffer) guard against slow or idle clients; I saw 408 replies at the configured times.
  • Writing the server by hand shows what a framework does: routing, parsing, validation, middleware and safe error handling.

What's next

Next: Express and Fastify, Lesson 5. We rebuild this to-do API in both frameworks, explain the middleware idea, show how each handles errors and validation, and compare the amount of code. Practise now: finish the Try it tasks, especially the PATCH route and the request id, because you will see how much easier they become with middleware.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series