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.
On this page
- Words used in this lesson
- The smallest server
- Building the to-do API
- Trying every route with curl
- Why the event loop makes this fast (and fragile)
- Keep-alive: reuse the connection
- Timeouts: protect the server from slow clients
- Streaming responses
- Where this by-hand approach hurts
- Try it
- Interview questions
- Key takeaways
- 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:
200OK,201created,400bad request,404not found,500server 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
import http from "node:http";http.createServer((req, res) => { res.end("Hello\n"); }).listen(3108, () => console.log("up"));$ 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 OKis the status line. Node used the default status200.Datewas added by Node.Connection: keep-aliveandKeep-Alive: timeout=5mean "I will keep this connection open for 5 seconds of silence, so you can send another request". More on this later.Content-Length: 6was added by Node because the body was one small string ended withres.end(). Six bytes:Helloplus 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:
EADDRINUSE - listen EADDRINUSE: address already in use 0.0.0.0:3107And 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
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:
{ "method": "GET", "url": "/echo?name=ada&n=2", "path": "/echo", "query": { "name": "ada", "n": "2" }, "host": "localhost:3106", "userAgent": "node", "httpVersion": "1.1"}req.urlis the path plus the query string. It is not a full URL. To split it, build aURLobject with any base (the base is needed only to make it valid) and use.pathnameand.searchParams.- Query values are always strings:
nis"2", not2. Convert and validate it yourself. req.headershas all header names in lower case. I sentX-Request-IdandContent-Typewithfetchand the server saw these keys:
{ "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 path | What it does | Success status |
|---|---|---|
GET /health | tells a monitor the server is alive | 200 |
GET /todos | list all to-dos | 200 |
POST /todos | create one from a JSON body | 201 |
GET /todos/:id | get one | 200 |
DELETE /todos/:id | delete one | 204 |
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
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);}HttpErroris a small error type that carries a status. Anywhere in our code we canthrow new HttpError(404, "no such todo"), and one central place turns it into a reply. That avoids writingres.writeHead(...)in twenty places.sendJsonsets the status and headers together withwriteHead, then ends the response with the body. NoticeBuffer.byteLength(body):Content-Lengthcounts bytes, not characters (we saw in lesson 2 that"é".lengthis 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:
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:
- Wrong
content-typegives 415 Unsupported Media Type. - 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.
- Bad JSON gives 400 Bad Request.
- 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
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:
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
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 onecatch. 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 bland500 internal error. Never send stack traces or internal messages to the client. They help attackers. - The
finallywrites one access log line for every request with the method, URL, status and time. - If the
catchruns afterres.writeHeadhas already been called (for example an error in the middle of a streamed reply), callingsendJsonagain would throwERR_HTTP_HEADERS_SENT. Real servers checkres.headersSentfirst. 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:
$ 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:
$ 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:
$ 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:
$ 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:
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.5msEach 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
Allowheader listing the allowed methods. - It does not set security headers, rate limits or CORS rules.
GET /todos/:idreturns whatever is in the map; there is no authentication.- It never handles
SIGTERMfor a clean shutdown (lesson 6). - A 413 reply is sent while the rest of the big body may still be arriving. Node and
curlhandled 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:
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 msFifty 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:
$ 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 intactOne 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:
$ curl -si -H 'Connection: close' localhost:3103/healthHTTP/1.1 200 OK...Connection: closeMeasuring 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")):
keepAlive false: 200 requests used 200 connections, 312 mskeepAlive true : 200 requests used 1 connection(s), 114 msThe 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:
| Setting | What it limits | Default in v22 docs |
|---|---|---|
server.keepAliveTimeout | How long an idle keep-alive connection stays open after a response | 5000 ms |
server.keepAliveTimeoutBuffer | Extra time added to the socket timeout beyond keepAliveTimeout (added in v22.19.0) | 1000 ms |
server.headersTimeout | Time allowed to receive the complete request headers | the smaller of requestTimeout and 60000 ms |
server.requestTimeout | Time allowed to receive the entire request | 300000 ms (5 minutes) |
server.timeout | Plain socket inactivity timeout | 0 (none) |
connectionsCheckingInterval (option) | How often Node checks for expired header/request timeouts | 30000 ms |
server.maxRequestsPerSocket | Requests allowed on one keep-alive connection | 0 (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:
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"]]);[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 serverLet us read each case.
- 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 reducesECONNRESETerrors, 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 plainkeepAliveTimeout.) - Slow headers. The client sent half of the headers and stopped. At about 504 ms (the
headersTimeoutof 500) the server sent408 Request Timeoutand closed the connection. The request never reached our handler. - Slow body. The headers were complete, so we got
200 OKat 5 ms: my handler never reads the body, it just answers. The client promised 100 bytes and sent 3. At 1506 ms (therequestTimeoutof 1500) the server sent a408and closed. I did not test a handler that waits for the whole body (likereadJson) against this case. The docs say the server answers 408 and closes the connection in that situation, so I expect thefor awaitloop 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():
client: AbortErrorserver: client went away, aborted = true | res.writableEnded = falseserver.close callback after 2805 msReading 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:
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:
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())}`);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:
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
typeofcheck. 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
- Add
GET /todos?done=trueto the server: filter by the query parameter. Remember that query values are strings. - Add
PATCH /todos/:idthat accepts{ "done": true }and updates the item. Return 404 if missing, 422 ifdoneis not a boolean. - Make the 405 reply include an
Allowheader with the methods that are allowed for that path. - Add a request id: use the
x-request-idheader if given, otherwise make one withrandomUUID(). Put it in the access log and in every reply as a header. - Use
server.requestTimeout = 2000and test it with a rawnetclient that sends aContent-Length: 100body but never finishes. Does yourreadJsonfail? What status does the client get? - Write a tiny client with
fetchthat creates three to-dos in parallel withPromise.all, and then lists them. Print the status codes.
Answers
const done = query.get("done"); let list = [...todos.values()]; if (done !== null) list = list.filter(t => t.done === (done === "true"));. Ifdoneis neither"true"nor"false", return 400.- Use
readJson, checktypeof data.done === "boolean"(elsethrow new HttpError(422, "done must be true or false")), find the todo (else 404), thent.done = data.doneand return the todo with 200. - Keep the
routestable and, whenpathMatchedis true, collect the methods of all routes whose pattern matched. Reply withres.setHeader("allow", methods.join(", ")). InsendJsonthis is theextraHeadersargument. - 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. - I did not run this exact exercise. Based on the 408 behaviour shown above, expect the server to send
408after the request timeout (at the next connection check) and yourfor await (const chunk of req)loop to end with an error that goes to the centralcatch. Whether you can still write a reply depends on whether the connection is already closed, which is why real code checksres.headersSentandres.writableEnded. 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 printr.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).reqis a readable stream andresis 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.byteLengthforContent-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,keepAliveTimeoutplus 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
- HTTP Methods, Status Codes and Idempotency in Practice · API Design
- REST API Design Best Practices: Resources, Versioning, Status Codes · Backend in Depth
- CORS Explained: Same-Origin Policy, Preflights and Credentials · The Browser Contract
Part of the study guide
Related series