Express.js Middleware Patterns You Should Know

Express.js Middleware Patterns You Should Know

Middleware is how Express works. Not just a feature of it -- it is the whole thing. This is the talk I give to every new hire: the five middleware types, execution order, why next() trips everyone up, async error handling, and the patterns we actually use in production.

Every Express app is just middleware. That sounds reductive but it's basically true. Once you see it that way, everything clicks.

A middleware function gets three things: the request object (req), the response object (res), and a next function. It can do stuff with the request, do stuff with the response, end the cycle by sending something back, or call next() to hand things off to the next function in line. That's it. Every Express app is a stack of these functions running one after another in the order you registered them.

I explain it to new hires like this: imagine a conveyor belt. The request comes in one end, passes through each station (middleware), and a response comes out the other end. Any station can stop the belt and send the response early. If no station stops it, the request falls off the end and the client gets nothing -- just an infinite hang.

Once that mental model sticks, the rest of Express makes sense. So let's build on it.

The Five Types of Middleware

Express has five kinds of middleware. I've seen people use Express for years without knowing all five, and it shows in their code.

1. Application-level middleware -- bound to app using app.use() or app.METHOD(). If you don't specify a path, it runs on every request.

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

// Runs on every request
app.use((req, res, next) => {
  console.log(`${req.method} ${req.url} - ${Date.now()}`);
  next();
});

// Runs only for GET /dashboard
app.get('/dashboard', (req, res, next) => {
  res.send('Dashboard');
});

2. Router-level middleware -- same thing, but scoped to an express.Router(). This is how you split a big app into modules without everything bleeding together.

const router = express.Router();

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

router.get('/profile', (req, res) => {
  res.json({ user: 'Anurag' });
});

app.use('/api/users', router);

3. Error-handling middleware -- has four arguments instead of three. That's how Express knows it's an error handler. I know, it's weird. The function signature is the API. Don't fight it.

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: err.message || 'Internal Server Error'
  });
});

4. Built-in middleware -- ships with Express. Since 4.16, you get express.json(), express.urlencoded(), and express.static(). Before that, you needed the body-parser package separately. You'll still see it in older tutorials.

app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(express.static('public'));

5. Third-party middleware -- npm packages. cors, helmet, morgan, compression, cookie-parser. Install, require, mount.

const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');

app.use(cors());
app.use(helmet());
app.use(morgan('combined'));

Execution Order and the next() Function

Here's where I see the most bugs from junior devs. The order you call app.use() is the order middleware executes. Full stop. Not alphabetical, not by importance. The order in your code.

app.use(middlewareA);  // runs first
app.use(middlewareB);  // runs second
app.use(middlewareC);  // runs third

And within each middleware, you must either send a response or call next(). If you do neither, the request hangs. No error. No log. Nothing. The client just waits until it times out. This is the number one thing that confuses new Express developers.

There are three flavors of next:

  • next() -- pass to the next middleware in line.
  • next('route') -- skip the rest of the current route's middleware and jump to the next matching route. Only works in route-level handlers.
  • next(err) -- skip everything that isn't an error handler. Jump straight to your error-handling middleware.
app.get('/user/:id',
  (req, res, next) => {
    if (req.params.id === '0') {
      return next('route'); // skip to next app.get('/user/:id', ...)
    }
    next(); // continue to the next handler in this route
  },
  (req, res) => {
    res.json({ id: req.params.id, source: 'first route' });
  }
);

app.get('/user/:id', (req, res) => {
  res.json({ id: req.params.id, source: 'second route' });
});

One rule that bites people: error-handling middleware must be registered last. After all your routes. After all your other middleware. Express walks the stack top to bottom, so the error handler needs to be at the bottom to catch everything above it. Put it in the wrong spot and errors will vanish silently.

Custom Middleware Patterns

Writing your own middleware is where Express gets fun. Here are the patterns I reach for again and again.

Request ID middleware -- every request gets a unique ID for tracing through logs. Simple, but saves you hours when debugging production issues.

const crypto = require('crypto');

function requestId(req, res, next) {
  req.id = req.headers['x-request-id'] || crypto.randomUUID();
  res.setHeader('X-Request-Id', req.id);
  next();
}

app.use(requestId);

Timing middleware -- how long did this request take? You want to know.

function timing(req, res, next) {
  const start = process.hrtime.bigint();

  res.on('finish', () => {
    const end = process.hrtime.bigint();
    const durationMs = Number(end - start) / 1e6;
    console.log(`${req.method} ${req.url} - ${durationMs.toFixed(2)}ms`);
  });

  next();
}

app.use(timing);

Role-based authorization factory -- this is the pattern I show people first, because once you understand it, middleware factories make sense forever. A function that returns middleware. You pass in config, you get back a customized (req, res, next) function.

function authorize(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }
    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

// Usage
app.delete('/api/posts/:id', authorize('admin', 'moderator'), deletePost);
app.get('/api/posts', authorize('admin', 'moderator', 'user'), listPosts);

Every major Express package -- helmet, cors, morgan -- uses this factory pattern. It's the standard way to make configurable middleware.

Rate limiter -- a bare-bones in-memory version. Good enough for small apps, though you'd want Redis behind it in production.

function rateLimit({ windowMs = 60000, max = 100 } = {}) {
  const hits = new Map();

  // Clean up expired entries periodically
  setInterval(() => {
    const now = Date.now();
    for (const [key, data] of hits) {
      if (now - data.start > windowMs) hits.delete(key);
    }
  }, windowMs);

  return (req, res, next) => {
    const key = req.ip;
    const now = Date.now();
    const record = hits.get(key);

    if (!record || now - record.start > windowMs) {
      hits.set(key, { start: now, count: 1 });
      return next();
    }

    record.count++;
    if (record.count > max) {
      return res.status(429).json({ error: 'Too many requests' });
    }
    next();
  };
}

app.use('/api', rateLimit({ windowMs: 60000, max: 50 }));

Async Middleware and Error Handling

This is the biggest trap in Express 4. If you write an async route handler and it throws, Express does not catch it. You get an unhandled promise rejection. Your error-handling middleware never fires. Your user gets a hung request or a generic crash.

Here's the wrong way:

// BAD -- thrown errors disappear
app.get('/api/users', async (req, res) => {
  const users = await User.findAll(); // if this throws, game over
  res.json(users);
});

The manual fix is try-catch everywhere:

app.get('/api/users', async (req, res, next) => {
  try {
    const users = await User.findAll();
    res.json(users);
  } catch (err) {
    next(err);
  }
});

That works, but it gets old fast. You're wrapping every single handler in identical try-catch blocks. So instead, write a wrapper once:

function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

// Clean async routes, errors handled automatically
app.get('/api/users', asyncHandler(async (req, res) => {
  const users = await User.findAll();
  res.json(users);
}));

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findByPk(req.params.id);
  if (!user) {
    const error = new Error('User not found');
    error.status = 404;
    throw error;
  }
  res.json(user);
}));

Now any thrown error or rejected promise flows straight to your error handler. We add asyncHandler to every new project on day one.

Quick note: Express 5 handles async errors natively. When it hits stable, asyncHandler becomes unnecessary. But we're not there yet, so use the wrapper.

Your centralized error handler should tell the difference between expected errors (bad input, not found) and unexpected ones (null references, database failures). Don't leak internal details to the client.

app.use((err, req, res, next) => {
  // Log the full error for debugging
  console.error(`[${req.id}] Error:`, err);

  // Operational errors have a status code
  if (err.status) {
    return res.status(err.status).json({
      error: err.message
    });
  }

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    return res.status(400).json({
      error: 'Validation failed',
      details: err.errors
    });
  }

  // Unexpected error -- do not leak details
  res.status(500).json({
    error: 'Internal Server Error'
  });
});

Middleware Composition

As your app grows, you'll end up with the same three or four middleware on a dozen routes. Don't repeat yourself. Compose them.

Array-based composition -- Express route methods accept arrays, so you can group common middleware into a reusable list:

const authenticate = require('./middleware/authenticate');
const validate = require('./middleware/validate');
const rateLimit = require('./middleware/rateLimit');

const apiDefaults = [
  rateLimit({ max: 100 }),
  authenticate,
  express.json()
];

app.post('/api/posts', ...apiDefaults, validate(postSchema), createPost);
app.put('/api/posts/:id', ...apiDefaults, validate(postSchema), updatePost);
app.delete('/api/posts/:id', ...apiDefaults, authorize('admin'), deletePost);

Conditional middleware -- sometimes you want to skip middleware for certain paths. A higher-order function handles it:

function unless(paths, middleware) {
  return (req, res, next) => {
    if (paths.includes(req.path)) {
      return next();
    }
    middleware(req, res, next);
  };
}

// Skip authentication for public routes
app.use(unless(['/login', '/register', '/health'], authenticate));

Response envelope -- if you want every API response wrapped in a consistent format, override res.json() once instead of changing every handler:

function responseEnvelope(req, res, next) {
  const originalJson = res.json.bind(res);

  res.json = (data) => {
    originalJson({
      success: res.statusCode < 400,
      timestamp: new Date().toISOString(),
      data
    });
  };

  next();
}

app.use('/api', responseEnvelope);

Router-scoped stacks -- use separate routers to keep concerns isolated. Public routes get no auth. Protected routes get auth middleware on the router itself, so you can't accidentally forget it on a route.

const publicRouter = express.Router();
const protectedRouter = express.Router();

publicRouter.get('/health', (req, res) => res.json({ ok: true }));
publicRouter.post('/login', loginHandler);

protectedRouter.use(authenticate);
protectedRouter.use(authorize('user'));
protectedRouter.get('/profile', profileHandler);
protectedRouter.get('/settings', settingsHandler);

app.use(publicRouter);
app.use(protectedRouter);

Things That Trip People Up

I keep a list. Every time someone on the team hits one of these, I add it. Here's what's on it:

  • Order matters more than you think. Put express.json() before any route that reads req.body. Put CORS before any route that needs cross-origin access. Put error handlers dead last. Swap two lines and spend an hour debugging something that shouldn't be broken.
  • Forgot next()? No error. Just a hung request. The client waits forever. No log, no crash, nothing. Add a lint rule or put it on your code review checklist.
  • "Cannot set headers after they are sent." This means you called res.json() or res.send() more than once. Usually happens in conditional logic where both branches send a response. Guard with if (res.headersSent) return;
  • One middleware, one job. Auth checks in one function. Validation in another. Logging in a third. The moment you start combining them, you lose the ability to reuse any of them independently.
  • Use the factory pattern. If your middleware needs configuration, return a function from a function. It's how helmet, cors, and morgan all work. It's the convention. Follow it.
  • No heavy computation in middleware. It runs on every matching request. If you need to crunch numbers, offload to a worker thread or a queue.
  • Test middleware in isolation. The signature is (req, res, next). Pass in mock objects. It's one of the easiest things in Node to unit test, so actually do it.
// Example: unit testing middleware
const { requestId } = require('./middleware/requestId');

describe('requestId middleware', () => {
  it('should generate a request ID if none is provided', () => {
    const req = { headers: {} };
    const res = { setHeader: jest.fn() };
    const next = jest.fn();

    requestId(req, res, next);

    expect(req.id).toBeDefined();
    expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', req.id);
    expect(next).toHaveBeenCalled();
  });
});

Written by Anurag Kumar

Full-stack developer passionate about Node.js and building fast, scalable web applications. Writing about what I learn every day.

Comments (0)

No comments yet. Be the first to share your thoughts!