Routing and Middleware Without a Framework
A raw Node HTTP server gives you two objects and one main responsibility. The objects are http.IncomingMessage and http.ServerResponse. The responsibility is deciding what happens after Node hands the request to your listener. By that point, Node has already accepted the socket, run the HTTP parser, parsed the headers, and created the response object. The request body may still be waiting to be read. The response headers may still be changeable.
Your code now has to choose the right handler, read the body when needed, call application code, and finish the response once. That is the whole framework-free HTTP problem in one place.
import http from 'node:http';
http.createServer(async (req, res) => {
res.statusCode = 200;
res.end('ok\n');
}).listen(3000);This server is valid, but it has no real policy yet. Every request gets 200. It does not care about the method. It does not inspect the path. It does not read a body. It does not catch promise rejections. There is one response for every request, so the code stays tiny.
A route connects a request signature to application code. In this chapter, that signature is method plus path. A router is the code that picks the matching route. A route table is the data the router searches. For a small server, an in-memory array of route records is enough. Bigger servers usually index the table, group routes by method, compile path matchers, or let a framework own the whole request lifecycle.
Writing the raw version is useful because every moving part stays visible. There are no decorators, no plugin system, no hidden body parser, and no framework-owned error handler. There are only functions, state, streams, and one response that must be finished correctly.
Node gives you HTTP-level data. Your application decides what that data means. Node knows the request method as a string. It knows the request target as text. It gives you parsed headers. It exposes the body as a readable stream. It does not know your API model, your routes, your validation rules, or your error format.
That application contract starts small. Which methods exist. Which paths exist. Which paths accept bodies. How large a body can be. Which response wins when no route matches. Which function turns thrown errors into HTTP responses. Frameworks wrap those choices in APIs. A raw server leaves them as regular JavaScript.
Raw Handler Structure
The request listener runs after Node has parsed enough of the HTTP message to create the JavaScript request object. The value in req.url is the request target text from the HTTP start line. For normal origin-server traffic, it is usually origin-form - a path plus an optional query string.
Routing starts by parsing that text once -
function requestUrl(req) {
return new URL(req.url, 'http://localhost');
}The base URL gives the URL constructor an origin, which it needs when the request target is relative, such as /users?id=42. For path routing, http://localhost is only parsing support. Do host validation separately when your application uses hostnames for redirects, tenant selection, callback URLs, or absolute URL generation.
The useful values for routing are url.pathname and url.searchParams. The path chooses the route. Query parameters refine the request after the route has already been selected. /users?active=true and /users?active=false normally reach the same handler and give it distinct query data.
So the tiny server grows by one useful step -
http.createServer(async (req, res) => {
const url = requestUrl(req);
res.statusCode = 200;
res.end(`${req.method} ${url.pathname}\n`);
}).listen(3000);Now the handler can read the method and path separately. That is the first routing decision. GET /users and POST /users share a path but ask for separate operations. GET /users/123 has a path segment the router may capture. ?include=teams is query data for the handler after the route has already been chosen.
Node leaves those decisions to userland. node:http gives you metadata and streams. Your code owns the route table, middleware convention, body parser, validation layer, and error response contract. That is why frameworks can exist as userland packages instead of being built into http.Server.
The base URL in requestUrl() deserves a careful look. req.url contains the URL text from the HTTP request. For origin-form targets, that string starts with /. The URL constructor needs an origin to parse a relative target, so the helper supplies http://localhost. The router then reads only pathname and searchParams. Public host trust belongs in its own entry-point policy.
Absolute-form request targets can appear when a client sends proxy-style HTTP/1.1 requests. A raw origin server can still parse them with URL, but the routing rule should stay clear - route on the path portion your server actually owns. Proxy behavior, upstream and downstream normalization, and host rewriting belong to the later proxy chapter.
Path normalization is also application policy. /users, /users/, /users//, and /users/%34%32 can produce separate strings at separate stages of parsing. URL gives you a parsed pathname. Percent-encoded path sequences such as %2F and %34 remain visible in that pathname unless application code decodes route parameters deliberately. URLPattern applies its own path matching rules. If your API treats trailing slashes as equivalent, write that rule down and test it. If your API treats them as separate paths, let the router preserve that behavior.
Raw routing gets messy when several functions read req.url directly. One helper should parse it. One context field should store it. Every downstream function should read that field. That keeps route matching, query parsing, logging, and error messages based on the same request target.
Method and Path Routing
Method dispatch selects code from req.method. Path matching selects code from url.pathname. Those two checks belong in the same router because the method tells you the requested operation and the path tells you the target.
Here is a tiny route table using URLPattern for paths. Node v24 exposes URLPattern, and the current docs still mark it Stability 1 - Experimental. On older runtimes, use a small matcher or a routing package in this slot.
const routes = [
route('GET', '/users', listUsers),
route('POST', '/users', createUser),
route('GET', '/users/:id', showUser),
];
function route(method, pathname, handler) {
return { method, pattern: new URLPattern({ pathname }), handler };
}Each record stores a method, a compiled path pattern, and a handler. That is enough for a small origin server. The route table holds selection data. The request listener should not become a pile of nested if statements.
Keeping routes as data helps as soon as the server needs inspection. A test can check that POST /users exists. A startup check can reject duplicate method and path records. A debug log can print the matched route name. A 405 Method Not Allowed response can compute supported methods from the same records used for successful dispatch.
For a small server, plain JavaScript objects are fine. Give each route record only the fields the router needs - method, matcher, handler, and maybe a name for logging. Keep validation schemas, auth rules, and resource modeling elsewhere. This router should stay focused on choosing the handler and managing the request lifecycle.
Named routes help without changing dispatch. A record can store name: 'users.show' next to the method, pattern, and handler. Logs can print the name after a match. Tests can assert that one route exists for that name. Metrics get a stable label even when the path pattern changes later. The router still selects by method and path. The name is for humans and tooling.
That label is also useful after the response finishes. A timing middleware can log users.show with the final status code instead of logging only /users/:id or the runtime path /users/42. Stable labels keep request logs easier to group.
Route parameters are values captured from variable path segments. In /users/:id, the :id segment names one value. A request for /users/42 matches the route and produces { id: '42' }. Route parameters come from the path. Query parameters come from the search string. Keeping those sources separate avoids bugs where /users/42 and /users?id=42 get treated as the same input.
function findRoute(method, pathname) {
for (const route of routes) {
if (route.method !== method) continue;
const match = route.pattern.exec({ pathname });
if (match) return { route, params: match.pathname.groups };
}
}This loop is the router. It checks route records in order. A method mismatch skips the path matcher. A path match returns the route and captured parameters. No match returns undefined, which must become a deliberate HTTP response later.
Order is already an API rule. If the table contains /users/:id before /users/me, then /users/me can be captured as { id: 'me' } before the specific route gets a chance. Some routers sort routes by specificity. Some keep insertion order and expect specific routes earlier. A raw router should pick one rule and keep it visible.
Duplicate routes are easier to catch at startup than during a request. Two GET /users/:id records leave one handler unreachable if the router stops at the first match. A raw server can reject that before listening.
function assertUnique(route, seen) {
const key = `${route.method} ${route.pattern.pathname}`;
if (seen.has(key)) throw new Error(`duplicate route ${key}`);
seen.add(key);
}That snippet assumes your pattern object exposes the string you registered, or that your route() helper stores it separately. The useful idea is timing. Route-table mistakes should fail before clients start hitting the server.
Method dispatch can also be indexed without changing behavior.
function byMethod(routes) {
const map = new Map();
for (const route of routes) {
const list = map.get(route.method) ?? [];
map.set(route.method, list.concat(route));
}
return map;
}That map lets the router search only the routes for one method. It also makes 405 computation easier because the server can ask which methods match a path. For a tiny server, the array scan is easier to read. For a larger table, method buckets are usually the first optimization.
Exact paths are simple. Dynamic segments add precedence. Wildcards add another concern.
const assets = route('GET', '/assets/*', serveAsset);A wildcard path lets many runtime paths reach the same handler. That is useful for static files and catch-all handlers. It also increases the chance of accidental matches. In a small route table, put wildcard records near the end and treat them as broad matches. Chapter 10 returns to static files and streaming bodies later, so the route-level idea is enough here.
The path matcher should work on the parsed pathname supplied by the URL implementation. Raw socket bytes stay below the HTTP parser. Node's HTTP parser gives you the request target text. URL separates pathname and search. URLPattern matches URL parts. If handlers need decoded route values, decode captured parameters deliberately and handle URIError. If you hand-roll matching, make clear choices for percent-encoding, repeated slashes, trailing slashes, and case policy. Clients will start depending on those choices.
Hand-rolled path matching often starts with pathname.split('/'). That can work for a constrained internal tool. It becomes protocol behavior once encoded slashes, empty segments, trailing slash policy, and unicode normalization enter the picture. A raw router using URLPattern delegates URL-pattern details to a runtime API already covered in Chapter 8, then keeps application policy in the route table.
Route parameters should stay strings when they leave the router. :id captures text. Handler code can decide whether that text must be a number, UUID, slug, database key, or something else. Convert and validate inside the handler or in route-specific middleware. That keeps routing separate from domain validation.
async function showUser(ctx) {
const id = Number(ctx.params.id);
if (!Number.isInteger(id)) throw httpError(400, 'bad id');
json(ctx.res, 200, await loadUser(id));
}The route matched /users/:id. The handler decided what kind of id it accepts. Later chapters cover richer validation. For now, the router finds a handler and prepares the raw handler input.
For a small server, a linear scan is fine. It is readable. It is also O(number of routes) on every request. That cost is usually hidden under application work when the table has a dozen records. Once the table grows into hundreds or thousands of records, use method buckets, prefix grouping, trie-style path lookup, or framework code that already handles lookup well.
The first raw router should stay boring.
Response Branches
Routing has three ordinary outcomes.
One route matches the method and path. Run its handler.
The path is known for at least one method, but the current method has no match. Return 405 Method Not Allowed.
No path matches. Return 404 Not Found.
A 404 handler writes the response for an unknown path. A 405 response says the target path exists, but this method is unsupported. For 405, include an Allow header with the supported methods. That header is part of the HTTP response contract and does not require a framework.
This distinction is small and useful. GET /missing means the router found no matching target path. DELETE /users/42 may mean the target path exists and the method is unsupported. Clients and tests can react to those cases separately. A typo in the path should show up as 404. A method mismatch should show up as 405 with supported methods attached.
HEAD needs an explicit rule. Some servers route HEAD /x through the same metadata path as GET /x and suppress the body. Some raw servers register separate HEAD handlers. Pick the behavior directly in the route table. Silent fallback from HEAD to GET can surprise handlers that always write bodies.
function allowedFor(pathname) {
const allowed = new Set();
for (const route of routes) {
if (route.pattern.test({ pathname })) allowed.add(route.method);
}
return [...allowed];
}This lookup ignores the current method and asks whether the path belongs to any route. If it does, the set gives the 405 response its Allow value.
Response finalization should live in helper code so every branch writes in the same style.
function send(res, status, body) {
const bytes = Buffer.byteLength(body);
res.writeHead(status, {
'content-type': 'text/plain; charset=utf-8',
'content-length': bytes,
});
res.end(body);
}The helper sets status, content type, content length, and then ends the response. response.end() is the final write for this response. After it runs, later code should treat the response as complete.
The fallback code can now be explicit -
function miss(ctx) {
const allowed = allowedFor(ctx.url.pathname);
if (allowed.length === 0) return send(ctx.res, 404, 'not found\n');
ctx.res.setHeader('allow', allowed.join(', '));
return send(ctx.res, 405, 'method not allowed\n');
}A terminal handler is a function that finishes the response for a branch. miss() is terminal because it calls send(). A matched route handler can also be terminal. Middleware can be terminal when it rejects a request early. The rule is simple - once a branch writes the final response, later branches must stop.
Terminal ownership is easier to see when helpers return after writing.
function requireGet(ctx, next) {
if (ctx.req.method === 'GET') return next();
return send(ctx.res, 405, 'method not allowed\n');
}The middleware either continues or writes. Only one path runs. That one habit prevents most accidental double-response bugs in raw code.
Double responses usually come from unclear ownership. One function writes 404. Another function keeps running and tries to write 200. Then Node reports a late header mutation or a write-after-end. Control flow is the fix. Return after terminal writes. Await the handler that owns the response. Keep the fallback as a real branch.
Header commit points make these mistakes show up quickly. response.writeHead(), response.flushHeaders(), response.write(), and response.end() can all commit response metadata. After commit, setHeader() becomes a late mutation. A raw router should avoid writing bytes until the final branch is known. That keeps status and headers changeable while middleware is still making decisions.
Status defaults can hide bugs here. ServerResponse starts with a success status unless your code changes it. A handler that calls res.end('missing') without setting statusCode sends a success-looking HTTP response. Set the status at the same point where the branch is chosen. Success, fallback, and error paths should be easy to spot in review.
JSON helpers should follow the same terminal rule.
function json(res, status, value) {
const body = JSON.stringify(value);
res.writeHead(status, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(body),
});
res.end(body);
}One helper owns JSON response headers and finalization. Route handlers still choose the status and value. Repeating response setup in every handler makes it easier for one branch to forget content type, content length, or the return after end().
async function dispatchRoute(ctx) {
const hit = findRoute(ctx.req.method, ctx.url.pathname);
if (!hit) return miss(ctx);
ctx.params = hit.params;
await hit.route.handler(ctx);
}The matched route owns the response after await hit.route.handler(ctx). The fallback owns it when no route matches. The caller can catch errors, but it should not write a success response after this function returns.
Middleware as Ordered Functions
Middleware is code that runs before or around a terminal handler. It receives per-request state and a continuation function. It can add data to the request context, reject early, call the next function, or run cleanup after the next function finishes.
A middleware chain is an ordered list of those functions. Order changes behavior. A URL parser must run before route dispatch if the router reads ctx.url. A JSON body parser must run before a route handler that reads ctx.body. A timing middleware can run before and after the terminal handler because it awaits the next function and then observes the completed request path.
Here is the smallest useful composition function -
const compose = stack => async ctx => {
let index = -1;
const run = async i => {
if (i <= index) throw new Error('next() called twice');
index = i;
await stack[i]?.(ctx, () => run(i + 1));
};
await run(0);
};The composed app is one async function. It starts at middleware index zero. Each middleware receives ctx and next. Calling await next() runs the following middleware. Returning without calling next() stops the chain.
The index guard catches a control-flow bug. next() is the continuation for one position in the chain. Calling it twice runs later middleware twice or runs a terminal handler twice. That usually leads to two writes for one response. The guard turns the bug into a thrown error near the source.
The chain follows normal promise sequencing. Middleware zero runs until it awaits next(). Middleware one then runs until it awaits next(). The terminal handler eventually writes or throws. After that, control returns outward through the middleware that awaited next(). Code after await next() runs in reverse order of entry. That is the cleanup path.
async function withCleanup(ctx, next) {
try {
await next();
} finally {
ctx.state.finishedAt = Date.now();
}
}The finally block runs when downstream code resolves or rejects. It also runs after a terminal handler writes a response. That makes cleanup reliable as JavaScript control flow. Actual byte delivery to the client still depends on response and socket state.
Now the server can run a stack.
const app = compose([withUrl, withRequestId, dispatchRoute]);
http.createServer((req, res) => {
app({ req, res }).catch(err => fail(req, res, err));
}).listen(3000);The http.createServer() request listener stays thin. It creates a per-request context object, passes it into the app, and catches any rejected promise that escapes. That catch path is the last defense against hung sockets.
Middleware can be tiny.
async function withUrl(ctx, next) {
ctx.url = requestUrl(ctx.req);
ctx.query = ctx.url.searchParams;
await next();
}That function parses the request URL once and stores both the URL and query parameters. Later code reads ctx.url.pathname and ctx.query. The route handler receives parsed request-target state instead of reparsing req.url.
A request id middleware can attach local state without global request state.
let nextId = 0;
async function withRequestId(ctx, next) {
ctx.requestId = `req-${++nextId}`;
await next();
}The counter is process-wide state. The generated id is per-request state. In production, IDs often arrive from incoming headers or from a separate generator. The pattern is the same - middleware attaches data to ctx, later code reads it, and no handler reaches through hidden global state for request data.
Short-circuiting is also just a return.
async function requireJson(ctx, next) {
if (contentType(ctx.req) === 'application/json') return next();
return send(ctx.res, 415, 'expected application/json\n');
}That middleware checks a precondition. If the request passes, the chain continues. If it fails, the middleware writes the response and returns. Downstream body parsing and route handling are skipped for that request.
Middleware order is where bugs often enter.
const app = compose([
withJsonBody,
withUrl,
dispatchRoute,
]);That stack parses the body before it parses the URL. Maybe that is fine for a JSON-only API. Maybe it rejects a large body before discovering that the path has no route. If your intent is route selection before body work, put withUrl and dispatch ahead of route-specific body parsing. A raw chain gives you full control, which also means wrong order is your responsibility.
Most middleware bugs are control-flow bugs. A middleware forgets await next() and the request stops early. A middleware calls next() after sending a response and a later handler writes again. A middleware starts async work, does not await it, and the outer catch never sees the rejection. A middleware mutates shared state and two requests observe each other's data.
The composed function has no hidden queue. It uses the same call stack and promise jobs as the rest of your program. Synchronous middleware runs until it hits an await, returns, throws, or calls the continuation. Async middleware returns a promise. The outer request listener observes only the promise returned by app(ctx). Anything outside that promise needs its own error policy.
That also explains error propagation. A thrown error before await next() rejects the composed app immediately. A thrown error inside downstream middleware rejects back through every upstream await next(). An upstream middleware can catch it, write an error response, and return. Or it can add context and rethrow.
async function withRouteErrors(ctx, next) {
try {
await next();
} catch (err) {
err.route = ctx.routeName;
throw err;
}
}That middleware only annotates the error. It adds route context and lets the top-level error handler decide how to write the response. Keeping response writing in one place reduces branch-specific surprises.
The chain has no scheduler of its own. It is promise sequencing. When middleware awaits next(), control moves into the following function and returns when that downstream work resolves or rejects. If downstream code writes the response, upstream code resumes after that response branch has finished. That is why cleanup, timing, and logging code often sits after await next().
async function withTiming(ctx, next) {
const start = performance.now();
await next();
const ms = performance.now() - start;
console.log(ctx.requestId, ctx.req.method, ctx.url.pathname, ms);
}The log runs after downstream middleware and the terminal handler complete. It observes the route path and timing without owning the response. If downstream code rejects, the line after await next() is skipped unless this middleware catches and rethrows.
Raw middleware is ordinary JavaScript call flow with promises. Node calls the request listener. The request listener calls your composed app. The app calls middleware zero. Middleware zero decides whether to call middleware one. Eventually some function writes to ServerResponse. Errors move back through the same promise chain until a catch handles them.
The response object has its own state alongside that JavaScript flow - headers pending, headers sent, body writable, body ended, socket open, socket closed. JavaScript control flow and response state can drift apart. A function can return without ending the response. A function can end the response and keep executing. A rejected promise can arrive after headers have already been sent. Raw middleware has to keep those tracks aligned.
The request object has state too. The request body stream may be unread, partially read, complete, destroyed, or closed. Middleware that reads the body changes what later middleware can read. A JSON body parser consumes the stream. A later proxy handler cannot stream the original body upstream because those bytes have already passed through the parser. That is one reason route-local body parsing is cleaner than global body parsing.
Middleware order decides who owns the data. URL parsing is harmless because it reads a string already present on the request object. Header inspection is harmless when it only reads parsed header values. Body parsing consumes stream data. Response helpers commit output state. Error middleware may destroy the request path. Put those side effects close to the route that needs them.
The clean pattern is simple. Terminal handlers return after ending. Middleware either returns early after ending or awaits next(). The top-level request listener catches errors. Error handling checks response state before writing. Body parsing sets limits before accumulating bytes. Request-local data sits on ctx.
That is enough structure for a small app.
Bounded Body Collection
Node's HTTP parser identifies where the message body begins and how it is framed. Application code still decides what the body means. A body parser consumes the request stream and converts its bytes into an application value. A JSON body parser collects bytes, decodes them as text, runs JSON.parse(), and stores the result where the handler can read it.
The unsafe version is common -
async function rawBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks);
}That code accepts any body size. A client can send more data than the process should hold. Stream backpressure controls read flow, but memory policy still needs a budget. A request body limit is that budget. The parser has to count bytes while reading and reject once the body crosses the configured maximum.
Memory pressure appears before the handler sees a parsed object. Every chunk pushed into chunks stays referenced until Buffer.concat() finishes. A thousand concurrent uploads can hold a thousand partial bodies. The limit belongs inside the collection loop because checking only after Buffer.concat() means the process has already accepted the allocation risk.
Declared length can help, but it cannot replace byte counting. Content-Length is a header value from the peer. It can be missing. It can be invalid. It can be smaller than the bytes that arrive before the parser catches the framing error. Chunked bodies have no single declared length. The stream counter is the enforcement point your application controls.
function httpError(status, message) {
const err = new Error(message);
err.status = status;
return err;
}Small raw servers can attach an HTTP status to ordinary errors. Chapter 27 owns error taxonomy. Here, the local goal is getting the response branch right.
The collector should count bytes as they arrive -
function acceptChunk(chunks, chunk, size, limit) {
const next = size + chunk.length;
if (next > limit) throw httpError(413, 'body too large');
chunks.push(chunk);
return next;
}The helper owns one chunk-sized decision. It computes the next byte count, rejects the body when the limit is crossed, stores the chunk, and returns the new total.
async function collect(req, limit) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size = acceptChunk(chunks, chunk, size, limit);
}
return Buffer.concat(chunks, size);
}The for await loop reads from the request stream. Each chunk is a Buffer. size is the number of bytes accepted so far. Crossing the limit throws 413 Payload Too Large. Buffer.concat(chunks, size) builds one buffer after the body completes.
The collector still needs a policy for limit failures. It throws while unread bytes may remain on the socket. Keeping the connection open would require draining or discarding the rest of the request body safely. A small raw server can choose a simpler policy - respond with 413, set Connection: close, and let the socket close after the response path finishes. That favors correctness over connection reuse.
Early length checks are still useful for fast rejection.
function declaredTooLarge(req, limit) {
const value = req.headers['content-length'];
const length = value === undefined ? 0 : Number(value);
return Number.isFinite(length) && length > limit;
}That helper treats a missing header as unknown. The stream counter still runs for every body. An invalid length should already be handled by HTTP parsing rules before a normal request reaches your handler, but application code should avoid trusting strange header values as real numbers.
A JSON request body has two claims to check. Content-Type tells you what the sender claims. The actual bytes still need to decode as UTF-8 and parse as JSON. Check the media type before parsing, then parse the bytes and handle parse failure.
function contentType(req) {
const value = req.headers['content-type'];
return String(value ?? '').split(';', 1)[0].trim().toLowerCase();
}The helper strips parameters such as charset=utf-8 and normalizes casing. It handles the ordinary application/json; charset=utf-8 case without becoming a full media type parser.
async function readJson(req, limit) {
if (contentType(req) !== 'application/json')
throw httpError(415, 'expected application/json');
const body = await collect(req, limit);
const decoder = new TextDecoder('utf-8', { fatal: true });
try { return JSON.parse(decoder.decode(body)); }
catch { throw httpError(400, 'invalid json'); }
}The parser maps bad input to HTTP statuses. Wrong media type becomes 415 Unsupported Media Type. Too many bytes becomes 413 Payload Too Large. Malformed UTF-8 and invalid JSON both become 400 Bad Request. A successful return gives the route handler parsed data and leaves response formatting to the shared error path.
Empty bodies need a local rule. JSON.parse('') throws. If a route requires a JSON object, an empty body is a bad request. If a route accepts an optional body, the parser can map an empty complete body to undefined and let the handler choose. Make that choice in the parser or route middleware, so the handler knows whether ctx.body always exists.
function requireObject(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
throw httpError(400, 'expected json object');
}That is a tiny validation step for handlers that expect JSON objects. Arrays, strings, numbers, booleans, and null are valid JSON values, but many API routes accept only objects. Schema systems come later.
Route-local parsing also avoids work on paths that will reject. A global JSON parser runs before routing unless you place it after route selection. That means a GET /missing request with a huge body can spend memory before the server discovers the route is unknown. A route-local parser runs only after method and path have selected a route that expects JSON.
The route table can store a local stack.
const routes = [
route('GET', '/users', listUsers),
route('POST', '/users', jsonBody(1_000_000), createUser),
];The earlier route() helper accepted one handler. A slightly richer version can store multiple handlers and compose them when the route matches.
function route(method, pathname, ...stack) {
return {
method,
pattern: new URLPattern({ pathname }),
handler: compose(stack),
};
}Now POST /users runs jsonBody(1_000_000) and then createUser. GET /users runs only listUsers.
Body collection has one more failure path. The client can close the connection before the declared body arrives. At that point, the request stream ends through an error or closes with message.complete still false. The application did not receive a full JSON document. Treat that as bad input or an aborted request, depending on how far the response has progressed.
async function collectComplete(req, limit) {
const body = await collect(req, limit);
if (!req.complete) throw httpError(400, 'incomplete body');
return body;
}message.complete belongs to the request object and reports whether the full HTTP message was received. For body parsers, it is the useful check after stream consumption. A normal empty body can still be complete. An aborted upload can leave the message incomplete.
Content-Length can reject some requests before body allocation begins. If the header says the body is larger than your limit, the server can answer 413 immediately. A client can still lie, omit the header, or use chunked transfer coding, so the streaming byte counter remains the real guard.
The middleware can combine these checks and attach the parsed value.
function jsonBody(limit) {
return async (ctx, next) => {
if (declaredTooLarge(ctx.req, limit)) throw httpError(413, 'body too large');
ctx.body = requireObject(await readJson(ctx.req, limit));
await next();
};
}The declared-length check gives an early exit. readJson() still enforces the byte limit while reading. requireObject() gives the handler an object value. The order is intentional - cheap header check, bounded read, syntax parse, local structure check, downstream handler.
The hard part is connection reuse after rejection. If the application stops reading a request body and leaves unread bytes on a persistent connection, the next HTTP message on that socket becomes ambiguous for the server lifecycle. For small raw handlers, the simplest policy is to send an error response with Connection: close when the body limit is crossed. More advanced servers can drain and discard the remaining body up to another limit, then keep the connection reusable. That belongs in framework or infrastructure code once the policy has to cover uploads, streaming proxy requests, slow clients, and observability.
Keep the raw parser narrow. JSON only. Limit every read. Reject unknown media types. Treat parse errors as client errors. Close on limit failures unless you deliberately drain. Leave multipart uploads and streaming request bodies for code designed around streaming.
Error Flow in Raw Handlers
A raw request can fail in two broad places. The HTTP layer can fail before your route handler receives a request. Subchapters 2 and 3 covered parser errors and clientError. The application layer can fail after your request listener starts. The second case belongs here.
An async route can throw before it writes.
async function createUser(ctx) {
const user = await saveUser(ctx.body);
send(ctx.res, 201, `${user.id}\n`);
}If saveUser() rejects, the route exits before writing a response. The top-level catch should convert that error into one response.
function fail(req, res, err) {
if (res.headersSent || res.writableEnded) return req.destroy(err);
const status = err.status ?? 500;
if (status === 413) res.setHeader('connection', 'close');
send(res, status, `${err.message}\n`);
}The first line protects against late errors. response.headersSent means the metadata has already been committed. response.writableEnded means the writable side has ended. At either point, a new error response would corrupt the exchange, so the code destroys the request path instead of pretending it can still send clean HTTP.
For errors before response commit, the function chooses the status from err.status and falls back to 500. A body-limit failure sets Connection: close because the server has rejected the body stream and should avoid keeping that connection for another request.
Error bodies need the same consistency as success bodies. The small fail() helper above writes plain text because the earlier snippets used send(). A JSON API would call json() instead. The useful part is centralization. Keep status mapping and response format in one error path so route handlers can throw local errors and the outer layer can turn them into HTTP.
function failJson(req, res, err) {
if (res.headersSent || res.writableEnded) return req.destroy(err);
const status = err.status ?? 500;
if (status === 413) res.setHeader('connection', 'close');
json(res, status, { error: err.message });
}That helper is still small. It adds one policy - errors are JSON objects. Chapter 12 owns full API error contracts, including codes, fields, localization, and documentation.
The catch belongs at the request listener entry point.
http.createServer((req, res) => {
const ctx = { req, res };
app(ctx).catch(err => fail(req, res, err));
}).listen(3000);That pattern catches rejected promises from middleware and terminal handlers. It also catches synchronous throws because async functions convert throws into rejected promises. Work started outside the awaited promise needs its own error policy.
The outer catch also protects the request from hanging. A thrown error without a catch leaves the composed promise rejected. The request listener has already returned to Node by then. Node has no application-level response to send for your app. If the response remains unwritten, the client waits until timeout or socket closure. A raw handler needs that catch as part of the server structure.
Fire-and-forget work can escape the response lifecycle.
async function badMiddleware(ctx, next) {
saveAuditEvent(ctx);
await next();
}If saveAuditEvent() rejects later, the composed app has already moved on. The outer catch receives no rejection. For request-owned work, await it or attach an error handler that has its own policy. Raw middleware gives you no hidden supervisor.
Detached work should copy only the state it needs. Passing the whole ctx into a background operation keeps references to req, res, body buffers, and route data alive longer than the request needs. That can keep memory reachable after the response has finished. Pass small values instead.
async function auditLater(ctx, next) {
const event = { id: ctx.requestId, path: ctx.url.pathname };
queueMicrotask(() => audit(event).catch(console.error));
await next();
}The audit event no longer holds the response object or request stream. The queued task has its own error policy. The request path can finish independently.
Late writes are another common failure.
async function badHandler(ctx) {
send(ctx.res, 202, 'accepted\n');
await slowWork();
send(ctx.res, 200, 'done\n');
}The second send() is a bug. The response ended before slowWork() ran. The fix is to separate background work from request work, or return a status that accurately describes the accepted operation. Chapter 27 handles resilience policy. Here, the mechanical rule is enough - one request gets one final response.
Request close handling belongs beside async work. A client can disconnect while your route is still waiting on a database call or upstream fetch. Request completion and response closure are separate states. For raw handlers, track both and check the result before writing after an await.
async function withCloseState(ctx, next) {
ctx.clientClosed = false;
const markClosed = () => { ctx.clientClosed = true; };
ctx.req.once('close', () => { if (!ctx.req.complete) markClosed(); });
ctx.res.once('close', () => { if (!ctx.res.writableEnded) markClosed(); });
await next();
}The message.complete check catches interrupted request bodies. The response close listener catches teardown before response.end() finishes. A socket close after a complete request still counts while the handler is waiting on downstream work, because the response can close with writableEnded === false. Use that state to avoid writes for clients that have gone away.
Node v24.16 and newer also expose req.signal, an AbortSignal tied to socket closure or request destruction. Pass it into downstream work that accepts cancellation, such as fetch() or a database client with abort support.
Keep input failures separate from application failures while debugging. A request stream error points to input or socket trouble. An application error means code failed while handling a valid request object. Mapping both to 500 hides the source. The raw server can map body parse errors to 400, media-type errors to 415, body limits to 413, and unknown application failures to 500. That local status mapping is enough to keep sockets from hanging and status codes from lying.
Late errors after a partial response are the ugly case. Suppose a route streams part of a response and then its data source fails. The status and headers are already committed. The server can close the connection, log the failure, and possibly let the client observe a truncated body. It cannot replace the response with a clean JSON error. Raw routing code should delay response commitment until it has enough data to choose status, or deliberately use streaming semantics where mid-body failure is part of the protocol design.
The same rule applies to middleware that calls res.write() early. That write commits headers. Later validation failures can no longer change the status. In raw code, keep validation and body parsing before the first write. Streaming responses are a separate design path, covered later with proxies and streaming bodies.
Per-Request State
The context object is the simplest state container for framework-free middleware.
async function showUser(ctx) {
const includeTeams = ctx.query.get('include') === 'teams';
const user = await loadUser(ctx.params.id, { includeTeams });
json(ctx.res, 200, user);
}The handler reads route parameters from ctx.params and query parameters from ctx.query. Earlier URL and routing middleware attached those values. The handler receives parsed request data from the request pipeline.
The earlier json() helper lets handlers write application values without repeating headers.
async function createUser(ctx) {
const user = await saveUser(ctx.body);
json(ctx.res, 201, { id: user.id });
}Raw HTTP remains underneath. The helper only centralizes response formatting. Request validation, resource modeling, authorization, CORS, and contract documentation belong later.
The context object also prevents global request state. A global currentUser, currentRequest, or currentParams breaks as soon as two requests overlap. Node can run many request handlers interleaved on the same event loop. While one handler awaits I/O, another handler can run. Shared mutable variables become cross-request state unless they are deliberately scoped and protected.
The per-request object gives every exchange its own references.
function makeContext(req, res) {
return {
req,
res,
state: Object.create(null),
};
}state is an empty object for middleware that needs namespaced data. One middleware can store ctx.state.user. Another can store ctx.state.metrics. For a small raw server, plain properties such as ctx.url, ctx.params, and ctx.body are also fine. The point is ownership - request data travels with the request.
Name collisions are the price of a plain object. Two middleware functions can both choose ctx.user and overwrite each other. A tiny server can handle that with naming discipline. A larger server usually needs conventions like ctx.state.auth, ctx.state.route, ctx.state.metrics, or symbols for library-owned fields. Frameworks often formalize this because middleware from separate packages has to share one context safely.
The context should also live only for the request. Avoid caching it after the response finishes. ctx.req and ctx.res hold references into stream and socket state. ctx.body may hold the entire parsed request body. If a background queue or cache stores the context object, it can keep those objects alive. Copy the small values needed for later work and let the request context be collected.
function auditEvent(ctx) {
return {
id: ctx.requestId,
method: ctx.req.method,
path: ctx.url.pathname,
};
}That event is safe to pass to later code because it contains plain data. It excludes the request stream, response stream, and parsed body.
Header-derived data needs care. A request id header, forwarded address header, or host header is client-controlled unless a trusted proxy or platform contract says otherwise. For this raw router, those values can pass through as strings. Later security and platform chapters own trust rules. Do not build authorization, tenancy, or redirect policy on raw headers only because middleware made the value easy to access.
The same rule applies to parsed bodies. ctx.body is parsed JSON. Business validation remains separate. A JSON object can miss fields, carry extra fields, use wrong types, or contain strings that break downstream assumptions. Chapter 12 owns schema validation and API contracts. The body parser here only turns bytes into a JavaScript value under a byte limit.
Wiring the Small Router Together
At this point, the whole server can stay small enough to read.
const app = compose([
withCloseState,
withUrl,
dispatchRoute,
]);The app records close state, parses the URL, and dispatches routes. Route-local stacks handle body parsing.
const routes = [
route('GET', '/users', listUsers),
route('POST', '/users', jsonBody(1_000_000), createUser),
route('GET', '/users/:id', showUser),
];The route table shows the public surface of the server at a glance - method, path, middleware, terminal handler. No hidden registry.
The request listener creates context and owns the final catch.
http.createServer((req, res) => {
const ctx = makeContext(req, res);
app(ctx).catch(err => fail(req, res, err));
}).listen(3000);That is a real framework-free HTTP handler. It routes by method and path. It captures route parameters. It keeps query parameters separate. It runs middleware in order. It returns 404 and 405 deliberately. It parses bounded JSON bodies. It catches rejected promises and avoids writing a second response after headers are committed.
The useful part is how easy the ownership is to see. Route matching happens in findRoute(). Fallback responses happen in miss(). Body limits happen in collect(). Error conversion happens in fail(). Response formatting happens in send() or json(). Per-request state lives in ctx.
Small servers benefit from that visibility. Tests can call findRoute() directly. Body parser tests can feed a fake readable stream. Middleware tests can pass a fake context. Route handlers can run without a network socket if the response helper is abstracted or mocked.
Raw code also shows the remaining responsibilities quickly.
Validation stops at JSON syntax here. Route-level schemas belong elsewhere. Content negotiation needs its own policy. Authentication and authorization need their own layer. CORS, rate limiting, structured error responses, observability context, and lifecycle hook systems all need explicit owners.
That line marks the point where a useful raw exercise can turn into a custom framework project. When that line moves, the maintenance cost moves with it. Usually earlier than the code makes obvious at first.
Those omissions are fine for a small internal handler, a test server, a local webhook receiver, or a constrained service with a tiny surface. They become operational debt when the API grows.
The first pressure point is route ordering. A broad pattern can capture paths that later routes meant to own. A raw router can document order and test the table, but lookup rules get more involved once optional segments, wildcards, mounted prefixes, and versioned paths show up.
Mounted prefixes are a common inflection point. /api/users, /admin/users, and /internal/users can share handler pieces while needing separate middleware. A raw route table can encode that with path prefixes and repeated stacks. The repetition is tolerable for five routes. It becomes noisy when every route needs logging, body parsing, auth, metrics, and feature flags in slightly separate combinations.
Route generation is another pressure point. Once routes need names for URL building, documentation, tests, and client SDKs, the route table becomes a source of truth. A hand-built array can still do it, but the surrounding tooling grows. That is where frameworks and API contract tools become useful.
The second pressure point is body parsing. JSON with a byte limit is manageable. Multipart forms, compressed request bodies, streaming uploads, partial reads, and proxying request bodies need more machinery. A raw body parser that started as nine lines becomes a fragile protocol edge when it has to cover every client behavior.
Streaming bodies change the design completely. A route that proxies an upload should avoid collecting the entire body into memory. A route that verifies a signature may need raw bytes before JSON parsing. A route that accepts compressed input has to decide where decompression happens and which byte limit applies before and after decompression. The raw JSON parser in this chapter has a narrow contract on purpose.
The third pressure point is lifecycle hooks. Frameworks centralize places to run code before parsing, before validation, after validation, before response, after response, and on error. A hand-built middleware chain can represent some of that, but every new hook needs convention. Without convention, each route invents its own local lifecycle.
Observability follows the same pattern. A tiny server can log method, path, status, and duration after await next(). A production service usually needs request IDs, trace propagation, structured logs, metrics labels, error classification, and context propagation through downstream async work. Raw middleware can start that work, but Chapter 29 owns the larger observability request context.
The fourth pressure point is API contract work. REST resource modeling, OpenAPI, JSON Schema, and schema validation libraries belong in Chapter 12. A raw router can call a validation function. API design framework work stays separate.
The fifth pressure point is security policy. Authentication, authorization, CORS, rate limiting, proxy trust, and request normalization all affect routing and middleware order. A raw handler can contain those checks, but each one needs a precise owner. Chapter 24 and Chapter 25 own those policies. Here, the raw server only leaves clear attachment points.
Keep the raw router honest. Use it when you want the HTTP mechanics visible in ordinary code. Move to a framework when routing, validation, lifecycle hooks, observability, and operational policy start taking more attention than handler logic. The underlying Node path stays the same - a parsed request object, a writable response object, ordered userland code, and one final response per exchange.