Routing and Middleware Without a Framework
When you call http.createServer() and pass a listener, Node calls that listener with two objects, an http.IncomingMessage and an http.ServerResponse. Everything after that call is yours to decide. Node has already accepted the socket, run the HTTP parser, read the headers, and built the response object before your listener runs. The body might not be read yet. The headers can still change.
From there your code picks the right handler, reads the body when a route needs it, runs the application code, and finishes the response exactly once. Those four steps are the whole job when there is no framework underneath.
import http from 'node:http';
http.createServer(async (req, res) => {
res.statusCode = 200;
res.end('ok\n');
}).listen(3000);This server runs, but it makes no decisions. Every request gets 200 regardless of method or path. Nothing reads a body, and nothing catches a rejected promise. There is one response for every request, so the code stays short.
Adding policy means matching a request to the code that should handle it. The thing you match on is the request signature, and here that signature is the method plus the path. The code that scans for a match is the router, and the data it scans is the route table. A small server keeps that table as an in-memory array of records. Larger ones index it, group records by method, or precompile their path matchers, and at some scale they give up and hand the whole request lifecycle to a framework.
The reason to build it by hand is that every part stays in plain sight. The matcher, the body parser, and the error handler are functions you wrote and can read top to bottom. What you work with is functions, some state, a few streams, and one response you are responsible for finishing correctly.
Node gives you HTTP-level data and stops there. The request method arrives as a string, the request target as text, the headers already parsed, and the body as a readable stream. What that data means belongs to your application. Your routes, your API model, and the format of your error responses live entirely in code you write, because Node has no concept of any of them.
The contract your application enforces starts small. You decide which methods and paths exist, which paths accept bodies, how large a body can be, what happens when no route matches, and how a thrown error turns into an HTTP response. A framework would hide each of those decisions behind an API. Without one, they stay as ordinary JavaScript you can see and change.
Raw Handler Structure
By the time your listener runs, Node has parsed enough of the HTTP message to build the JavaScript request object. The string in req.url is the request target copied from the HTTP start line. For ordinary origin-server traffic it shows up in origin-form, which is a path followed by an optional query string.
Routing starts by parsing that string once.
function requestUrl(req) {
return new URL(req.url, 'http://localhost');
}The second argument gives the URL constructor an origin to resolve against, which it needs whenever the request target is relative, such as /users?id=42. For routing on the path, http://localhost is a placeholder that makes parsing succeed and nothing more. Validate real hostnames in a separate step when your application builds redirects, selects a tenant, checks callback URLs, or generates absolute URLs.
Routing reads two fields off that object, url.pathname and url.searchParams. The path selects the route. Query parameters refine the request once the route is already chosen. A request for /users?active=true and one for /users?active=false reach the same handler and hand it different query data.
Putting that helper into the listener changes what the handler can see.
http.createServer(async (req, res) => {
const url = requestUrl(req);
res.statusCode = 200;
res.end(`${req.method} ${url.pathname}\n`);
}).listen(3000);The handler reads the method and the path as two separate values, and that split is the first routing decision. GET /users and POST /users share a path while asking for different operations. GET /users/123 carries a path segment the router may capture. ?include=teams is query data the handler reads after the route is chosen.
All of those decisions sit in userland. node:http hands you metadata and streams, and the rest is yours to write, the route table, the middleware convention, the body parser, the validation, and the error response contract. That separation is why frameworks ship as ordinary npm packages instead of living inside http.Server.
One detail about that helper needs to be explicit. For origin-form targets, req.url starts with /, which is a relative reference with no origin of its own. That is the only reason the helper passes http://localhost. The router then reads pathname and searchParams and ignores the made-up host. Whether to trust a real public host is a separate question for your entry-point code.
Absolute-form targets show up when a client sends proxy-style HTTP/1.1 requests. A raw origin server can still parse them with URL, but keep the routing rule simple and match on the path portion your server is actually serving. Proxy behavior, upstream and downstream normalization, and host rewriting come up in the later proxy chapter.
Path normalization is also application policy. The strings /users, /users/, /users//, and /users/%34%32 can come out distinct at different stages of parsing. URL gives you a parsed pathname, and percent-encoded sequences such as %2F and %34 stay encoded in it until your code decodes a route parameter on purpose. URLPattern then applies its own matching rules on top. Decide what trailing slashes mean for your API, write the decision down, and test it, whether you treat /users and /users/ as the same route or as two.
Raw routing turns messy when several functions each reach for req.url on their own. Parse it once in a helper, store the result on a single context field, and let every later function read that field. Then route matching, query parsing, logging, and error messages all work from the same parsed target instead of re-deriving it.
Method and Path Routing
Dispatch needs two values. The method comes from req.method, the path from url.pathname. Keep them together, because the method is the operation the client is asking for and the path is the thing it wants that operation applied to.
A small route table can use URLPattern for the path side. Node v24 exposes URLPattern, and the current docs still mark it Stability 1, Experimental. On older runtimes, drop a small matcher or a routing package into the same 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 holds a method, a compiled path pattern, and a handler. For a small origin server that is enough. Keeping the routes as data is what stops the request listener from turning into a stack of nested if statements.
Treating routes as data pays off as soon as you want to inspect them. You can write a test that asserts POST /users is registered. Before the server even listens, a startup pass can walk the array and reject duplicate method-and-path records. The matched route name can go straight into a debug log. And when a request needs a 405 Method Not Allowed, the supported-methods list comes from the same records that drive successful dispatch.
Plain JavaScript objects are fine at this size. Give each record only the fields the router actually uses, which means a method, a matcher, a handler, and maybe a name for logging. Validation schemas, auth rules, and resource modeling go somewhere else. The router has one job, picking the handler and steering the request through its lifecycle.
Names help without touching dispatch. A record can carry name: 'users.show' alongside the method, pattern, and handler. After a match, logs can print that name, a test can assert a route exists under it, and metrics keep a stable label even after the path pattern changes. Selection still runs on method and path. The name exists for people and tooling reading the output, not for the matcher.
The same label helps after the response is done. A timing middleware can log users.show with the final status code rather than the pattern /users/:id or the concrete path /users/42. Logs that share a stable label are easier to group later.
A route parameter is the value a variable path segment captures. The :id in /users/:id names one such value, so a request for /users/42 matches and produces { id: '42' }. Those values come from the path, while query parameters come from the search string. Keep them in separate places and you avoid the bug where /users/42 and /users?id=42 get read 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 };
}
}That loop is the whole router. It walks the records in order, and a method mismatch skips the path check entirely. When the path matches, it returns the route together with the captured parameters. When nothing matches it returns undefined, and turning that undefined into a real HTTP response is a job for code further down.
Order is part of your API whether you intended it or not. Put /users/:id in the table before /users/me, and /users/me gets captured as { id: 'me' } before the specific route is ever tried. Routers handle this in different ways, some sorting by specificity, some keeping insertion order and trusting you to register specific paths first. A raw router needs to pick one of those and make the choice visible in the code.
First match wins. A dynamic route placed above a more specific literal will match first and shadow it. With /users/:id registered before /users/me, a request for /users/me matches the dynamic route as { id: 'me' }, and the /users/me handler never runs. Register specific paths first, or sort by specificity, and write the rule down so the next edit to the table does not break it without anyone noticing.
Duplicate routes are far easier to catch at startup than mid-request. Two GET /users/:id records leave one handler unreachable whenever the router stops at the first match. A raw server can refuse to start instead of shipping that bug.
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 the pattern object still exposes the string you registered, or that your route() helper kept it separately. The point is the timing. A mistake in the route table should fail before any client reaches the server.
Run the duplicate-key and ordering checks across the route array once at startup, before listen(). The table is plain data in memory, so the checks cost nothing per request. A handler that would otherwise stay shadowed becomes a failure at deploy time, which is much easier to find than a stray 404 in production.
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;
}With that map, the router searches only the records for one method. It also makes the 405 case easier, since the server can ask which methods a given path supports. A plain array scan reads better while the table is small. Once the table grows, grouping by method is usually the first optimization you reach for.
Exact paths are straightforward. Dynamic segments bring precedence questions, and a wildcard goes further still.
const assets = route('GET', '/assets/*', serveAsset);A wildcard path routes many runtime paths into one handler. That fits static files and catch-all handlers, and it also raises the odds of an accidental match. In a small table, keep wildcard records near the end and read them as broad matches. Chapter 10 comes back to static files and streaming bodies, so the route-level view is all you need right now.
Match on the parsed pathname the URL implementation gives you. The raw socket bytes stay below the HTTP parser, which hands up the request target as text. URL splits that text into pathname and search, and URLPattern matches against the URL parts. When a handler needs a decoded route value, decode the captured parameter yourself and catch the URIError that bad input can throw. Roll your own matcher and you inherit every decision about percent-encoding, repeated slashes, trailing slashes, and case, and clients will start depending on whatever you pick.
Hand-rolled matching usually begins with pathname.split('/'). That holds up for a constrained internal tool. It turns into protocol behavior the moment encoded slashes, empty segments, trailing-slash rules, and unicode normalization show up. A router built on URLPattern hands the URL-pattern details to a runtime API that Chapter 8 already covered, and keeps the application policy in the route table.
Let route parameters leave the router as strings. :id captures text and nothing more. The handler is where you decide whether that text has to be a number, a UUID, a slug, a database key, or something else, and where you convert and validate it, or in route-specific middleware. That keeps routing and domain validation as separate concerns.
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, and the handler decided what counts as a valid id. Richer validation shows up in later chapters. For now the router finds a handler and prepares its input.
A linear scan is fine for a small server. It reads clearly, and it runs in O(number of routes) on every request. With a dozen records that cost disappears under the application work. Once the table reaches hundreds or thousands of records, move to method buckets, prefix grouping, trie-style path lookup, or framework code that already solves lookup.
Response Branches
Routing ends in one of three ordinary outcomes.
A route matches both the method and the path, so its handler runs.
The path exists for some other method but not this one. That case is a 405 Method Not Allowed.
Nothing matches the path at all, which gives back a 404 Not Found.
The 404 path writes a response for a target the router does not recognize. A 405 says the target exists but the method is not one it supports, and that response should carry an Allow header listing the methods that are. The Allow header is part of the HTTP contract, and you can set it without a framework.
The distinction is small but it carries information. GET /missing tells the client the router has no such target. DELETE /users/42 can instead mean the target is real and only the method is wrong. Clients and tests can branch on the two. A path typo surfaces as a 404, and a wrong method surfaces as a 405 with the allowed methods attached.
HEAD needs a rule you state on purpose. One approach routes HEAD /x through the same metadata as GET /x and drops the body before sending. Another registers separate HEAD handlers. Make the choice in the route table itself. A silent fall-through from HEAD to GET can catch out handlers that always write a body.
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 request method and asks only whether the path belongs to any route. When it does, the resulting set is the Allow value for the 405.
Keep response finalization in a helper so every branch writes responses the same way.
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 the status, the content type, and the content length, then ends the response. response.end() is the last write for this exchange. Once it has run, the rest of your code should treat the response as finished.
The fallback code can now be written out plainly.
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');
}Call a function terminal when it finishes the response for its branch. miss() is terminal because it ends in send(). A matched route handler is terminal as well, and so is any middleware that rejects a request early. The rule that follows is simple, once a branch has written the final response, every other branch has to stop.
This is easier to keep straight when every helper returns right after it writes.
function requireGet(ctx, next) {
if (ctx.req.method === 'GET') return next();
return send(ctx.res, 405, 'method not allowed\n');
}The middleware either calls next() or writes a response, and exactly one of those runs. Returning right after the write is the single habit that heads off most double-response bugs in raw code.
Double responses usually trace back to unclear control flow. One function writes 404, another keeps going and tries to write 200, and Node reports a late header mutation or a write-after-end. The fix lives in the control flow. Return after a terminal write, await the handler that is responsible for the response, and keep the fallback as a genuine branch rather than a fall-through.
Header commit points make these mistakes surface fast. response.writeHead(), response.flushHeaders(), response.write(), and response.end() each commit the response metadata. After that commit, a call to setHeader() is a late mutation that does nothing. A raw router should hold off writing bytes until it knows the final branch, which keeps the status and headers editable while middleware is still deciding.
writeHead(), flushHeaders(), write(), and end() each commit the status line and the headers. After any one of them runs, setHeader() and res.statusCode no longer change what the client receives. Do every validation, body parse, and status decision before that first write. A failure that arrives after it can no longer be turned into a clean error response.
Status defaults hide bugs here too. ServerResponse begins with a success status until your code changes it, so a handler that calls res.end('missing') without touching statusCode sends a response that looks like success. Set the status at the same spot where you pick the branch. A reviewer should be able to see the success, fallback, and error paths at a glance.
JSON responses 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 handles the JSON response headers and the finalization, while route handlers still pick the status and the value. Repeat that setup in every handler instead, and sooner or later one branch forgets the content type, the 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);
}After await hit.route.handler(ctx), the matched route has written the response. When no route matches, the fallback writes it instead. The caller can catch errors from this function, but it must not write a success response once this function has returned.
Middleware as Ordered Functions
Middleware is the code that runs before or around a terminal handler. Each piece receives the per-request state and a continuation function. From there it can attach data to the request context, reject the request early, call the next function in line, or do cleanup after that next function returns.
Those functions run as an ordered list, and the order changes the behavior. If the router reads ctx.url, the URL parser has to run before dispatch. If a route handler reads ctx.body, the body parser has to run before that handler. Timing middleware is the case that runs on both sides, because it awaits the next function and then sees the finished request once control comes back.
The smallest composition function that does the job is short.
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 a single async function that begins at index zero. Each middleware gets ctx and next. Calling await next() runs the one after it, and returning without calling next() ends the chain there.
The index guard catches a specific control-flow bug. next() is the continuation for one position in the chain, so calling it twice runs the later middleware, or the terminal handler, a second time. That tends to produce two writes for a single response. The guard converts the mistake into a thrown error close to where it happened.
The chain is ordinary promise sequencing. Middleware zero runs up to its await next(), which lets middleware one run up to its own await next(), and so on until the terminal handler writes or throws. Control then unwinds back out through each middleware that was waiting on next(). Whatever you put after await next() runs in reverse order of entry, and that is where cleanup goes.
async function withCleanup(ctx, next) {
try {
await next();
} finally {
ctx.state.finishedAt = Date.now();
}
}The finally block runs whether downstream code resolves or rejects, and it runs after a terminal handler has written its response. That makes the cleanup reliable at the level of JavaScript control flow. Whether the bytes actually reached the client is a separate question that depends on the response and socket state.
With compose in place, the server runs 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() listener stays thin. It builds a per-request context object, passes it into the app, and catches any promise the app leaves rejected. That catch is the last thing standing between a thrown error and a socket that hangs open.
Individual middleware can be very small.
async function withUrl(ctx, next) {
ctx.url = requestUrl(ctx.req);
ctx.query = ctx.url.searchParams;
await next();
}That function parses the request URL one time and stores both the URL object and its query parameters. Later code reads ctx.url.pathname and ctx.query. The route handler gets the parsed target handed to it instead of parsing req.url again.
A request-id middleware can attach per-request state without reaching for a global.
let nextId = 0;
async function withRequestId(ctx, next) {
ctx.requestId = `req-${++nextId}`;
await next();
}The counter is process-wide, while each generated id belongs to one request. In production those ids often come from an incoming header or a dedicated generator, but the structure is the same. Middleware writes data onto ctx, later code reads it back, and no handler has to reach into a hidden global for request data.
Short-circuiting the chain is a plain 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. A request that satisfies it continues down the chain, and a request that fails gets its response written right there and returns, so the body parsing and route handling below never run for it.
Getting the middleware order wrong is a common way to introduce bugs.
const app = compose([
withJsonBody,
withUrl,
dispatchRoute,
]);That stack parses the body before it parses the URL. For a JSON-only API that might be acceptable. The cost is that it can reject a large body before it ever discovers the path has no route. When you want route selection to happen before body work, put withUrl and dispatch ahead of route-specific body parsing. The chain gives you full control, which is the same thing as saying the wrong order is on you.
The bugs that show up in middleware are almost all control-flow bugs, and a handful recur. A missing await next() stops the request early. A next() call made after the response has already gone out lets a later handler write a second response. Async work that nobody awaited will fail silently, since the outer catch never sees its rejection. Shared mutable state is the last one, where two in-flight requests overwrite and then read each other's data.
The composed function runs 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 listener sees only the one promise that app(ctx) returns, and anything started outside that promise needs an error policy of its own.
The same model explains how errors propagate. An error thrown before await next() rejects the composed app right away. One thrown deeper down rejects back out through every await next() above it. Any upstream middleware along the way can catch it and either write an error response and return, or 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 attaches route context and leaves the top-level handler to decide how the response gets written. Writing responses in one place keeps the surprises out of individual branches.
The chain has no scheduler of its own, only promise sequencing. When middleware awaits next(), control moves into the next function and comes back when that work resolves or rejects. If downstream code wrote the response, the upstream code picks up after that branch is done. That is why cleanup, timing, and logging tend to live 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 line runs after the downstream middleware and the terminal handler have finished. It sees the route path and the timing without ever writing to 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 layered in. Node calls the listener, the listener calls your composed app, the app calls middleware zero. From there each middleware chooses whether to call the next, until some function writes to ServerResponse. Errors travel back out along the same promise chain until a catch handles them.
Running alongside that control flow is the response object, which carries its own state, headers pending or already sent, the body writable or ended, the socket open or closed. The two can drift apart. A function might return without ending the response, or end the response and keep running, and a rejected promise might land after the headers are already on the wire. Part of writing raw middleware is keeping the control flow and the response state agreeing with each other.
The request object carries state as well. Its body stream can be unread, partially read, complete, destroyed, or closed. Middleware that reads the body changes what any later middleware can read. Once a JSON parser has consumed the stream, a proxy handler further down cannot forward the original body upstream, because those bytes already went through the parser. That is one reason to parse bodies per route rather than globally.
Order also decides which middleware gets to consume the data. Reading the URL is harmless, since it only touches a string already sitting on the request, and so is inspecting parsed header values. Body parsing consumes the stream, response helpers commit the output state, and error middleware can tear down the request. Keep those side effects close to the route that needs them.
Taken together the pattern stays small. Terminal handlers return after they end the response. Middleware either ends early and returns, or awaits next(). The listener catches errors and checks the response state before it writes, body parsing sets a limit before it accumulates bytes, and per-request data rides on ctx.
Bounded Body Collection
Node's HTTP parser works out where the body begins and how it is framed. What the body means is still up to your code. A body parser is the piece that reads the request stream and turns those bytes into an application value. The JSON version collects the bytes, decodes them as text, runs JSON.parse(), and leaves the result somewhere the handler can read it.
The unsafe version is the one you see most often.
async function rawBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks);
}That code accepts a body of any size. A client can send far more data than the process should ever hold in memory. Stream backpressure controls the read rate, but it does not cap total memory, and that cap is something you have to set. The parser counts bytes as it reads and rejects the body once it crosses the maximum you configured.
This collector sets no limit. It buffers the entire request body into memory regardless of how large the request is. A handful of large or slow uploads at once can exhaust the heap and bring the process down. Count bytes inside the read loop and stop before Buffer.concat() ever runs. Do not rely on Content-Length for the cap either. The peer sends that header, it can be absent under chunked encoding, and it can claim fewer bytes than actually arrive.
The memory pressure builds before the handler ever sees a parsed object. Each chunk pushed into chunks stays referenced until Buffer.concat() completes, so a thousand concurrent uploads can pin a thousand partial bodies at once. The check belongs inside the collection loop, because checking after Buffer.concat() means the process already took on the allocation risk.
A declared length helps, but it cannot stand in for counting bytes. Content-Length is a value the peer sent. It can be missing, malformed, or smaller than the number of bytes that actually arrive before the parser notices the framing error. A chunked body has no single declared length at all. The byte counter in the stream is the one enforcement point your application fully controls.
function httpError(status, message) {
const err = new Error(message);
err.status = status;
return err;
}A small raw server can pin an HTTP status onto an ordinary error. Chapter 27 goes deep on error taxonomy. The goal here is narrower, 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 makes one decision per chunk. It works out the new byte count, rejects the body if that count crosses the limit, stores the chunk otherwise, and returns the running 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 what happens when the limit trips. It throws while unread bytes may still be sitting on the socket. To keep the connection alive you would have to drain or discard the rest of the body safely first. A small raw server can take the simpler route. Answer 413, set Connection: close, and let the socket close once the response is written. Dropping the connection is a cost a small server can afford.
An early length check still buys you a 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 rather than zero-or-huge. The stream counter still runs on every body regardless. HTTP parsing rules should already have rejected a truly invalid length before the request reaches your handler, but your code should still not treat a strange header value as a trustworthy number.
A JSON request body comes with two claims you have to check. Content-Type is what the sender says it sent. The bytes themselves still have to decode as UTF-8 and parse as JSON. Check the media type first, then parse the bytes, and handle the case where parsing fails.
function contentType(req) {
const value = req.headers['content-type'];
return String(value ?? '').split(';', 1)[0].trim().toLowerCase();
}The helper drops parameters such as charset=utf-8 and lowercases the result. It covers the ordinary application/json; charset=utf-8 case without turning into 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 turns bad input into HTTP statuses. A wrong media type comes back as 415 Unsupported Media Type. A body over the limit is a 413 Payload Too Large. Malformed UTF-8 and invalid JSON both land on 400 Bad Request. On success the handler gets parsed data, and formatting the response is left to the shared error path.
Empty bodies need a rule of their own, because JSON.parse('') throws. A route that requires a JSON object should treat an empty body as a bad request. A route where the body is optional can have the parser map an empty completed body to undefined and let the handler take it from there. Decide that in the parser or in route middleware, so the handler always knows whether ctx.body is guaranteed to exist.
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.
Parsing per route also skips work on paths that are going to reject anyway. A global JSON parser runs before routing unless you deliberately place it after route selection, so a GET /missing carrying a huge body can burn memory before the server even finds out the route does not exist. A route-local parser only runs once the method and path have already picked a route that wants 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 took a single handler. A slightly richer version can take several and compose them when the route matches.
function route(method, pathname, ...stack) {
return {
method,
pattern: new URLPattern({ pathname }),
handler: compose(stack),
};
}With that, POST /users runs jsonBody(1_000_000) and then createUser, while GET /users runs only listUsers.
There is one more way body collection fails. The client can close the connection before the full body arrives. When that happens, the request stream either errors out or ends with message.complete still false, and the application never got a complete JSON document. Treat it as bad input or as an aborted request, depending on how far the response has already gotten.
async function collectComplete(req, limit) {
const body = await collect(req, limit);
if (!req.complete) throw httpError(400, 'incomplete body');
return body;
}message.complete is a property of the request object that reports whether the full HTTP message arrived. For a body parser it is the check to make after the stream is consumed. An ordinary empty body still counts as complete, whereas an aborted upload leaves the message incomplete.
Content-Length lets you reject some requests before any body allocation. When the header claims a body larger than your limit, the server can answer 413 straight away. A client can still lie about it, leave it off, or use chunked transfer coding, which is why the streaming byte counter stays 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 is the early exit, readJson() holds the byte limit while reading, and requireObject() makes sure the handler gets an object. The work is ordered cheap to expensive, the header check first, then the bounded read, then the JSON parse, then the structure check, and only then the downstream handler.
The hard part is reusing the connection after a rejection. If the application stops reading the body and leaves unread bytes on a persistent connection, the next HTTP message on that socket is ambiguous. For a small raw handler, the simplest policy is to send the error with Connection: close when the limit trips. A more advanced server can drain and discard the rest of the body up to a second limit and then keep the connection reusable. That work belongs in framework or infrastructure code once it has to handle uploads, streaming proxy requests, slow clients, and observability.
Keep the raw parser narrow. JSON only, a limit on every read, unknown media types rejected, parse errors turned into client errors, and the connection closed on a limit failure unless you have chosen to drain it. Leave multipart uploads and streaming request bodies to code built around streaming.
Error Flow in Raw Handlers
A raw request can fail in two broad places. One is the HTTP layer, before your route handler ever runs, and subchapters 2 and 3 covered parser errors and clientError there. The other is the application layer, after your listener has started. This section is about the second one.
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 leaves without writing a response. The top-level catch is where that error becomes a single 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 guards against late errors. response.headersSent tells you the metadata is already committed, and response.writableEnded tells you the writable side is closed. At either point a fresh error response would corrupt the exchange, so the code destroys the request instead of pretending it can still send clean HTTP.
For errors that arrive before the response is committed, the function takes the status from err.status and falls back to 500. A body-limit failure also sets Connection: close, since the server rejected the body stream and should not hold that connection for another request.
Error bodies deserve the same consistency as success bodies. The small fail() helper writes plain text because the earlier snippets used send(). A JSON API would call json() here instead. The point is that it is centralized. Keep the status mapping and the response format in one error path, so route handlers throw local errors and the outer layer turns 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, and it adds a single policy, that errors come back as JSON objects. Chapter 12 covers 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 both middleware and terminal handlers. It also catches synchronous throws, because an async function turns a throw into a rejected promise. Anything started outside the awaited promise needs an error policy of its own.
The outer catch also keeps the request from hanging. An uncaught throw leaves the composed promise rejected, and by then the listener has already returned to Node. Node has no application-level response to send on your behalf. With the response never written, the client waits until a timeout or the socket closes. The catch is part of the server structure, not an optional extra.
Fire-and-forget work can slip out of 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 work the request depends on, await it or attach an error handler that has its own policy. Raw middleware gives you no hidden supervisor.
The listener's app(ctx).catch() watches only the one promise it awaits. A call made without await has nowhere for its rejection to go. Worse, if that call closed over ctx, it pins req, res, and the body buffer in memory long after the response finished. Await any work that belongs to the request, or give background work its own error handler and copy out only the plain values it needs.
Detached work should copy out only the state it needs. Handing the whole ctx to a background operation keeps req, res, the body buffers, and the route data referenced longer than the request needs them, which keeps that memory reachable after the response is done. Pass the 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.
Handling a closed connection belongs right next to your async work. A client can disconnect while the route is still waiting on a database call or an upstream fetch. Request completion and response closure are two separate states. In a raw handler, track both and check them before you write anything 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 any downstream work that accepts cancellation, such as fetch() or a database client with abort support.
While debugging, keep input failures and application failures apart. A request stream error points at the input or the socket. An application error means your code failed while handling a request object that was perfectly valid. Map both to 500 and you lose the source. The raw server can send 400 for body parse errors, 415 for media-type errors, 413 for body limits, and 500 for unknown application failures. That much status mapping is enough to keep sockets from hanging and status codes from lying.
The ugly case is a late error after part of the response already went out. Say a route streams the first 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 let the client see a truncated body, but it cannot swap in a clean JSON error. Raw routing code should hold off committing the response until it has enough to choose a status, or commit to streaming semantics where a mid-body failure is part of the protocol design.
The same rule covers middleware that calls res.write() early. That write commits the headers, and any validation that fails afterward can no longer change the status. In raw code, do the validation and body parsing before the first write. Streaming responses are a separate design, covered later alongside proxies and streaming bodies.
Per-Request State
The context object is the simplest place to keep state in 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 the header setup.
async function createUser(ctx) {
const user = await saveUser(ctx.body);
json(ctx.res, 201, { id: user.id });
}Raw HTTP is still underneath all of this. The helper only centralizes the response formatting. Request validation, resource modeling, authorization, CORS, and contract documentation all come later.
The context object is also what keeps request state out of globals. A module-level currentUser, currentRequest, or currentParams breaks the moment two requests overlap. Node interleaves many request handlers on one event loop, so while one handler is awaiting I/O, another runs. A shared mutable variable becomes cross-request state unless it is carefully scoped and protected.
A module-scoped currentUser or currentRequest breaks under real traffic. One handler hits an await for I/O, a second handler runs and overwrites that shared variable, and now both read each other's data. Keep every per-request value on the ctx object that travels down the chain, and never on a variable two requests can share.
A 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 ctx.state.metrics. For a small raw server, plain properties such as ctx.url, ctx.params, and ctx.body work just as well. Either way, request data travels with the request.
Name collisions are the cost of a plain object. Two middleware functions can both write ctx.user and clobber each other. At a small scale, naming discipline is enough to avoid it. As the server grows, you usually want a convention like ctx.state.auth, ctx.state.route, and ctx.state.metrics, or symbols for fields a library controls. Frameworks tend to formalize this, because middleware from separate packages has to share one context without stepping on each other.
The context should also live only as long as the request. Do not cache it once the response is finished. ctx.req and ctx.res hold references into stream and socket state, and ctx.body can hold the entire parsed request body. A background queue or cache that holds onto the context keeps all of that alive with it. Copy out the small values you need for later work and let the context get 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.
Data derived from headers needs care. A request-id header, a forwarded-address header, or a host header is controlled by the client unless a trusted proxy or platform contract says otherwise. In this raw router those values can pass through as plain strings. Later security and platform chapters set the trust rules. Do not build authorization, tenancy, or redirect policy on a raw header just because middleware made it easy to read.
Host, X-Forwarded-For, forwarded-proto, and request-id headers can all be forged by the caller unless a trusted proxy rewrites them first. Do not key authorization, tenant routing, or redirect targets off those values in the raw layer. And the http://localhost base you hand to URL is only a parsing placeholder, so ctx.url.host always reads localhost and tells you nothing about the real origin.
The same goes for parsed bodies. ctx.body is parsed JSON and nothing more, and business validation stays separate from it. A JSON object can be missing fields, carry extra ones, use the wrong types, or hold strings that break assumptions further down. Chapter 12 covers schema validation and API contracts. The parser here only turns bytes into a JavaScript value under a byte limit.
Wiring the Small Router Together
At this point the whole server fits in a form you can read top to bottom.
const app = compose([
withCloseState,
withUrl,
dispatchRoute,
]);The app records close state, parses the URL, and dispatches routes, and the 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 what the server exposes at a glance, the method, the path, the middleware, and the terminal handler, with no hidden registry behind it.
The listener builds the context and holds 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 working framework-free HTTP handler. It routes by method and path, captures route parameters, and keeps query parameters separate from them. It runs middleware in order, returns 404 and 405 as distinct cases, and parses bounded JSON bodies. It catches rejected promises, and it never writes a second response once the headers are committed.
What you gain is how visible each responsibility is. findRoute() does the matching, miss() writes the fallback responses, collect() enforces the body limit, fail() converts errors, send() and json() format responses, and per-request state lives on ctx.
That visibility pays off most on small servers. A test can call findRoute() directly, hand the body parser a fake readable stream, or pass middleware a fake context. Route handlers can even run with no network socket at all when the response helper is abstracted or mocked.
Raw code also makes the remaining responsibilities easy to see.
Validation here stops at JSON syntax, and route-level schemas live somewhere else. Content negotiation needs a policy of its own. Authentication and authorization need their own layer. CORS, rate limiting, structured error responses, observability context, and lifecycle hooks each need a clear place to live.
That is the line where a raw exercise starts turning into a custom framework project. As you push the line outward, the maintenance cost grows with it, usually sooner than the code makes obvious.
For a small internal handler, a test server, a local webhook receiver, or a service with a narrow surface, leaving those out is fine. They turn into operational debt as the API grows.
Route ordering is usually the first strain. A broad pattern can match paths that later, more specific routes were meant to handle. A raw router can document the order and test the table, but the lookup rules get more involved as optional segments, wildcards, mounted prefixes, and versioned paths enter the picture.
Mounted prefixes are where this shows up first. Paths like /api/users, /admin/users, and /internal/users can share handler pieces while needing different middleware. A raw route table can encode that with path prefixes and repeated stacks. For five routes the repetition is tolerable. It gets noisy once every route needs logging, body parsing, auth, metrics, and feature flags in slightly different combinations.
Route generation pushes in the same direction. Once routes need names for URL building, documentation, tests, and client SDKs, the route table turns into a source of truth. A hand-built array can still serve that role, but the tooling around it grows. That is the point where frameworks and API contract tools start to earn their place.
Body parsing is the next area to strain. JSON under a byte limit stays manageable. Multipart forms, compressed bodies, streaming uploads, partial reads, and proxied request bodies all need more machinery. A body parser that began as nine lines turns into a fragile protocol edge once it has to handle every client behavior.
Streaming bodies change the design outright. A route that proxies an upload should not pull the whole body into memory first. One that verifies a signature may need the raw bytes before any JSON parsing. One that accepts compressed input has to decide where decompression happens and which byte limit applies on each side of it. The JSON parser in this chapter keeps a narrow contract on purpose.
Lifecycle hooks are a third area. Frameworks give you defined places to run code, before parsing, before and after validation, before and after the response, and on error. A hand-built chain can stand in for some of that, but each new hook needs a convention agreed up front. Without one, every route grows its own ad-hoc lifecycle.
Observability follows the same curve. A small server can log the 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 carried through downstream async work. Raw middleware can begin that, but the larger observability context is Chapter 29's subject.
API contract work is a fourth area. REST resource modeling, OpenAPI, JSON Schema, and schema validation libraries are Chapter 12's territory. A raw router can call a validation function. The API design framework work around it stays separate.
Security policy is the last of these. Authentication, authorization, CORS, rate limiting, proxy trust, and request normalization all touch routing and middleware order. A raw handler can hold those checks, but each one needs a precise home. Chapters 24 and 25 set those policies. The raw server here only leaves clear places to attach them.
Use the raw router 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 of your attention than the handler logic does. The Node path underneath does not change either way. You still have a parsed request object, a writable response object, ordered userland code, and one final response per exchange.