jsguides

Middleware Patterns in Node.js

Middleware is the backbone of Express.js. It’s the mechanism that lets you inject logic into the request-response cycle, making your application flexible and modular. This tutorial covers the fundamental middleware patterns you need to build dependable Node.js applications.

What Is Middleware?

Middleware functions are functions that have access to three things: the request object (req), the response object (res), and the next function in the application’s request-response cycle.

A middleware function can:

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

If a middleware doesn’t end the cycle, it must call next() to pass control to the next middleware. Forgetting to call next() is a common mistake that leaves requests hanging.

The Basic Middleware Signature

Every middleware function follows this signature:

function middlewareName(req, res, next) {
  // Your logic here
  next(); // Pass control to the next middleware
}

The three parameters are conventionally named req, res, and next. You can name them anything, but sticking to the convention makes your code readable to other developers.

Creating Your First Middleware

Here’s a simple logging middleware that logs when a request hits your server:

const myLogger = function (req, res, next) {
  console.log('Request received:', req.method, req.url);
  next();
};

app.use(myLogger);

The order matters. If you load this middleware after a route, the request never reaches it because the route handler terminates the cycle first.

Middleware That Modifies the Request

You can add properties to the request object that downstream handlers can use:

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

app.use(requestTime);

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

This pattern is useful for adding timestamps, user information from authentication, or any data computed early in the cycle.

Configurable Middleware

Create reusable middleware by exporting a function that accepts options:

// my-middleware.js
module.exports = function (options) {
  return function (req, res, next) {
    // Use options.foo, options.bar here
    if (options.required && !req.headers[options.required]) {
      return res.status(400).send('Missing required header');
    }
    next();
  };
};

// Usage
const mw = require('./my-middleware');
app.use(mw({ required: 'x-api-key' }));

This pattern is how popular middleware like cors and helmet work — they accept configuration and return a middleware function.

Error Handling Middleware

Error handling middleware is special. It takes four parameters instead of three:

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

Express identifies error handlers by their four-parameter signature. When you call next(err), Express skips all regular middleware and routes, going straight to your error handler.

Async Middleware

Async middleware requires special handling. If your middleware returns a Promise, Express 5 will automatically call next() with the rejection reason:

// Express 5 (automatic)
async function validateCookies(req, res, next) {
  await checkCookies(req.cookies);
  next();
}

// Express 4 (manual error handling)
function validateCookies(req, res, next) {
  checkCookies(req.cookies)
    .then(() => next())
    .catch(next); // Pass error to Express
}

In Express 4, always catch async errors and pass them to next(). Otherwise, unhandled rejections crash your server.

Chaining Middleware

The power of middleware comes from chaining. Each middleware can:

  • Perform its task and call next()
  • Send a response and end the cycle
  • Pass an error to next(err)
app.use(checkAuth);      // 1. Verify user is authenticated
app.use(loadUser);        // 2. Load user data into req.user
app.use(logRequest);      // 3. Log the request
app.get('/profile', (req, res) => {  // 4. Handle the route
  res.json(req.user);
});

This chain pattern is how authentication, logging, parsing, and routing all work together cleanly.

Third-Party Middleware

Express includes minimal built-in middleware. For common tasks, use proven third-party packages:

PackagePurpose
morganHTTP request logging
helmetSecurity headers
corsCross-origin resource sharing
cookie-parserParse cookies
body-parserParse request bodies
const helmet = require('helmet');
const morgan = require('morgan');

app.use(helmet());
app.use(morgan('tiny'));

Ordering and Short-Circuiting

Middleware order is part of the design, not just an implementation detail. A logging layer usually belongs near the start so it can observe the full request, while authentication and validation should happen before expensive work. When a middleware sends a response early, it short-circuits the rest of the chain. That is useful for rejects, cached responses, and guard rails, but it should be used deliberately so later handlers are not skipped by accident.

This is why teams benefit from naming middleware by responsibility rather than by placement. A request can move through several steps, and each step should be easy to describe in one sentence. If a handler both validates and formats the response, it becomes harder to reuse and harder to test. Small, purpose-built middleware pieces are easier to reorder when the application changes.

Testing Middleware in Isolation

Middleware is much easier to trust when you test it with a fake request, a fake response, and a spy for next(). That lets you confirm whether it calls through, stops early, or sets the right fields on the request object. You do not need a running server for most of those checks, which keeps the feedback loop fast.

It also helps to write one test for the success path and one for the rejection path. Middleware often fails in the edge cases: missing headers, malformed body data, or unexpected user input. A short test suite that covers those edges will catch more bugs than a long manual checklist. Good middleware tests are narrow, predictable, and focused on behavior rather than setup noise.

When to Split Middleware

If a middleware function starts carrying branching logic for unrelated cases, split it into smaller functions. One function can attach metadata, another can enforce rules, and a third can format an error response. That separation keeps each unit easy to reason about and makes it clearer which piece is responsible when a request fails.

Splitting also makes it easier to reuse the same logic in routes that have different needs. A middleware that only knows about one step in the request lifecycle can move between routes without dragging along extra assumptions. That flexibility matters once the app grows beyond a single demo flow.

Middleware Lifecycles

Middleware should be thought of as part of a request lifecycle, not as a random collection of callbacks. Each one receives the request at a certain point, does one small job, and either passes control onward or ends the response. That mental model makes it easier to decide where a new piece belongs and whether it should run before or after other checks.

Lifecycle thinking also helps with cleanup. If middleware attaches listeners, stores temporary values, or opens a resource, it needs a matching end point. Without that habit, request handling can leave behind work that no longer matters. A good middleware chain starts and ends cleanly, even when a request fails early.

Request Lifecycles

Middleware works best when you can draw the path of a request from start to finish. A request may begin with logging, move through validation, pass into the route handler, and then end with a response formatter. When the lifecycle is visible like that, it becomes much easier to place new middleware in the right spot.

That view also helps when you debug a bad request. You can ask which step saw the request first, which step changed it, and which step decided to stop. Those answers point you toward the failure faster than looking at the whole chain all at once.

See Also