Mert Özen Further With Every Line
Backend Node.js Fundamentals — #10

A Bare HTTP Server with node:http: Request/Response, Hand-Rolled Routing and Why You Need a Framework

Mert Özen Nov 14, 2026 15 min 11 views
A Bare HTTP Server with node:http: Request/Response, Hand-Rolled Routing and Why You Need a Framework

node:http is the lowest layer of HTTP in Node: reading the body with a size limit, routing, validation and error handling are your job, and a single error in an async handler can take the process down. With real output: the crashing server, a JSON body read as a stream with 413/415/422 responses, a hand-rolled router, a worker stopped when the client leaves, listen() binding to ::, headersTimeout answering slowloris with 408, and a connection left open after a 413.

Version note: The examples in this post were run with Node.js 26.13.1 (LTS) and TypeScript 7.0.2.

Guess First

So far we have written the API of Bookstore, the online bookstore used as the shared example across this site's series, on bare node:http. This handler has a bug: it tries to read the title of a book that was not found.

import { createServer } from "node:http";

// One async handler, one bug: what does the client get?
const server = createServer(async (req, res) => {
    if (req.url === "/api/books/9780000000000") {
        const book = undefined as { title: string } | undefined;
        res.end(book!.title);
        return;
    }
    res.end("ok");
});

server.listen(3100, "127.0.0.1", () => console.log("listening on 3100"));

In ASP.NET Core this would be a NullReferenceException; the client would get a 500 and the server would keep running. What does the client see in Node? What happens to the request that comes after the error?

curl -sS http://127.0.0.1:3100/health
curl -sS http://127.0.0.1:3100/api/books/9780000000000
curl -sS http://127.0.0.1:3100/health
ok
curl: (52) Empty reply from server
curl: (7) Failed to connect to 127.0.0.1 port 3100 after 0 ms: Couldn't connect to server
file:///home/user/bookstore-api/labs/10/guess.ts:7
        res.end(book .title);
                      ^

TypeError: Cannot read properties of undefined (reading 'title')
    at Server.<anonymous> (file:///home/user/bookstore-api/labs/10/guess.ts:7:23)
    at Server.emit (node:events:514:20)
    at parserOnIncoming (node:_http_server:1464:12)
    at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)

Node.js v26.13.1
server exit=1

The client got no response, the server exited with code 1 and the next request could not connect. The handler is async; createServer ignores the promise it returns, so the rejection stays unhandled and takes the process down (07). The error line reads book .title, a trace of the type stripping from 05: the ! was replaced by a space, which is why column numbers do not shift.

node:http is not a framework; it is the lowest layer of HTTP in Node. In this post we get to know that layer closely: the request and response objects, reading the body with a size limit, a hand-rolled routing table, central error handling, keep-alive and timeout settings. We add POST /api/orders to Bookstore without a framework, and at the end we list our reasons for moving to Express.

IncomingMessage and ServerResponse

The callback you pass to createServer receives two objects per request. req is an IncomingMessage: a Readable stream (09) with method, url and headers on it. url is only the path and query string (/api/books?page=2); we parse it with new URL(req.url, "http://localhost"). Header names arrive in lower case: req.headers["content-type"]. When a header appears more than once, most are joined with commas and some, like set-cookie, become an array; the raw form is in rawHeaders.

res is a ServerResponse: a Writable stream. You set the status and headers in one go with writeHead(status, headers), or piece by piece with statusCode and setHeader; the headers are sent on the first write or end call and res.headersSent becomes true. After that the status cannot change. Node 26 removed the old writeHeader alias completely; only writeHead remains. In Bookstore, JSON responses go through one helper:

import type { ServerResponse } from "node:http";

export function sendJson(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
    const json = JSON.stringify(body);
    res.writeHead(status, {
        "content-type": "application/json; charset=utf-8",
        "content-length": Buffer.byteLength(json),
        ...headers,
    });
    res.end(json);
}

We compute content-length with Buffer.byteLength, not from the string's length; as we saw in 08, a string with Turkish characters has more bytes than characters. A wrong content-length makes the client cut the response short.

Reading the Body as a Stream, with a Size Limit

ASP.NET Core reads the body for you and binds it to a [FromBody] parameter, and Kestrel has a default MaxRequestBodySize of about 30 MB. In node:http the body is a stream with no limit at all; if you don't read it, it is never read, and if you don't set a limit, the client sends as much as it likes:

import type { IncomingMessage } from "node:http";
import { HttpError } from "#http/errors";

// Reads a JSON body as a stream and stops as soon as it grows past the limit
export async function readJsonBody(req: IncomingMessage, limitBytes = 64 * 1024): Promise<unknown> {
    const contentType = req.headers["content-type"] ?? "";
    if (!/^application\/json\b/i.test(contentType)) {
        throw new HttpError(415, "content-type must be application/json");
    }
    const declared = Number(req.headers["content-length"] ?? 0);
    if (declared > limitBytes) {
        throw new HttpError(413, `body larger than ${limitBytes} bytes`);
    }

    const chunks: Buffer[] = [];
    let received = 0;
    for await (const chunk of req as AsyncIterable<Buffer>) {
        received += chunk.length;
        if (received > limitBytes) {
            throw new HttpError(413, `body larger than ${limitBytes} bytes`);
        }
        chunks.push(chunk);
    }
    try {
        return JSON.parse(Buffer.concat(chunks).toString("utf8"));
    } catch {
        throw new HttpError(400, "body is not valid JSON");
    }
}

There are two checks because content-length may not be sent: for a body sent with Transfer-Encoding: chunked, you only learn the size while reading. That is why the real limit is the counter inside the loop. We join the chunks with Buffer.concat and only then decode; the problem from 08 of Turkish characters split at a chunk boundary applies here too. HttpError is a small Error subclass that knows its status; we will see shortly how it becomes an HTTP response.

A Hand-Rolled Routing Table

Since 06, server.ts has had an if chain. Once path parameters (/api/books/:isbn) arrive, that chain falls apart. Let's write a small router: each route is a method, a path pattern and a handler.

// "/api/books/:isbn" -> /^\/api\/books\/([^/]+)$/ with keys ["isbn"]
function compile(path: string): { pattern: RegExp; keys: string[] } {
    const keys: string[] = [];
    const source = path
        .split("/")
        .map((segment) => {
            if (segment.startsWith(":")) {
                keys.push(segment.slice(1));
                return "([^/]+)";
            }
            return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
        })
        .join("/");
    return { pattern: new RegExp(`^${source}$`), keys };
}

function decodeSegment(segment: string): string {
    try {
        return decodeURIComponent(segment);
    } catch {
        // "%E0%A4" and similar broken escapes are the client's fault, not a 500
        throw new HttpError(400, `malformed path segment: ${segment}`);
    }
}

export class Router {
    #routes: Route[] = [];

    add(method: string, path: string, handler: Handler): this {
        this.#routes.push({ method, ...compile(path), handler });
        return this;
    }

    // Returns the handler, the allowed methods for a 405 when only the method is wrong, or undefined for 404
    match(method: string, pathname: string): { handler: Handler; params: Params } | { allowed: string[] } | undefined {
        const allowed: string[] = [];
        for (const route of this.#routes) {
            const m = route.pattern.exec(pathname);
            if (!m) {
                continue;
            }
            if (route.method !== method) {
                allowed.push(route.method);
                continue;
            }
            const params = Object.fromEntries(route.keys.map((key, i) => [key, decodeSegment(m[i + 1])]));
            return { handler: route.handler, params };
        }
        return allowed.length > 0 ? { allowed } : undefined;
    }
}

Routes are tried in order and the first match wins. That is why /api/orders/export is registered before /api/orders/:id; in the reverse order, an export request would turn into a lookup for an order whose id is "export". ASP.NET Core's routing looks at precedence rather than order: a literal segment always matches before a parameter segment. URLPattern, global since Node 24, does this job with a standard API, but the Node 26 documentation still marks it experimental (stability 1); since Express replaces this router in 11, I stayed with our own small version.

Bookstore: POST /api/orders and Central Error Handling

The route table now looks like this:

const router = new Router()
    .add("GET", "/health", (req, res) => handleHealth(req, res))
    .add("GET", "/api/books", (req, res) => handleListBooks(req, res))
    .add("GET", "/api/books/:isbn", handleGetBook)
    .add("POST", "/api/orders", (req, res) => handlePlaceOrder(req, res))
    .add("GET", "/api/orders/export", (req, res, { url }) => handleOrderExport(req, res, url))
    .add("GET", "/api/orders/:id", handleGetOrder)
    .add("GET", "/api/reports/prices", (req, res, { url }) => handlePriceReport(req, res, url))
    .add("GET", "/diagnostics/event-loop", (req, res) => handleEventLoopDelay(req, res));

POST /api/orders calls OrderService.placeOrder from 07. But first we need to check the shape of the body, and with node:http we write that by hand too:

// Hand-written validation: exactly the part a schema library will take over
function parsePlaceOrder(body: unknown): { customerId: string; lines: OrderLine[] } {
    const errors: string[] = [];
    const input = (typeof body === "object" && body !== null ? body : {}) as Record<string, unknown>;
    if (typeof input.customerId !== "string" || input.customerId.trim() === "") {
        errors.push("customerId must be a non-empty string");
    }
    if (!Array.isArray(input.lines) || input.lines.length === 0 || input.lines.length > 50) {
        errors.push("lines must be an array with 1 to 50 items");
    } else {
        input.lines.forEach((line: { isbn?: unknown; quantity?: unknown }, i: number) => {
            if (typeof line?.isbn !== "string") {
                errors.push(`lines[${i}].isbn must be a string`);
            }
            if (!Number.isInteger(line?.quantity) || (line.quantity as number) < 1 || (line.quantity as number) > 20) {
                errors.push(`lines[${i}].quantity must be an integer between 1 and 20`);
            }
        });
    }
    if (errors.length > 0) {
        throw new HttpError(400, errors.join("; "));
    }
    return { customerId: input.customerId as string, lines: input.lines as OrderLine[] };
}

// POST /api/orders
export async function handlePlaceOrder(req: IncomingMessage, res: ServerResponse): Promise<void> {
    const { customerId, lines } = parsePlaceOrder(await readJsonBody(req));
    try {
        const order = orderService.placeOrder(customerId, lines);
        sendJson(res, 201, order, { location: `/api/orders/${order.id}` });
    } catch (err) {
        if (err instanceof UnknownBookError) {
            throw new HttpError(422, err.message);
        }
        throw err;
    }
}

The part that prevents the crash from Guess First lives in server.ts: we await the handler returned by the router and turn every error into an HTTP response in a single catch. This block is a hand-written version of ASP.NET Core's exception handling middleware:

try {
    const match = router.match(req.method ?? "GET", url.pathname);
    if (!match) {
        throw new HttpError(404, `no route for ${url.pathname}`);
    }
    if ("allowed" in match) {
        res.setHeader("allow", match.allowed.join(", "));
        throw new HttpError(405, `${req.method} not allowed on ${url.pathname}`);
    }
    await match.handler(req, res, { url, params: match.params });
} catch (err) {
    if (aborted.signal.aborted) {
        log("warn", "client went away", { method: req.method, path: url.pathname, durationMs: Math.round(performance.now() - started) });
    } else if (res.headersSent) {
        log("error", "error after the response started", { error: (err as Error).message });
        res.destroy();
    } else if (err instanceof HttpError) {
        // After 413 the rest of the body is still on its way: close instead of reading it all
        sendJson(res, err.status, { error: err.message }, err.status === 413 ? { connection: "close" } : {});
    } else {
        log("error", "unhandled error", { error: (err as Error).stack });
        sendJson(res, 500, { error: "internal server error" });
    }
}

There are four cases. If the client has already left, there is no point in writing a response. If the headers have been sent (in the middle of the export, say), the status cannot change; we cut the connection so the client can tell the response is incomplete. An HttpError goes out with its own status, and everything else has its stack trace logged and goes to the client as a 500 without details. Let's try it:

curl -s -i -X POST http://127.0.0.1:3000/api/orders -H "content-type: application/json" \
  -d '{"customerId":"ada","lines":[{"isbn":"9780441172719","quantity":2},{"isbn":"9780141439587","quantity":1}]}'
HTTP/1.1 201 Created
x-request-id: 41a10140-a855-481a-a5aa-3783d15d38f9
content-type: application/json; charset=utf-8
content-length: 217
location: /api/orders/ORD-1001
Date: Sat, 14 Nov 2026 18:57:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":"ORD-1001","customerId":"ada","status":"Pending","items":[{"isbn":"9780441172719","quantity":2,"unitPrice":349.9},{"isbn":"9780141439587","quantity":1,"unitPrice":245}],"createdAtUtc":"2026-11-14T18:57:09.708Z"}

I also sent the bad requests one by one (the number at the end of each line is the status code):

{"error":"customerId must be a non-empty string; lines[0].isbn must be a string; lines[0].quantity must be an integer between 1 and 20"} 400
{"error":"unknown ISBN 9780000000000"} 422
{"error":"body is not valid JSON"} 400
{"error":"content-type must be application/json"} 415
{"error":"body larger than 65536 bytes"} 413
{"error":"body larger than 65536 bytes"} 413
{"error":"malformed path segment: %E0%A4"} 400
{"error":"no route for /nope"} 404

In order: a body with the wrong shape, an ISBN that is not in the catalog, half a JSON document, a form post, a 70 KB body (once with content-length and once with Transfer-Encoding: chunked), a broken path escape and a route that does not exist. DELETE /api/orders/ORD-1001 got 405 Method Not Allowed with an allow: GET header. For now the error format is a single error field; we move to the same contract as ASP.NET Core's ProblemDetails in 12.

When the client leaves: the request signal

The piece we promised in 07: the counterpart of ASP.NET Core's HttpContext.RequestAborted. If the close event arrives before the response has finished, the client closed the connection; we connect that to an AbortController and put the signal into the request context:

const aborted = new AbortController();
res.on("close", () => {
    if (!res.writableFinished) {
        aborted.abort(new Error("client closed the connection"));
    }
});

The worker mode of the price report uses this signal: when the signal passed to events.once aborts, the wait rejects and finally stops the worker with terminate().

// Stops the thread if the caller gave up; harmless after a normal finish
export async function buildPriceReportInWorker(rows: number, signal?: AbortSignal): Promise<PriceReport> {
    const worker = new Worker(workerUrl, { workerData: { rows } });
    try {
        const [report] = (await once(worker, "message", { signal })) as [PriceReport];
        return report;
    } finally {
        await worker.terminate();
    }
}
curl -s -m 0.3 "http://127.0.0.1:3000/api/reports/prices?mode=worker"; echo "curl exit=$?"
curl exit=28
{"time":"2026-11-14T18:57:15.758Z","level":"warn","requestId":"79fa5293-9304-4030-a9a1-5be51f1046df","msg":"client went away","method":"GET","path":"/api/reports/prices","durationMs":309}

curl gave up after 300 ms (exit code 28 means a timeout); the server noticed at 309 ms and did not compute the rest of the report.

listen(), Keep-Alive and Timeouts

Let's look at the defaults:

import { createServer, maxHeaderSize } from "node:http";

const server = createServer();
console.log({
    headersTimeout: server.headersTimeout,
    requestTimeout: server.requestTimeout,
    keepAliveTimeout: server.keepAliveTimeout,
    timeout: server.timeout,
    maxRequestsPerSocket: server.maxRequestsPerSocket,
    maxHeaderSize,
});
server.listen(0, () => {
    console.log("listen() without a host:", server.address());
    server.close();
});
{
  headersTimeout: 60000,
  requestTimeout: 300000,
  keepAliveTimeout: 5000,
  timeout: 0,
  maxRequestsPerSocket: 0,
  maxHeaderSize: 16384
}
listen() without a host: { address: '::', family: 'IPv6', port: 55179 }

The last line first. If you call listen without a host, Node listens on ::, meaning every IPv6 address and, on dual-stack systems, every IPv4 address too. On your development machine your API is then open to everyone on the same network. Kestrel, by default, listens on localhost only. Since 07 Bookstore's HOST defaults to 127.0.0.1; in a container we will set HOST=0.0.0.0, because the loopback inside a container cannot be reached from outside. One more note: on some systems the name localhost resolves to the IPv6 ::1 first. If another process listens on ::1 on the same port, curl localhost:3000 reaches that process, not your server; when in doubt, write the address explicitly.

The timeouts cover different stages of a request:

  • headersTimeout (60 s): the time allowed for all headers to arrive after the connection opens.
  • requestTimeout (300 s): the time allowed for receiving the whole request, body included. It does not cover the response; that is why the long export in 09 is not affected.
  • keepAliveTimeout (5 s): how long an idle connection is kept open after a response; it shows up in the response as Keep-Alive: timeout=5.
  • timeout (0): how long a socket may stay completely inactive before it is closed; off by default.

The purpose of headersTimeout and requestTimeout is the slowloris attack: a client opens a connection, sends the headers one byte at a time and never finishes. Once enough connections are open, the server cannot accept new requests. Let's shorten the timeout and try it:

import { createServer } from "node:http";
import { connect } from "node:net";

// headersTimeout lowered to 2 s so the demo is short; the check runs every 500 ms
const server = createServer({ headersTimeout: 2_000, connectionsCheckingInterval: 500 }, (req, res) => res.end("ok"));
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as { port: number };

// A slow client: sends the request line, then one header byte every 300 ms and never finishes
const started = performance.now();
const socket = connect(port, "127.0.0.1", () => socket.write("GET /api/books HTTP/1.1\r\nHost: localhost\r\n"));
const drip = setInterval(() => socket.write("X"), 300);
socket.on("data", (data) => console.log(`after ${Math.round(performance.now() - started)} ms the server sent: ${data.toString().split("\r\n")[0]}`));
socket.on("close", () => {
    console.log(`connection closed after ${Math.round(performance.now() - started)} ms`);
    clearInterval(drip);
    server.close();
});
after 2008 ms the server sent: HTTP/1.1 408 Request Timeout
connection closed after 2009 ms

Even though the client kept sending bytes, it could not finish the headers within 2 seconds, so it got 408 Request Timeout and the connection was closed. Because the check runs every connectionsCheckingInterval, the timing can drift by a few ms. In Bookstore we pass these values to createServer explicitly; 10 seconds for headers and 30 seconds for the whole request are enough for a JSON API:

const server = createServer(
    {
        // Stated explicitly even where they equal the defaults, so they show up in code review
        headersTimeout: 10_000,
        requestTimeout: 30_000,
        keepAliveTimeout: 5_000,
        maxHeaderSize: 16 * 1024,
    },
    (req, res) => {
        // ...
    },
);

Keep-alive has a well-known production trap: with a load balancer in front, if Node's keepAliveTimeout is shorter than the load balancer's idle timeout, the load balancer can send a new request on a connection just as Node closes it, and the client sees a 502. Keep Node's timeout longer than the load balancer's. On the shutdown side, server.close() stops accepting new connections and finishes once the open requests complete; it also closes idle keep-alive connections. We build graceful shutdown on top of this in 14.

On the client side http.request is still there, but the global fetch we used in 07 and 09 (undici 8 in Node 26) is the right choice for new code: it reuses connections by default, accepts an AbortSignal and gives you the body as a stream.

Why Do We Need a Framework?

What we wrote by hand in this post, and its counterparts:

Written by handASP.NET CoreExpress 5
Route table, path parameters, 405Endpoint routingapp.get, Router, req.params
readJsonBody and the size limitModel binding, MaxRequestBodySizeexpress.json({ limit })
parsePlaceOrderData annotations, FluentValidationNone; a library such as zod (12)
Central try/catchException handling middlewareError middleware; rejected promises handled automatically
Request id and loggingMiddleware, ILoggerMiddleware
Request signalRequestAbortedNone; the same approach

Some things are still missing: typed query string reading, content negotiation, serving static files, CORS, cookies, attaching middleware to a route or a group of routes. Each can be written, but each is code you have to maintain and that can introduce a security hole. That is reason enough to leave this layer to a proven framework.

In Production

Put a limit on every body. The node:http default is unlimited; for JSON, 64 KB to 1 MB is enough for most APIs, and exceptions such as file uploads can be opened per route.

Write timeouts explicitly in code and keep them consistent with the layers in front of you: the load balancer's idle timeout, the reverse proxy's timeouts and Node's keepAliveTimeout have to be considered together.

No handler error should take the process down. Without a central catch, every bug in an async handler is an outage; we saw that in Guess First.

Know which address you listen on in production. 0.0.0.0 in a container, 127.0.0.1 in development; calling listen without a host opens every interface everywhere.

Common Pitfalls

Passing an async callback to createServer without catching its errors. The rejection stays unhandled and the process exits.

Computing content-length with string.length. With a response containing Turkish characters, the client cuts the body short.

Trying to change the status after the headers have been sent. You get ERR_HTTP_HEADERS_SENT; check res.headersSent first.

Forgetting route order. If /api/orders/:id is registered before /api/orders/export, the export can never be reached.

Responding without reading the body and leaving the connection open. To discard an unread body, Node keeps the connection open and waits for the rest. In labs/10/unread-body.ts the client announces 100,000 bytes and sends 1,000 of them, and the server answers 413 without reading the body; in the second run the response adds connection: close:

HTTP/1.1 413 Payload Too Large | Date: Sat, 14 Nov 2026 18:59:58 GMT | Connection: keep-alive
connection still open after 1 s, the server waits for the rest of the body
connection closed
HTTP/1.1 413 Payload Too Large | connection: close | Date: Sat, 14 Nov 2026 18:59:59 GMT
connection closed

In the first case a malicious client can keep sending gigabytes even after getting a 413. That is why Bookstore closes the connection on a 413.

A Small Experiment

★☆☆ In labs/10/slowloris.ts, let the client finish the headers ("\r\n\r\n") and send the body slowly, and lower requestTimeout to 3 seconds. Which timeout applies this time?

★★☆ Add HEAD support to the router: for every path with a GET route, HEAD should return the same headers without sending a body. Update the allow header in the 405 response as well.

★★★ Write the PUT /api/books/:isbn/cover endpoint: read the body as a stream with a 2 MB limit, pass it to saveCover from 08 and turn InvalidCoverError into a 422. When an upload that exceeds the limit is cut off, is a temporary file left on disk?

We now know concretely what a framework will give us. In the next post we move to Express 5: routing, the middleware chain, express.json(), what changed from Express 4 and what NODE_ENV=production really changes in Express.