Middleware Concepts

Difficulty: Intermediate

Middleware is the core architectural concept in Express. A middleware function is any function that has access to the request object (req), the response object (res), and the next function (next) in the application's request-response cycle. Middleware functions can execute any code, modify the request and response objects, end the request-response cycle, or call the next middleware in the stack.

When a request arrives at your Express application, it passes through a pipeline of middleware functions in the order they were registered. Each middleware can either send a response (ending the cycle) or call next() to pass control to the next middleware. If a middleware neither sends a response nor calls next(), the request hangs forever. This pipeline model is what makes Express so flexible - you can add, remove, and reorder middleware to change how requests are processed.

There are three main types of middleware in Express. Application-level middleware is registered with app.use() or app.METHOD() and runs on every matching request. Router-level middleware is registered with router.use() and only runs for routes on that specific router. Error-handling middleware has four parameters (err, req, res, next) and is only invoked when an error is passed to next(err) or thrown in an async handler.

The order in which middleware is registered matters enormously. Middleware registered first runs first. This means you should register body parsers before route handlers (so req.body is populated), loggers before everything (so all requests are logged), and error handlers after everything (so they catch all errors). A common bug is registering a body parser after routes, causing req.body to be undefined.

You can pass errors to the error handling middleware by calling next(err) with an error object. In Express 5, you can also throw errors or return rejected Promises from async route handlers and they will be automatically forwarded to the error handler. Error handling middleware must have exactly four parameters (err, req, res, next) - Express uses the parameter count to distinguish error handlers from regular middleware.

Code examples

Understanding the Middleware Chain

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

// Middleware 1: Logger (runs on ALL requests)
app.use((req, res, next) => {
  console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
  req.requestTime = Date.now(); // Attach data to req
  next(); // MUST call next() to continue the chain
});

// Middleware 2: Add custom header to all responses
app.use((req, res, next) => {
  res.setHeader('X-Request-Id', Math.random().toString(36).substring(7));
  next();
});

// Route handler (the final middleware in the chain)
app.get('/', (req, res) => {
  const responseTime = Date.now() - req.requestTime;
  res.json({
    message: 'Hello World',
    responseTime: `${responseTime}ms`
  });
  // No next() call - this ends the request-response cycle
});

// This middleware only runs if no route matched above
app.use((req, res) => {
  res.status(404).json({ error: 'Route not found' });
});

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

Requests flow through middleware in registration order. The logger runs first, attaches timing data, then calls next(). The header middleware adds a custom header, then calls next(). The route handler sends the response (no next needed). If no route matches, the 404 handler catches it.

Route-Specific and Conditional Middleware

const express = require('express');
const app = express();
app.use(express.json());

// Auth middleware (reusable)
function authenticate(req, res, next) {
  const token = req.headers.authorization;
  if (!token || token !== 'Bearer secret123') {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  req.user = { id: 1, name: 'Alice', role: 'admin' };
  next();
}

// Role check middleware (factory function)
function requireRole(role) {
  return (req, res, next) => {
    if (req.user.role !== role) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

// Public route - no middleware
app.get('/api/public', (req, res) => {
  res.json({ message: 'This is public' });
});

// Protected route - auth middleware applied to this route only
app.get('/api/profile', authenticate, (req, res) => {
  res.json({ user: req.user });
});

// Admin route - stacked middleware (auth + role check)
app.delete('/api/users/:id', authenticate, requireRole('admin'), (req, res) => {
  res.json({ message: `User ${req.params.id} deleted by ${req.user.name}` });
});

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

Middleware can be applied to specific routes by passing it as an argument before the handler. Multiple middleware functions can be stacked: authenticate runs first, then requireRole('admin'), then the handler. requireRole is a factory function that returns a middleware, allowing you to customize the required role.

Error Handling Middleware

const express = require('express');
const app = express();
app.use(express.json());

// Custom error class
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
  }
}

// Route that throws an error
app.get('/api/data', (req, res) => {
  throw new AppError('Data not available', 503);
});

// Async route - Express 5 catches rejected promises automatically
app.get('/api/users/:id', async (req, res) => {
  const id = parseInt(req.params.id);
  if (isNaN(id)) {
    throw new AppError('Invalid user ID', 400);
  }
  // Simulated async operation
  if (id > 100) {
    throw new AppError('User not found', 404);
  }
  res.json({ id, name: `User ${id}` });
});

// Error handling middleware - MUST have 4 parameters
app.use((err, req, res, next) => {
  console.error(`Error: ${err.message}`);

  const statusCode = err.statusCode || 500;
  const message = err.message || 'Internal Server Error';

  res.status(statusCode).json({
    error: message,
    ...(process.env.NODE_ENV !== 'production' && { stack: err.stack })
  });
});

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

Error middleware has exactly 4 parameters (err, req, res, next). It catches errors thrown in route handlers and errors passed via next(err). Custom error classes like AppError carry a statusCode for precise HTTP error responses. Stack traces are only included in development.

Key points

Concepts covered

Middleware, next(), Application Middleware, Router Middleware, Error Middleware, Middleware Chain