Home
Backend from First Principles / Module 7 — Middleware

Middleware

The chain of responsibility. Order matters. Single-responsibility middleware.


The work that every request needs

Look at any real handler and strip it down to the line that actually matters. A getOrder handler's real job is one database query. But before that query can run, a pile of identical work has to happen: the request has to be logged, the user has to be authenticated, the body has to be parsed, maybe a rate limit has to be checked. And getUser, getProduct, listInvoices — every other handler — needs that exact same pile.

You could copy that work into all forty handlers. Then you change the logging format and you are editing forty files. Middleware is the answer: functions that run around your handlers, in a chain, so the shared work is written once and applied everywhere.

A middleware function sees the request before the handler does, can act on it, and then either passes control onward or stops the chain. The router picks which handler runs; middleware decides what happens on the way there and back.


The onion model

The mental model that makes middleware click is an onion. The handler is the core. Each middleware is a layer wrapped around it. A request travels inward through every layer to reach the handler, then the response travels outward through the same layers in reverse.

Text
   request ──►┌─────────────────────────────┐
              │  logging                    │
              │ ┌─────────────────────────┐ │
              │ │  authentication         │ │
              │ │ ┌─────────────────────┐ │ │
              │ │ │  body parsing       │ │ │
              │ │ │   ┌─────────────┐   │ │ │
              │ │ │   │  HANDLER    │   │ │ │
              │ │ │   └─────────────┘   │ │ │
              │ │ └─────────────────────┘ │ │
              │ └─────────────────────────┘ │
              └─────────────────────────────┘
   response ◄── (unwinds through the same layers)

That "and back out again" half is the part people miss. A middleware can run code after the handler finishes, on the way out:

JavaScript
function timing(req, res, next) {
  const start = Date.now();
  next();                              // go inward — run everything below
  // control returns HERE on the way back out
  console.log(`${req.method} ${req.url} took ${Date.now() - start}ms`);
}

The next() call is the hinge. Code before it runs on the way in; code after it runs on the way out. Response-shaping, timing, and final logging all live after next(). (In async frameworks you await next() instead, but the inward/outward shape is identical.)


Why order is everything

Middleware runs in the order you register it. That ordering is not a detail — it is correctness. Get it wrong and things break in ways that look like unrelated bugs.

JavaScript
app.use(requestLogger);     // 1. log every request (even rejected ones)
app.use(rateLimiter);       // 2. reject floods before doing real work
app.use(authenticate);      // 3. identify the user
app.use(bodyParser);        // 4. parse JSON body
app.use(router);            // 5. finally, the matched handler

Walk through why this specific order:

Logging first so that even requests rejected by later layers still appear in your logs. Put it after the rate limiter and your logs go silent during exactly the traffic spike you most need to see.

Rate limiting before authentication because checking a token costs a database or cache lookup. If you authenticate first, an attacker flooding you with requests makes you do that expensive work millions of times before you reject them. Rate-limit on the cheap signal (IP) first.

Authentication before body parsing because parsing an unbounded request body costs memory and CPU. Reject the unauthenticated request before you spend that.

The classic order bug: putting authentication after the router. Now the route handler runs, queries the database, then the auth middleware would have rejected the request — except it never gets the chance, because the handler already responded. Unauthenticated users hit your database. Auth that runs too late is not auth.


Short-circuiting — when middleware says no

A middleware does not have to call next(). If it sends a response itself and skips next(), the chain stops dead — nothing inward of it runs, including the handler. This is short-circuiting, and it is the whole point of guard middleware.

JavaScript
function authenticate(req, res, next) {
  const token = req.headers.authorization;
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
    // NOTE: no next() — the chain ends here. Handler never runs.
  }
  const user = verifyToken(token);
  if (!user) {
    return res.status(401).json({ error: 'Invalid token' });
  }
  req.user = user;   // attach result for downstream layers
  next();            // token is good — continue inward
}

Two things in that example are worth copying as habits. First, every early exit uses returnreturn res.status(401).... Forget the return and execution falls through to next() anyway, so you both reject the request and run the handler, sending two responses. That throws "headers already sent" and is a genuinely confusing bug to chase.

Second, req.user = user. Middleware communicates with downstream layers by attaching data to the request object. The handler later reads req.user without knowing or caring how it got there. (The clean version of this pattern — request-scoped storage instead of mutating the request — is Module 8.)


Error-handling middleware

Normal middleware takes the request, the response, and next. Error-handling middleware takes one extra argument — the error — and frameworks treat that four-argument shape specially: it only runs when something upstream failed.

JavaScript
// Error handler — note the FOUR parameters. Register it LAST.
function errorHandler(err, req, res, next) {
  console.error(err.stack);
  const status = err.statusCode || 500;
  res.status(status).json({
    error: status === 500 ? 'Internal server error' : err.message
  });
}

app.use(router);
app.use(errorHandler);   // last in the chain

When any handler or middleware throws (or calls next(err)), the framework skips every remaining normal middleware and jumps straight to the error handler. This is what lets your handlers be written cleanly — they just throw on a problem instead of carrying response-shaping code:

JavaScript
async function getOrder(req, res) {
  const order = await db.orders.find(req.params.id);
  if (!order) throw new NotFoundError('Order not found');
  res.json(order);
  // No try/catch. The error middleware catches and shapes it.
}

One catch worth knowing: in callback-style frameworks, a throw inside an async function is not automatically caught — you have to next(err) or wrap the handler. Modern frameworks and Express 5 handle async throws natively. Check which world you are in. Error handling deep-dives in Module 14; the point here is structural — the error handler is a middleware, it has a distinct signature, and it goes last.


Writing clean middleware — and when not to reach for it

A few habits separate middleware that ages well from middleware that becomes a tangle.

One job per middleware. authenticate authenticates. It does not also log, also rate-limit, also parse. Small single-purpose layers can be reordered and reasoned about; a do-everything middleware cannot.

Make configurable middleware a factory. When a middleware needs options, write a function that returns the middleware:

JavaScript
function rateLimit(maxPerMinute) {
  return function (req, res, next) {     // the actual middleware
    if (tooMany(req.ip, maxPerMinute)) {
      return res.status(429).json({ error: 'Too many requests' });
    }
    next();
  };
}

app.use('/api',   rateLimit(100));   // generous for the API
app.use('/login', rateLimit(5));     // strict for login

Never block the event loop. A middleware runs on every request. A synchronous file read or a CPU-heavy loop inside one stalls every request behind it. Keep middleware async and light.

Now the honest part — middleware is not always the right tool. It earns its place when work is genuinely cross-cutting: needed by many or all routes. It is the wrong tool when:

A good middleware stack is short, ordered deliberately, and made entirely of cross-cutting concerns. When you find yourself adding a middleware that only one route cares about, that is the signal to put the code where it belongs instead.


Source & Credit

The Backend from First Principles series is based on what I learnt from Sriniously's YouTube playlist — a thoughtful, framework-agnostic walk through backend engineering. If this material helped you, please go check the original out: youtube.com/@Sriniously. The notes here are my own restatement for revisiting later.

⁂ Back to all modules