Node.js & APIs · 18 min · 150 XP
Middleware and one error boundary
Build the request pipeline as small functions that wrap each other, with one boundary that turns every failure into a safe response.
Most of what a server does for every request has nothing to do with any one route: give the request an id, log it, check the session, parse the body, time it, and catch whatever goes wrong. Writing that into each handler means writing it forty times and forgetting it once. Middleware is the pattern that pulls it out: a function that receives the request and a next function, and decides whether to call the rest of the pipeline.
async function timing(request, next) {
const started = Date.now();
const response = await next(); // everything after this one in the pipeline
console.log(request.id, response.status, Date.now() - started + "ms");
return response;
}
async function requireUser(request, next) {
if (!request.user) return json(401, { error: "Sign in first" }); // short-circuit
return next();
}Because each middleware wraps everything after it, order is behaviour. The pipeline runs like nested function calls: the first middleware's code before next() runs first, and its code after next() runs last. Put authentication before the handler and it protects the handler; put it after and it protects nothing. Express, Koa, Fastify's hooks and Hono all differ in syntax, but every one of them is this idea.
The outermost layer should be an error boundary: one place that catches anything thrown below it and turns it into a response. Two kinds of error reach it. Expected errors, such as a missing order or a forbidden action, carry a status and a message written for the client, so throw them as a small HttpError class. Unexpected errors, such as a dropped database connection or a bug, carry messages written for you, full of hostnames, SQL and stack frames. Those become a plain 500 with the request id, and the details go to your logs instead of the client.
return await next(), not return next(), inside a try. Without the await, the function hands back the promise before it has failed, the try block has already finished, and the rejection sails straight past your catch. This one keyword is the most common reason an error boundary silently doesn't work.
Loading your workspace…