Last updated: 2026-09-23

Middleware in Depth

4.1 What Middleware Actually Is

A middleware function is just a function that runs during the request-response cycle and has access to the request, the response, and a way to pass control forward.

That's it. Three arguments and a call to next().

The Express documentation defines middleware functions as functions that have access to the request object (req), the response object (res), and the next function in the application's request-response cycle . The next function, when invoked, executes the middleware succeeding the current middleware.

Middleware functions can do four things :

  • Execute any code
  • Make changes to the request and response objects
  • End the request-response cycle
  • Call the next middleware in the stack

The critical rule: if the current middleware function does not end the request-response cycle, it must call next() to pass control to the next middleware function. Otherwise, the request will be left hanging . The client waits forever. The connection eventually times out. This is one of the most common mistakes in Express development, and it's worth internalizing early.

Here's the simplest possible middleware function:

const myLogger = function (req, res, next) {
  console.log('LOGGED');
  next();
};

It prints something, then calls next() to move the chain forward.

4.2 The Middleware Pipeline, Revisited

In Topic 2, we established that an Express application is essentially a series of middleware function calls executed during the request-response cycle. Now it's time to see that pipeline in action.

Middleware executes in the order it's registered. The Express documentation states it plainly: "The order of middleware loading is important: middleware functions that are loaded first are also executed first" .

app.use(myLogger);  // Runs first
app.get('/', (req, res) => {
  res.send('Hello World!');
});

Every request prints "LOGGED" before the route handler runs. If you swap the order — define the route first, then load myLogger — the request never reaches the logger. The route handler terminates the request-response cycle, and the middleware after it never executes .

This ordering property is what makes middleware composition so powerful. You're not configuring a system. You're building a sequence. And the sequence is the logic.

4.3 Types of Middleware

Express recognizes several categories of middleware, distinguished by where they're attached and what they're meant to do. The Express documentation lists these types :

Application-Level Middleware

Bound to an instance of the application object using app.use() or app.METHOD().

// Runs for every request
app.use((req, res, next) => {
  console.log('Time:', Date.now());
  next();
});

// Runs only for requests to /user/:id
app.use('/user/:id', (req, res, next) => {
  console.log('Request Type:', req.method);
  next();
});

Application-level middleware is the broadest scope. It can be mounted without a path (runs everywhere) or with a path (runs for requests matching that path) .

Router-Level Middleware

Works exactly like application-level middleware, except it's bound to an instance of express.Router() .

const router = express.Router();

router.use((req, res, next) => {
  console.log('Router-level middleware');
  next();
});

The distinction matters for modularity. A router's middleware runs only for requests that enter that router. It doesn't affect the rest of the application.

Error-Handling Middleware

Error-handling middleware is defined with four arguments instead of three: (err, req, res, next) .

The signature is what identifies it. Express looks for the four-argument form when deciding whether a function is an error handler. Even if you don't use the next parameter, you must include it to maintain the signature. Otherwise, Express treats the function as regular middleware and it won't handle errors .

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send('Something broke!');
});

We'll cover error handling in detail in the next section.

Built-in Middleware

Express includes several middleware functions out of the box. The documentation lists these :

  • express.static — serves static assets such as HTML files, images, and so on
  • express.json — parses incoming requests with JSON payloads (available with Express 4.16.0+)
  • express.urlencoded — parses incoming requests with URL-encoded payloads (available with Express 4.16.0+)

These are covered in more detail later in this topic.

Third-Party Middleware

Everything else. Authentication, logging, security headers, rate limiting, CORS — these come from the npm ecosystem and are installed and loaded like any other module.

4.4 Application-Level Middleware in Practice

The app.use() method is how you mount application-level middleware. It accepts an optional path and one or more middleware functions .

Without a Path

app.use((req, res, next) => {
  console.log('Request received');
  next();
});

Runs for every request, regardless of URL.

With a Path

app.use('/user/:id', (req, res, next) => {
  console.log('User ID:', req.params.id);
  next();
});

Runs for any request whose URL matches /user/:id, regardless of HTTP method. The route parameters are available in req.params because Express parses the path before running the middleware.

Multiple Middleware Functions

You can load a series of middleware functions at a single mount point, creating a substack :

app.use('/user/:id',
  (req, res, next) => {
    console.log('First middleware');
    next();
  },
  (req, res, next) => {
    console.log('Second middleware');
    next();
  }
);

Both run in order for requests matching the path.

Route Handlers Are Middleware Too

This is worth emphasizing: route handlers are middleware. They just happen to be mounted with a specific HTTP method via app.METHOD() .

app.get('/user/:id', (req, res) => {
  res.send(`User ${req.params.id}`);
});

This route handler is middleware that only runs for GET requests to /user/:id. It receives the same req, res, and next as any other middleware function. The only difference is that it's mounted with a method restriction.

next('route'): Skipping Remaining Handlers

Within a route's middleware stack, calling next('route') skips the remaining middleware functions for that route and passes control to the next matching route .

This only works in middleware loaded via app.METHOD() or router.METHOD() — not in functions loaded via app.use() or router.use() .

app.get('/user/:id',
  (req, res, next) => {
    if (req.params.id === '0') {
      return next('route'); // Skip to next matching route
    }
    next();
  },
  (req, res) => {
    res.send('Regular user');
  }
);

app.get('/user/:id', (req, res) => {
  res.send('Special case: user 0');
});

A request to /user/0 skips the first route's second handler and lands on the fallback route.

4.5 Router-Level Middleware

Router-level middleware is functionally identical to application-level middleware, but scoped to a router instance .

const router = express.Router();

router.use((req, res, next) => {
  console.log('Request entering router');
  next();
});

router.get('/users', (req, res) => {
  res.send('User list');
});

When this router is mounted on the application, the middleware runs only for requests that enter the router. The rest of the application is unaffected.

next('router'): Exiting a Router

There's a router-specific variant of next(): calling next('router') passes control back outside the router instance, skipping any remaining router-level middleware .

router.use((req, res, next) => {
  if (!req.headers.authorization) {
    return next('router'); // Exit this router entirely
  }
  next();
});

This is useful for conditional routing: if a request doesn't belong in this router, send it back to the parent.

4.6 The next Function in Detail

The next function is the mechanism that makes the middleware pipeline work. It's not part of Node.js or the Express API — it's the third argument passed to every middleware function .

By convention it's always named next, but it could be named anything. The Express documentation advises: "To avoid confusion, always use this convention" .

What next() Does

Calling next() passes control to the next middleware function in the stack. The order is the order of registration.

What next(err) Does

Passing anything to next() — except the string 'route' or 'router' — tells Express that an error occurred. Express skips all remaining non-error-handling middleware and jumps to the error-handling middleware .

app.use((req, res, next) => {
  const err = new Error('Something went wrong');
  next(err); // Skip to error handlers
});

What next('route') Does

As covered above, skips the remaining handlers in the current route's stack and moves to the next matching route. Only works in app.METHOD() or router.METHOD() middleware .

What next('router') Does

Exits the current router and passes control back to the parent application. Only works in router-level middleware .

4.7 Error-Handling Middleware

Error handling in Express is built on the same middleware pipeline, with one crucial difference: error-handling middleware has four arguments instead of three .

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send('Something broke!');
});

How Express Catches Errors

Synchronous errors in route handlers and middleware require no extra work. If synchronous code throws, Express catches it automatically :

app.get('/', (req, res) => {
  throw new Error('BROKEN'); // Express catches this
});

Asynchronous errors are more nuanced. In Express 4, you had to catch them manually and pass them to next() :

app.get('/', (req, res, next) => {
  fs.readFile('/file-does-not-exist', (err, data) => {
    if (err) {
      next(err); // Pass to Express
    } else {
      res.send(data);
    }
  });
});

Express 5 changes this. Route handlers and middleware that return a Promise now call next(value) automatically when they reject or throw an error . The runtime catches the rejection and forwards it to error handlers.

app.get('/user/:id', async (req, res, next) => {
  const user = await getUserById(req.params.id);
  res.send(user);
  // If getUserById throws or rejects, next is called automatically
});

If no rejected value is provided, next is called with a default Error object provided by the Express router .

This eliminates an entire class of bugs and removes the need for wrapper utilities like express-async-handler in most cases.

The Default Error Handler

Express comes with a built-in error handler that catches any errors not handled by custom error middleware . When an error is passed to next() and no custom handler exists, the default handler:

  • Sets res.statusCode from err.status or err.statusCode (defaults to 500 if outside 4xx/5xx range)
  • Sets res.statusMessage according to the status code
  • Sends the error stack trace in development, or just the status message in production
  • Closes the connection if the response has already started streaming

To run in production mode, set NODE_ENV=production. This suppresses stack traces from being sent to clients .

Writing Custom Error Handlers

A custom error handler is just middleware with four arguments:

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: {
      message: err.message,
      // Include stack only in development
      ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
    }
  });
});

Error-handling middleware must be defined last, after all other app.use() and route calls. If it's defined before other middleware, it won't catch errors from those later functions .

4.8 Built-in Middleware

Express ships with several middleware functions that cover common needs. These were previously bundled with the framework but are now exposed as separate functions .

express.static

Serves static files — images, CSS, JavaScript, HTML — from a directory .

app.use(express.static('public'));

Files in the public directory become accessible at the root URL. A file at public/logo.png is served at /logo.png .

The root argument specifies the directory. Express looks for files relative to that directory, and the directory name is not part of the URL .

To use a virtual path prefix:

app.use('/static', express.static('public'));

Now public/logo.png is served at /static/logo.png. The /static prefix doesn't exist in the filesystem — it's purely a URL mount point .

For best results, the documentation recommends using a reverse proxy cache for static assets in production .

express.json

Parses incoming requests with JSON payloads. Available in Express 4.16.0+ .

app.use(express.json());

After this middleware runs, req.body contains the parsed JSON. Without it, req.body is undefined for JSON requests.

Important caveat: As the Express documentation warns, req.body's shape is based on user-controlled input. All properties and values in this object are untrusted and should be validated before trusting . A property might not exist, might not be a string, or might not be what you expect. Never assume req.body.foo.toString() will work just because the client sent something.

express.urlencoded

Parses incoming requests with URL-encoded payloads, typically from HTML form submissions .

app.use(express.urlencoded({ extended: false }));

The extended option controls which parsing library is used: false uses Node's built-in querystring parser, true uses the qs library for richer nested object support.

4.9 Writing Your Own Middleware

Custom middleware is just a function with the right signature. The Express documentation provides a clear example: a myLogger that prints a message .

const myLogger = function (req, res, next) {
  console.log('LOGGED');
  next();
};

app.use(myLogger);

Every request prints "LOGGED" before reaching its route handler.

Modifying the Request Object

Middleware can add properties to req for later functions to use :

const requestTime = function (req, res, next) {
  req.requestTime = Date.now();
  next();
};

app.use(requestTime);

app.get('/', (req, res) => {
  res.send(`Requested at: ${req.requestTime}`);
});

This is a common pattern: middleware enriches the request with data that downstream handlers need.

Middleware for Validation

A middleware function can validate incoming data and reject requests before they reach route handlers :

const validateCookies = function (req, res, next) {
  cookieValidator(req.cookies)
    .then(() => next())
    .catch(err => res.status(400).send('Invalid cookies'));
};

app.use(validateCookies);

If validation fails, the request ends there. If it succeeds, next() passes control forward.

4.10 The Express 5 Promise Change

This deserves its own section because it's one of the most practically significant changes in Express 5.

In Express 4, async middleware functions were a footgun. If an async function threw or rejected, Express had no way to catch it. The rejection would be silently swallowed, the request would hang, and the error would be lost.

The workaround was either wrapping everything in try/catch and calling next(err) manually, or using a utility like express-async-handler that wrapped the function and caught rejections.

Express 5 fixes this at the framework level. Middleware functions that return a Promise now call next(value) automatically when they reject or throw . The rejection is forwarded to error handlers just as if you'd called next(err) yourself.

app.get('/data', async (req, res, next) => {
  const data = await fetchFromDatabase(); // If this throws...
  res.json(data);                          // ...error handlers catch it
});

No wrapper. No try/catch. It just works.

The only exception: if no rejected value is provided, next is called with a default Error object provided by the Express router .

This change eliminates an entire category of bugs and removes the need for async handler utilities in most Express 5 projects.