Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

Express Middleware Explained: A Complete Guide With Real Examples (2026)

If you’ve written even one Express route, you’ve already used middleware — because in Express, a route handler is middleware. But most developers use Express middleware for months before they really understand what it’s doing. This guide fixes that. You’ll learn how Express middleware actually executes, the five types, the order rules that silently break apps, and real, copy-paste examples for logging, authentication, validation, rate limiting, and error handling — plus the async gotcha that still bites Express 4 projects in 2026.

Last updated: 7 October 2026.

What Is Express Middleware?

A middleware function is a function with access to three things: the request object (req), the response object (res), and the next middleware function in the cycle, conventionally called next. According to the official Express docs, middleware can do exactly four things: execute any code, modify the request and response objects, end the request-response cycle, or pass control to the next middleware.

The absolute minimum middleware looks like this:

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

app.use((req, res, next) => {
  console.log(`${req.method} ${req.url} at ${new Date().toISOString()}`);
  next(); // pass control to the next middleware
});

app.get('/', (req, res) => {
  res.send('Hello World');
});

app.listen(3000);

There is one rule you must internalise: if a middleware function doesn’t end the request-response cycle, it must call next(). Otherwise the request hangs forever and your client times out. No error, no warning — just silence.

How Express Middleware Actually Executes

Express keeps an internal stack of middleware layers. Every time you call app.use(), app.get(), or router.use(), Express pushes the handler and its path onto that stack. When a request arrives, Express walks the stack top to bottom:

  1. Express starts at the first item in the stack.
  2. It checks whether the request path matches the middleware’s path.
  3. If it matches, Express runs the middleware.
  4. The middleware either calls next() to continue, calls next(err) to jump to error handlers, or sends a response to stop the chain.

Think of it as a chain of checkpoints. Each checkpoint inspects the request, optionally transforms it, and decides: pass it forward or stop it here. That decision is the entire middleware contract.

Order is not cosmetic — it’s behavioural. Middleware runs in the order you register it, and a middleware registered after a route never runs for that route’s requests. Register body parsers before routes, auth before protected routes, and error handlers last.

The Five Types of Express Middleware

The official Express docs recognise five types. Here is each one with a real example.

1. Application-Level Middleware

Bound to the app instance with app.use() or app.METHOD(). Without a path it runs for every request; with a path prefix it runs for every request whose path starts with that prefix.

// Runs for EVERY request
app.use((req, res, next) => {
  console.log('Incoming:', req.method, req.originalUrl);
  next();
});

// Runs only for paths starting with /admin
app.use('/admin', (req, res, next) => {
  console.log('Admin section accessed');
  next();
});

2. Router-Level Middleware

Identical behaviour, but bound to an express.Router() instance. This is how you keep a growing app modular — each feature file owns its middleware, and it only runs when the router handles a request.

// routes/users.js
const express = require('express');
const router = express.Router();

// Runs before every route in this router
router.use((req, res, next) => {
  console.log('User router hit');
  next();
});

router.get('/', (req, res) => res.json({ users: [] }));
router.get('/:id', (req, res) => res.json({ id: req.params.id }));

module.exports = router;
// app.js — everything in userRouter only runs for /users/* paths
const userRouter = require('./routes/users');
app.use('/users', userRouter);

3. Built-In Middleware

Express ships with these — you don’t need to install anything:

  • express.json() — parses JSON request bodies into req.body.
  • express.urlencoded() — parses URL-encoded form bodies.
  • express.text() and express.raw() — parse text and Buffer payloads.
  • express.static() — serves static assets like HTML files and images.
// Without this, req.body is undefined for JSON requests
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

This is the most common “why is req.body undefined?” bug on Stack Overflow: the parser must be registered before the routes that read the body.

4. Error-Handling Middleware

Defined like normal middleware except with four arguments instead of three: (err, req, res, next). Express decides it’s an error handler by counting the parameters (Function.length), which creates the famous arity trap:

// WRONG: Express treats this as normal middleware — errors pass through it
app.use((err, req, res) => {
  res.status(500).json({ error: 'Broken' });
});

// CORRECT: four arguments = error handler
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({ error: err.message });
});

You must declare all four parameters even if you never use next. Error handlers also belong last, after all routes — errors bubble up through the stack, so a handler registered at the top never sees errors thrown below it.

5. Third-Party Middleware

The npm ecosystem fills the gaps: morgan for logging, helmet for security headers, cors for cross-origin requests, cookie-parser for cookies. Install and mount like this:

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

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

Real-World Example: Auth + Role Checks in One Chain

Middleware chaining is separation of concerns at its best. Instead of cramming token verification, role checks, and business logic into one route, chain small single-purpose middlewares:

// 1. Verify the token and attach the user
const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Missing token' });
  try {
    req.user = verifyJwt(token); // throws on invalid token
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }
};

// 2. Role-based access control
const requireRole = (role) => (req, res, next) => {
  if (req.user?.role !== role) {
    return res.status(403).json({ error: 'Access denied' });
  }
  next();
};

// Chained route: token first, then role, then the handler
app.post('/admin/settings', authenticate, requireRole('admin'), (req, res) => {
  res.json({ status: 'Settings updated' });
});

Notice the return before each response. Sending a response and also calling next() is a classic bug — the next handler tries to send a second response and Express throws “Cannot set headers after they are sent”. When you end the cycle, return so next() never runs.

Real-World Example: Body Validation Middleware

A reusable validator factory keeps route handlers clean and validation logic in one place:

const validateBody = (schema) => (req, res, next) => {
  const errors = {};
  for (const [field, rules] of Object.entries(schema)) {
    const value = req.body?.[field];
    if (rules.required && (value === undefined || value === '')) {
      errors[field] = 'is required';
    } else if (value !== undefined && rules.pattern && !rules.pattern.test(value)) {
      errors[field] = 'has an invalid format';
    }
  }
  if (Object.keys(errors).length > 0) {
    return res.status(400).json({ errors });
  }
  next();
};

app.post('/api/users', validateBody({
  username: { required: true },
  email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
}), (req, res) => {
  res.status(201).json({ message: 'User created' });
});

Real-World Example: A Simple Rate Limiter

No dependencies — a small in-memory rate limiter teaches you how third-party middleware works under the hood:

const rateLimit = ({ windowMs, max }) => {
  const hits = new Map(); // IP -> { count, resetTime }
  return (req, res, next) => {
    const key = req.ip;
    const now = Date.now();
    let entry = hits.get(key);
    if (!entry || now > entry.resetTime) {
      entry = { count: 0, resetTime: now + windowMs };
      hits.set(key, entry);
    }
    entry.count += 1;
    res.set('X-RateLimit-Remaining', Math.max(0, max - entry.count));
    if (entry.count > max) {
      return res.status(429).json({
        error: 'Too many requests',
        retryAfterSeconds: Math.ceil((entry.resetTime - now) / 1000)
      });
    }
    next();
  };
};

app.use('/api', rateLimit({ windowMs: 60 * 1000, max: 100 }));

Passing Data Between Middlewares: res.locals

When one middleware computes something the next one needs — a parsed user, a request ID, a timing value — the Express-sanctioned channel is res.locals. It attaches data to the current request’s lifecycle and dies with the response:

app.use((req, res, next) => {
  res.locals.requestId = crypto.randomUUID();
  res.locals.startedAt = Date.now();
  next();
});

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

Avoid stuffing ad-hoc properties onto req for cross-middleware data; res.locals is the documented namespace for exactly this.

The Async Gotcha: Express 4 vs Express 5

This is the single most expensive middleware misunderstanding. In Express 4, a rejected promise or thrown error inside an async handler is not caught by your error middleware. You must forward it with next(err) — or the request hangs / the process crashes on an unhandled rejection:

// Express 4 — you MUST forward async errors yourself
app.get('/user/:id', async (req, res, next) => {
  try {
    const user = await db.findUser(req.params.id);
    res.json(user);
  } catch (err) {
    next(err); // without this, the rejection goes nowhere
  }
});

The standard Express 4 workaround is a tiny wrapper — many codebases call it asyncHandler — or the express-async-errors package, which monkey-patches Express to do it automatically.

Express 5 (stable since late 2024) fixes this natively: route handlers and middleware that return a promise automatically call next(value) when they reject or throw. Plain async handlers just work — no wrappers, no try/catch plumbing. The catch is that a large share of production codebases are still on Express 4, so check your version (npm list express) before deciding which pattern your app needs.

Middleware Order: The Golden Stack

Commit this order to memory — getting it wrong silently swallows errors or breaks parsing:

// 1. Security middleware (helmet, cors)
// 2. Body parsers (express.json, express.urlencoded)
// 3. Request context (request IDs, logging)
// 4. Session / authentication
// 5. Route handlers
// 6. 404 handler (for unmatched routes)
// 7. Error handler (ALWAYS LAST)
// 404 handler — catches anything that fell through the routes
app.use((req, res) => {
  res.status(404).json({ error: 'Not found' });
});

// Error handler — catches next(err) and thrown sync errors. LAST.
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: process.env.NODE_ENV === 'production' ? 'Something broke' : err.message
  });
});

Common Express Middleware Mistakes

  • Forgetting next(). The request hangs with no error. Always trace which branch of your middleware fails to call next() or send a response.
  • Calling next() after sending a response. Leads to “Cannot set headers after they are sent”. Use return res.json(...) to stop execution.
  • Three-argument error handler. Express needs all four parameters to recognise it as an error handler.
  • Error handler not last. Errors bubble up the stack — a handler above the throwing middleware never fires.
  • Body parser after routes. req.body will be undefined in every handler above the parser.
  • next(‘route’) outside a route handler. Per the official docs, next('route') only works in middleware loaded via app.METHOD() or router.METHOD(). Use next('router') to bail out of a whole router instead.

Interview Cheat-Sheet

  • Middleware = function with access to req, res, and next.
  • No next() and no response = hanging request.
  • Five types: application-level, router-level, error-handling, built-in, third-party.
  • Error handlers need four arguments; Express detects them by arity.
  • Order: security → parsers → auth → routes → 404 → error handler last.
  • Express 4 async errors need next(err); Express 5 auto-forwards rejected promises.
  • res.locals passes data between middlewares for the current request.

Further Reading & References

Leave a Comment