Course outline · 0% complete

0/29 lessons0%

Course overview →

Middleware: The Pipeline

lesson 5-2 · ~13 min · 15/29

One idea runs all of Express

Middleware is a function that sees the request before your route handler does. Each middleware can inspect the request, change it, end the response early, or pass control onward by calling next():

app.use((req, res, next) => {
  console.log(req.method + " " + req.url);
  next(); // hand over to the next function in line
});

Requests flow through the middleware in registration order, forming a pipeline: maybe a logger, then a body parser (express.json()), then an auth check, and finally your handler. Each stage decides: pass it on, or stop the chain.

Almost everything in the Express ecosystem, logging, sessions, CORS (cross-origin rules, defined below), rate limiting, auth, is packaged as middleware. Understand the pipeline and you understand the framework.

loggernext()authnext() or stophandlerres.json(...)the request flows left to right, any stage may end it early
The middleware pipeline. Each stage receives the request and either calls next() or ends the response itself.

A fifteen-line middleware engine

This is the actual mechanism inside Express. next advances an index through the middleware list, then hands off to the handler.

function logger(req, next) {
  console.log("logger: " + req.method + " " + req.path);
  next();
}

function auth(req, next) {
  console.log("auth: token looks fine");
  next();
}

function handler(req) {
  console.log("handler: sending admin data");
}

function run(middlewares, handler, req) {
  let i = 0;
  function next() {
    if (i < middlewares.length) {
      const mw = middlewares[i];
      i++;
      mw(req, next);
    } else {
      handler(req);
    }
  }
  next();
}

run([logger, auth], handler, { method: "GET", path: "/admin" });

Output

logger: GET /admin
auth: token looks fine
handler: sending admin data

Tracing it is the exercise: next() runs logger, which calls next(), which runs auth, which calls next(), and the list is now empty so the handler runs. Control passes forward one function at a time, driven entirely by those calls.

The shared i is what makes it work, since every middleware receives the same next function and that function closes over one counter. Incrementing before the call is deliberate, because incrementing after would let a middleware that calls next() twice run the same stage again.

The else branch treating the handler as the end of the line explains why a route handler looks like middleware without a next. It is the last stage, so there is nothing to advance to.

Nothing here is asynchronous, and real middleware often is. The engine still works unchanged, because a middleware that calls next() inside a callback simply resumes the pipeline later, which is how body parsers wait for the full request before continuing.

Note that no middleware knows what comes after it. logger calls next without any idea whether auth exists, which is what lets the same middleware be reused in any pipeline and in any position.

A middleware that neither continues nor responds

The client experiences the request hanging until it gives up waiting.

Nothing moves the pipeline except next() or a response. A middleware that does neither leaves the request stuck, and the client spins until its timeout fires.

The server side of this is quietly bad too. No error is logged, no exception is thrown, and the request stays in memory holding its socket, so a route with this bug slowly accumulates stuck connections.

This is one of the most common Express bugs in the wild, and it almost always hides in an if branch. The happy path calls next(), some early-exit branch forgets to, and the failure only appears for the inputs that take that branch.

Middleware ends byResult
calling next()the next stage runs
sending a responsethe client gets an answer, pipeline stops
calling next(err)Express jumps to the error handler
doing neitherthe request hangs

The habit that prevents it is to make every branch end in exactly one of the first three. Reading a middleware and checking that each path either responds or continues takes seconds and catches the bug before it ships.

A gate that can stop the chain

auth now blocks unless the token matches, so the second run never reaches the handler.

function logger(req, next) {
  console.log("logger: " + req.method + " " + req.path);
  next();
}

function auth(req, next) {
  if (req.token === "secret") {
    console.log("auth: ok");
    next();
  } else {
    console.log("auth: blocked");
  }
}

function handler(req) {
  console.log("handler: sending admin data");
}

function run(middlewares, handler, req) {
  let i = 0;
  function next() {
    if (i < middlewares.length) {
      const mw = middlewares[i];
      i++;
      mw(req, next);
    } else {
      handler(req);
    }
  }
  next();
}

run([logger, auth], handler, { method: "GET", path: "/admin", token: "secret" });
run([logger, auth], handler, { method: "GET", path: "/admin", token: "wrong" });

Output

logger: GET /admin
auth: ok
handler: sending admin data
logger: GET /admin
auth: blocked

An if/else on req.token === "secret" is all that is needed inside auth. Blocking the chain means simply not calling next(), and the handler never runs as a consequence rather than because anything told it not to.

Five lines of output for two runs is the visible proof. The blocked run produces two lines instead of three, and the missing line is the handler that was never reached.

In real Express you would also send res.status(401).json(...) when blocking, and here the print stands in for it. That is the difference between blocking correctly and causing the hang from the previous block, since a blocked request still owes the client an answer.

The ordering in the array is what makes this a gate at all. logger runs before auth, so blocked requests are still logged, and swapping them would hide every rejected request from the log.

This is also the shape of every authentication middleware you will write. It inspects the request, attaches something useful such as req.user on success, and either continues or answers with 401, which is exactly what unit 7 builds on real tokens.

One middleware you will meet in week one: CORS

Browsers enforce the same-origin policy. By default, JavaScript on one origin (the scheme, domain, and port combination, like https://myapp.com or http://localhost:5173) may not read responses it fetches from a different origin.

The rule exists because you are logged into many sites at once. Without it, any malicious page you visit could silently call your bank's API using your cookies, the small values a site stores in your browser and the browser automatically re-sends with every request to that site, and read the answer. Cookies are how staying logged in works, as unit 7 will show.

That same rule blocks the legitimate case. A frontend served from http://localhost:5173 calling your API on http://localhost:3000 is cross-origin, because the ports differ and that alone makes a different origin.

CORS (Cross-Origin Resource Sharing) is the opt-in mechanism the server uses to relax the policy. It adds a response header such as Access-Control-Allow-Origin: http://localhost:5173, which tells the browser that pages from that origin may read its responses.

In Express it is one line of middleware, app.use(cors({ origin: "http://localhost:5173" })) from the cors package, which is why it appeared in the middleware list above. It goes near the top of the stack, since a response that never reaches the header-setting middleware never gets the header.

Note carefully where the enforcement lives, which is in the browser. Command-line tools and other servers ignore CORS entirely, so it is not an access-control mechanism for your API. Authentication protects data, and CORS only governs what web pages are allowed to read.

Where a CORS fix belongs

The API server must send the Access-Control-Allow-Origin response header, for example via cors() middleware.

Access-Control-Allow-Origin is a permission the server grants on its response, and a client cannot grant itself permission, since that would defeat the policy's purpose. Nothing you change in the frontend code can make the browser relax the rule.

The two localhost URLs differ in port, so they are different origins and the browser demands the header. Same host and same scheme are not enough, because an origin is the scheme, host, and port together.

One cors() middleware line on the API fixes it, and in production you list your real frontend origin instead of allowing everyone. A wildcard is convenient in development and tells every site on the internet that its pages may read your responses.

The error message is misleading in a specific way worth knowing. The request usually reached your server and was answered normally, and the browser then refused to hand the response to the page, so your server logs show a successful request while the console shows a failure.

Note that CORS is enforced in the browser only, so curl and other servers ignore it entirely. It is not access control for your API, and authentication is what protects data while CORS only governs what web pages may read.