Express middleware functions are the building blocks of your Node.js applications, but their internal workings can be a bit of a black box.

Here’s how it looks when Express is actually running:

Imagine a request comes in. Express takes this request and passes it through a chain of middleware functions. Each middleware function gets a chance to do something with the request and response objects. They can modify them, send a response immediately, or pass the request along to the next middleware in line.

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

// Middleware 1: Log the request method and URL
app.use((req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next(); // Pass control to the next middleware
});

// Middleware 2: Add a custom header
app.use((req, res, next) => {
  res.setHeader('X-Powered-By', 'AwesomeApp');
  next();
});

// Route handler (also a type of middleware)
app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.listen(3000, () => {
  console.log('Server listening on port 3000');
});

When you hit http://localhost:3000/ in your browser, the console will show:

GET /

And the browser will display "Hello World!" with an X-Powered-By: AwesomeApp header. If you were to hit http://localhost:3000/users, the console would show GET /users.

The magic happens with the next() function. When called, it tells Express to move to the next middleware in the stack. If next() is not called, the request essentially hangs, waiting for a response to be sent. This is how you can short-circuit the middleware chain, for example, by sending an authentication error before any other logic runs.

The order in which you define your app.use() and route handlers is crucial because it dictates the order in which middleware functions are executed. Middleware defined earlier in the file will be executed first. This allows for a layered approach: you can handle logging, authentication, body parsing, and then route-specific logic in distinct, reusable functions.

The request object (req) and response object (res) are passed by reference to each middleware function. This means any modifications made to them in one middleware function are visible to all subsequent middleware functions. This is how middleware can add properties to the request object (like req.user after authentication) or set headers on the response object.

The core mechanism is a series of functions, each accepting (req, res, next). When next() is invoked, Express calls the next function in its internal array of handlers for that specific request. If next() is called with an argument, typically an Error object, Express skips all remaining regular middleware and handlers and looks for an error-handling middleware (one that takes (err, req, res, next) as arguments).

What most people don’t realize is that app.use() and app.METHOD() (like app.get, app.post) both essentially add functions to this internal stack. app.use() without a path argument applies to all incoming requests. When a path is provided to app.use(), or when using app.METHOD(), Express matches the request’s URL and method against the defined paths and methods, and only then adds those specific middleware functions to the execution chain.

Understanding this stack allows for powerful composition, creating reusable modules for cross-cutting concerns like CORS handling, session management, or request validation.

The next concept to grasp is how route parameters and query strings are parsed and made available within this middleware chain.

Want structured learning?

Take the full Express course →