Skip to main content

πŸ”— Middleware Architecture and Flow

Middleware is the beating heart of every Express application. Once you understand how a request threads its way through a chain of small, focused functions β€” and how the humble next() call steers that journey β€” the rest of Express suddenly makes sense. This lesson builds that mental model piece by piece.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what middleware is and describe the (req, res, next) function signature
  • Trace how a request flows through the middleware chain and back out as a response
  • Distinguish the three behaviours of next(): next(), next('route'), and next(err)
  • Predict middleware execution order from the order of app.use() calls and mount paths
  • Write error-handling middleware using the four-argument signature

Estimated Time: 35–45 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a small Express app whose middleware chain logs, tags, and conditionally short-circuits requests.

In This Lesson

What Is Middleware?

Middleware is simply a function that sits in the middle of the journey between an incoming HTTP request and the response Express sends back. Each middleware function gets a chance to inspect the request, modify it, respond to it, or hand it along to the next function in line. That's the whole idea β€” Express is, at its core, little more than a pipeline of middleware functions run in order.

πŸ’‘ Assembly-line analogy: Picture a request as a product moving down a factory conveyor belt. Each middleware is a specialised station: one inspects the label (logging), one attaches a part (parses the body), one checks the product is authorised to continue (authentication). The product moves from station to station until it is finished (a response is sent) or pulled off the line for repair (an error handler).

Because every middleware sees the same req and res objects, they can cooperate: an early one can attach req.user and a later one can read it. In Express, a middleware function can do any of four things:

  • Execute arbitrary code (log, time, measure)
  • Modify the req and res objects (attach data, set headers)
  • End the request–response cycle by sending a response
  • Call next() to pass control to the next middleware in the stack

πŸ“– Key Terms

Middleware: a function with the signature (req, res, next) that runs during the request–response cycle.

The stack / chain: the ordered list of middleware Express runs for a given request.

Route handler: the final middleware in a route's chain β€” usually the one that actually sends the response.

The Middleware Signature

Every Express middleware function shares the same shape. It receives three parameters that give it full access to the request–response cycle:

// The canonical middleware signature
function logger(req, res, next) {
  // 1. Do work β€” here we log the method and URL
  console.log(`${req.method} ${req.originalUrl}`);

  // 2. Hand control to the next middleware in the chain
  next();
}
  • req β€” the request object: method, URL, headers, params, query, and (after parsing) body.
  • res β€” the response object: used to set status, headers, and send data back.
  • next β€” a function you call to pass control onward. Forget to call it (and don't send a response) and the request hangs forever.

There is a second, special signature reserved for error-handling middleware. It takes four arguments, and Express recognises it purely by that arity:

// Error-handling middleware β€” note the leading `err`
function errorHandler(err, req, res, next) {
  console.error(err.stack);
  res.status(500).json({ error: 'Something broke!' });
}

⚠️ The four-argument rule

Express decides a function is an error handler by counting its parameters. A function declared with four parameters is treated as error-handling middleware; three or fewer is treated as regular middleware. This means you must keep the unused next in the signature even if you never call it β€” dropping it turns your error handler back into ordinary middleware.

The Request–Response Flow

Let's trace an actual request through a small stack. A logger runs first, then a body parser, then an authentication check, and finally the route handler. If authentication fails, control jumps straight to the error handler instead.

flowchart LR A[HTTP Request] --> B[Logger] B --> C[Body Parser] C --> D[Auth Check] D --> E[Route Handler] E --> F[HTTP Response] B -.-> Z[Error Handler] C -.-> Z D -.->|"next(err)"| Z Z -.-> F

The same story told as a sequence of hand-offs between the participants:

sequenceDiagram participant Client participant Logger participant Parser as Body Parser participant Auth as Auth Check participant Route as Route Handler participant Err as Error Handler Client->>Logger: HTTP Request Logger->>Logger: log method & url Logger->>Parser: next() Parser->>Parser: parse req.body Parser->>Auth: next() alt Authenticated Auth->>Route: next() Route->>Client: res.json(...) else Not authenticated Auth->>Err: next(error) Err->>Client: 401 response end

Reading that flow in plain English:

  1. The client sends an HTTP request; Express hands it to the first middleware (the logger).
  2. The logger records the request details and calls next().
  3. The body parser turns the raw body into req.body and calls next().
  4. The auth middleware checks credentials. On success it calls next() to reach the route handler; on failure it calls next(error) to jump to the error handler.
  5. Whichever handler runs last sends a response, ending the cycle.

πŸ’‘ Real-world flow

When you open a page on a service like Netflix, your request passes through a stack that validates your session, loads your profile, checks your subscription, and only then reaches the handler that assembles your homepage. Each concern is one small middleware β€” easy to test, reorder, or reuse.

The Three Faces of next()

The next() function is the steering wheel of the middleware chain. Calling it with different arguments sends the request in different directions:

CallEffect
next()Run the next middleware in the current stack.
next('route')Skip the rest of this route's middleware and try the next matching route. (Only meaningful inside app.METHOD() / router handler stacks.)
next(err)Skip all remaining regular middleware and jump to the error-handling middleware.
The three behaviours of next() next() advances to the following middleware; next('route') skips to the next matching route; next(err) jumps to the error handler. next() mw 1 mw 2 handler next('route') route A mw skipped route B handler next(err) mw skipped error handler
Figure 1 β€” next() advances one step, next('route') skips to the next matching route, and next(err) diverts to the error handler.

Here is all three in a single route so you can see them side by side:

app.get('/example',
  (req, res, next) => {
    console.log('First middleware');
    if (req.query.skipRoute) return next('route');      // jump to the next /example route
    if (req.query.error)     return next(new Error('Boom')); // jump to the error handler
    next();                                              // continue to the next middleware
  },
  (req, res, next) => {
    console.log('Second middleware β€” skipped when skipRoute or error is set');
    next();
  },
  (req, res) => {
    res.send('Regular response');
  }
);

// A second handler for the same path β€” reached only via next('route')
app.get('/example', (req, res) => {
  res.send('Alternative response');
});

// Error handler β€” reached only via next(err)
app.use((err, req, res, next) => {
  res.status(500).send(`Error response: ${err.message}`);
});
πŸ’‘ Conveyor-belt analogy: A plain next() moves the product to the next station. next('route') is a diversion onto a different belt. next(err) is the emergency stop that routes the product to quality control.

Execution Order & Mount Paths

Middleware runs in the order it is registered. This is the single most important rule to internalise, because a body parser registered after a route can't help that route, and an auth check registered after a protected handler protects nothing.

A middleware's mount path decides which requests it applies to. With no path it runs for every request; with a path prefix it runs only for URLs beginning with that prefix:

const express = require('express');
const app = express();

// (1) No path β€” runs for EVERY request, first
app.use((req, res, next) => {
  req.startedAt = Date.now();
  next();
});

// (2) Path-scoped β€” runs only for URLs beginning with /api
app.use('/api', (req, res, next) => {
  req.isApiRequest = true;
  next();
});

// (3) No path again β€” runs after (1) for every request
app.use((req, res, next) => {
  console.log(`So far: ${Date.now() - req.startedAt}ms`);
  next();
});

app.get('/api/data', (req, res) => {
  res.json({ isApiRequest: req.isApiRequest, ms: Date.now() - req.startedAt });
});

app.get('/home', (req, res) => {
  res.json({ isApiRequest: req.isApiRequest || false });
});

app.listen(3000, () => console.log('http://localhost:3000'));

The stack a request runs through depends on its URL:

  • GET /api/data β†’ (1) β†’ (2) β†’ (3) β†’ handler (matches the /api mount)
  • GET /home β†’ (1) β†’ (3) β†’ handler (skips the /api-scoped middleware)
flowchart TB R[HTTP Request] --> M1[Middleware 1
global timer] M1 --> P{Path starts
with /api ?} P -->|yes| M2[Middleware 2
tag as API] P -->|no| M3[Middleware 3
log elapsed] M2 --> M3 M3 --> RH{Route match} RH -->|/api/data| H1[API handler] RH -->|/home| H2[Home handler] H1 --> Res[HTTP Response] H2 --> Res

Mount paths can be more than a simple string β€” Express accepts a path prefix, a regular expression, or an array:

app.use('/api', apiMiddleware);            // string prefix
app.use(/user/, userMiddleware);           // regular expression
app.use(['/api', '/admin'], secureMiddleware); // array of paths

πŸ’‘ Application-level vs router-level

The same rules apply whether you attach middleware to the app object or to an express.Router() instance. A router is just a mini-app you can mount under a prefix β€” app.use('/admin', adminRouter) β€” so its middleware only runs for that branch of your URL space. You'll lean on routers heavily as apps grow.

Error-Handling Middleware

Errors need their own lane. When any middleware calls next(err) β€” or throws inside an async handler that you forward β€” Express skips every remaining regular middleware and looks for the first error-handling middleware (the four-argument kind). Register it last, after all routes.

// A route that forwards its error instead of crashing the process
app.get('/data', (req, res, next) => {
  if (!req.query.id) {
    return next(new Error('id query parameter is required'));
  }
  res.json({ ok: true });
});

// Central error handler β€” LAST, and note the four parameters
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.statusCode || 500).json({ error: err.message });
});

With modern async/await handlers, wrap the body in try/catch and forward with next(err) so the central handler stays in charge:

app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await db.findUser(req.params.id);
    if (!user) {
      const err = new Error('User not found');
      err.statusCode = 404;
      throw err;
    }
    res.json(user);
  } catch (err) {
    next(err); // hand off to the central error handler
  }
});

⚠️ Express 4 vs Express 5

In Express 4, a rejected promise or a synchronous throw inside an async handler is not caught automatically β€” you must catch and call next(err) yourself (or use a helper like express-async-errors). Express 5 improves this: it forwards rejected promises from route handlers to your error middleware automatically. Writing the explicit try/catch works correctly on both, so it's the safe habit to build.

Hands-on Exercise

πŸ‹οΈ Build a Three-Stage Middleware Chain

Objective: Wire up an Express app whose middleware demonstrates logging, request tagging, and a conditional short-circuit β€” proving to yourself that order and next() behave exactly as described.

Requirements:

  1. A global logger that prints METHOD URL and attaches req.receivedAt = Date.now().
  2. A tagger mounted only on /api that sets req.isApi = true.
  3. A route GET /api/secret that responds with 403 unless the header x-key: let-me-in is present; otherwise it returns JSON including how many milliseconds elapsed since receivedAt.
  4. A central error handler as the last middleware.
πŸ’‘ Hint

Register the global logger first, then the /api tagger, then your routes, then the error handler. Read the header with req.get('x-key'). To exercise the error lane, have one branch call next(new Error('...')) and confirm it reaches the handler.

βœ… Sample solution
const express = require('express');
const app = express();

// (1) Global logger
app.use((req, res, next) => {
  req.receivedAt = Date.now();
  console.log(`${req.method} ${req.originalUrl}`);
  next();
});

// (2) API tagger β€” only for /api/*
app.use('/api', (req, res, next) => {
  req.isApi = true;
  next();
});

// (3) Protected route
app.get('/api/secret', (req, res, next) => {
  if (req.get('x-key') !== 'let-me-in') {
    return res.status(403).json({ error: 'Forbidden' });
  }
  res.json({
    message: 'Welcome in',
    isApi: req.isApi,
    elapsedMs: Date.now() - req.receivedAt
  });
});

// (4) Central error handler β€” LAST
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.statusCode || 500).json({ error: err.message });
});

app.listen(3000, () => console.log('http://localhost:3000'));

Test it: curl -H "x-key: let-me-in" localhost:3000/api/secret returns the JSON; without the header you get 403.

Best Practices

βœ… Do

  • Keep each middleware small and single-purpose β€” one concern per function.
  • Always call next() or send a response. Never both, never neither.
  • Register body parsers and security middleware before the routes that need them.
  • Put your error handler last, and forward errors with next(err).
  • return next(...) so execution stops after you hand off.

❌ Don't

  • Don't call next() after you've already sent a response β€” you'll get "Cannot set headers after they are sent".
  • Don't do heavy synchronous work in a global middleware; it blocks every request.
  • Don't rely on middleware that mutates req if it's registered after the handler that reads it.
  • Don't drop the fourth parameter from an error handler β€” Express will stop treating it as one.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Middleware is a (req, res, next) function; Express is a pipeline of them run in order.
  • A middleware can inspect/modify req/res, end the cycle, or call next().
  • next() advances, next('route') skips to the next route, next(err) diverts to the error handler.
  • Order and mount path determine which middleware runs and when.
  • Error-handling middleware has four parameters and must be registered last.

🎯 Quick Quiz

Question 1: What happens if a middleware neither calls next() nor sends a response?

Question 2: How does Express recognise a function as error-handling middleware?

Question 3: A request to GET /home hits an app with a global logger, then app.use('/api', tagger), then the handler. Which run?

πŸ“š Further Reading

πŸš€ What's Next?

Now that you understand how the chain works, the next lesson surveys the middleware you'll reach for constantly β€” Express's built-in parsers and static server, plus the essential third-party packages for logging, security, and CORS.