Phase 2 — Building REST APIs lesson-0010

Middleware

Middleware is the backbone of every Express application. Master it and you understand how the entire framework works — routing, auth, error handling, everything.

The Core Concept

A middleware function is any function with this signature:

// Regular middleware
(req, res, next) => { ... }

// Error-handling middleware — 4 arguments, MUST be exactly 4
(err, req, res, next) => { ... }

Every incoming request flows through a pipeline of these functions in the order they were registered. Each function can:

  • Modify req or res (add properties, set headers)
  • End the cycle by calling res.json() / res.send()
  • Pass forward by calling next() — "I'm done, keep going"
  • Skip to error handling by calling next(err) — "something went wrong"
The Forgotten next()
If a middleware function neither calls next() nor sends a response, the request hangs forever. The client waits, times out, and you see no error. This is the most common middleware bug.

The Full Request Pipeline

Here's what actually happens for a POST /api/v1/users request to a production Express app:

Incoming TCP Request
Node's http server receives raw bytes on port 3000. Calls your Express app.
express.json()
Reads the raw body stream, parses it as JSON, attaches to req.body. Calls next().
requestId middleware
Generates a UUID, attaches to req.id, sets X-Request-ID header. Calls next().
morgan()
Records start time. Hooks into res finish event to log method, path, status, duration.
helmet()
Sets security headers: X-Frame-Options, X-Content-Type-Options, CSP, etc. Calls next().
cors()
Checks Origin header. Adds Access-Control-Allow-Origin. Handles OPTIONS preflight. Calls next().
rateLimit()
Checks request count for this IP. If under limit, calls next(). If over, calls next(new TooManyRequestsError()).
↙ if rate limited: jumps to error handler ↘
authenticate()
Reads Authorization: Bearer <token>, verifies JWT. Attaches req.user. On failure calls next(new UnauthorizedError()).
↙ if auth fails: jumps to error handler ↘
validate(schema)
Parses and validates req.body / req.params / req.query against Zod schema. On failure calls next(new ValidationError(...)).
↙ if validation fails: jumps to error handler ↘
Route handler (controller)
Your business logic runs. Queries DB, builds response. Calls res.json(). Done.
↙ if handler throws: jumps to error handler ↘
Error Handler (4-arg)
Only reached if next(err) was called anywhere above. Logs the error, sends consistent error response.

Types of Middleware

Application-level

Registered with app.use(). Runs on every request unless path-scoped.

Router-level

Registered with router.use(). Only runs on routes mounted to that router.

Route-level

Passed as arguments to a specific route handler. Only runs for that exact route.

Error-handling

Exactly 4 arguments (err, req, res, next). Only called when next(err) is called.

Built-in

express.json(), express.urlencoded(), express.static()

Third-party

morgan, helmet, cors, express-rate-limit, cookie-parser, compression…

Writing Middleware: All Patterns

1. Transform / Attach Data

// Attach a unique request ID for tracing across logs
const { randomUUID } = require('crypto');

const requestId = (req, res, next) => {
  req.id = req.headers['x-request-id'] ?? randomUUID();
  res.set('X-Request-ID', req.id);
  next(); // ← must call next or request hangs
};
app.use(requestId);

2. Guard / Reject Early

// Authentication — verify JWT, attach user to req
const jwt = require('jsonwebtoken');
const { UnauthorizedError } = require('../errors/AppError');

const authenticate = (req, res, next) => {
  const authHeader = req.get('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    return next(new UnauthorizedError('No token provided'));
  }

  const token = authHeader.slice(7);
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next(); // token valid — proceed
  } catch (err) {
    next(new UnauthorizedError('Invalid or expired token'));
  }
};

// Role-based authorisation — build on top of authenticate
const requireRole = (...roles) => (req, res, next) => {
  if (!roles.includes(req.user?.role)) {
    return next(new ForbiddenError(`Requires role: ${roles.join(' or ')}`));
  }
  next();
};

// Usage
router.delete('/users/:id',
  authenticate,               // must be logged in
  requireRole('admin'),      // must be admin
  deleteUserHandler
);

3. Logging / Observability

// Structured request logger (production-grade)
const requestLogger = (req, res, next) => {
  const start = Date.now();

  // 'finish' fires when the response is fully sent
  res.on('finish', () => {
    const duration = Date.now() - start;
    const level = res.statusCode >= 500 ? 'error'
                : res.statusCode >= 400 ? 'warn'
                : 'info';

    console[level](JSON.stringify({
      type:       'request',
      requestId:  req.id,
      method:     req.method,
      path:       req.path,
      status:     res.statusCode,
      duration,
      ip:         req.ip,
      userAgent:  req.get('user-agent'),
      userId:     req.user?.id,
    }));
  });

  next();
};

4. Middleware Factory (Higher-Order Middleware)

// A factory is a function that RETURNS a middleware function.
// This lets you configure middleware with parameters.

const cache = (ttlSeconds) => {
  const store = new Map(); // in production use Redis
  return (req, res, next) => {
    const key = req.originalUrl;
    const cached = store.get(key);
    if (cached) {
      res.set('X-Cache', 'HIT');
      return res.json(cached);
    }

    // Wrap res.json to capture and cache the response
    const originalJson = res.json.bind(res);
    res.json = (data) => {
      store.set(key, data);
      setTimeout(() => store.delete(key), ttlSeconds * 1000);
      res.set('X-Cache', 'MISS');
      return originalJson(data);
    };
    next();
  };
};

// Usage — cache this route's response for 60 seconds
router.get('/stats', cache(60), statsHandler);

Error-Handling Middleware — The Full Picture

This is the most misunderstood part of Express. Error middleware is fundamentally different from regular middleware:

PropertyRegular MiddlewareError Middleware
Signature(req, res, next) — 3 args(err, req, res, next)exactly 4
When it runsOn every matching request in orderOnly when next(err) is called
How to trigger itNormal request flowCall next(new Error(...)) from any middleware or handler
Registration orderAnywhere sensibleMust be last — after all routes
Typical useParse, log, authenticate, validateFormat + send error responses, log errors
The 4-Argument Rule
Express identifies error middleware solely by the number of arguments. If you write (err, req, res) with 3 args, Express treats it as regular middleware and your errors will never reach it. The next parameter must be declared even if you never call it.

Triggering the Error Handler

// Three ways to reach the error handler:

// 1. next(err) — synchronous middleware/routes
app.get('/users/:id', (req, res, next) => {
  const user = db.findUser(req.params.id);
  if (!user) return next(new NotFoundError('User'));
  res.json(user);
});

// 2. throw inside an async handler (with express-async-errors installed)
app.get('/users/:id', async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) throw new NotFoundError('User'); // caught by express-async-errors
  res.json(user);
});

// 3. next(err) in async middleware (without express-async-errors)
app.use(async (req, res, next) => {
  try {
    await somethingAsync();
    next();
  } catch (err) {
    next(err); // forward to error handler
  }
});

A Production-Grade Error Handler

// src/middleware/errorHandler.js
const { AppError } = require('../errors/AppError');

// Map common third-party error codes to HTTP status codes
const normaliseError = (err) => {
  // Prisma unique constraint (duplicate email, etc.)
  if (err.code === 'P2002') {
    return new AppError(`${err.meta?.target} already exists`, 409, 'CONFLICT');
  }
  // Prisma record not found
  if (err.code === 'P2025') {
    return new AppError('Record not found', 404, 'NOT_FOUND');
  }
  // JWT errors
  if (err.name === 'JsonWebTokenError') {
    return new AppError('Invalid token', 401, 'UNAUTHORIZED');
  }
  if (err.name === 'TokenExpiredError') {
    return new AppError('Token expired', 401, 'TOKEN_EXPIRED');
  }
  // express.json() body parse error
  if (err.type === 'entity.parse.failed') {
    return new AppError('Invalid JSON body', 400, 'INVALID_JSON');
  }
  return err;
};

const errorHandler = (err, req, res, next) => {  // ← 4 args, MUST be 4
  const error = normaliseError(err);

  // Log everything — full context for debugging
  const logPayload = {
    type:       'error',
    requestId:  req.id,
    method:     req.method,
    path:       req.path,
    status:     error.statusCode ?? 500,
    message:    error.message,
    stack:      error.stack,
    userId:     req.user?.id,
  };

  if (!error.isOperational) {
    // Non-operational = programmer bug — log at highest severity
    console.error('[FATAL]', JSON.stringify(logPayload));
  } else {
    console.warn(JSON.stringify(logPayload));
  }

  // Never send error details for unknown (programmer) errors
  if (!error.isOperational) {
    return res.status(500).json({
      error: {
        code:      'INTERNAL_ERROR',
        message:   'An unexpected error occurred',
        requestId: req.id,  // include so users can report it
      }
    });
  }

  res.status(error.statusCode).json({
    error: {
      code:    error.code,
      message: error.message,
      ...(error.details && { details: error.details }),
    }
  });
};

module.exports = errorHandler;

Async Middleware — The Critical Problem

Express was built before async/await. It does not automatically catch rejected promises from async middleware or routes. If you forget to handle this, errors silently disappear:

❌ Silent failure
app.get('/users',
  async (req, res) => {
    // if this throws...
    const u = await db.findAll();
    // ...Express never sees it
    // error handler NOT called
    // request hangs forever
  }
);
✅ Option A: try/catch
app.get('/users',
  async (req, res, next) => {
    try {
      const u = await db.findAll();
      res.json(u);
    } catch(err) {
      next(err); // forward!
    }
  }
);
// ✅ Option B: asyncHandler wrapper — eliminates try/catch boilerplate
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/users', asyncHandler(async (req, res) => {
  const u = await db.findAll(); // throw auto-forwarded to error handler
  res.json(u);
}));
// ✅ Option C: express-async-errors (recommended for new projects)
// npm install express-async-errors
// Add ONE line at the top of app.js:
require('express-async-errors'); // patches Express to catch all async rejections

// Now async handlers work natively — no wrapper needed
app.get('/users', async (req, res) => {
  const u = await db.findAll(); // throw → error handler. Just works.
  res.json(u);
});

Essential Third-Party Middleware Stack

npm install express-async-errors morgan helmet cors express-rate-limit \
            compression cookie-parser hpp
// src/app.js — production middleware stack in recommended order
require('express-async-errors');       // must be first

const express      = require('express');
const morgan       = require('morgan');
const helmet       = require('helmet');
const cors         = require('cors');
const rateLimit    = require('express-rate-limit');
const compression  = require('compression');
const cookieParser = require('cookie-parser');
const hpp          = require('hpp'); // HTTP Parameter Pollution protection

const app = express();

// 1. Trust proxy (must be first setting if behind load balancer)
app.set('trust proxy', 1);

// 2. Security headers (helmet before everything else)
app.use(helmet());

// 3. CORS — configure allowed origins explicitly
app.use(cors({
  origin: (origin, cb) => {
    const allowed = process.env.ALLOWED_ORIGINS.split(',');
    if (!origin || allowed.includes(origin)) cb(null, true);
    else cb(new Error('Not allowed by CORS'));
  },
  credentials: true,
  methods:     ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
}));

// 4. Rate limiting — global
app.use(rateLimit({
  windowMs:       15 * 60 * 1000, // 15 minutes
  max:            200,
  standardHeaders: true,           // send RateLimit-* headers
  legacyHeaders:   false,
}));

// 5. Body parsers (after rate limit — don't parse huge bodies of rate-limited requests)
app.use(express.json({ limit: '10kb' }));       // 10kb body limit prevents DoS
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
app.use(cookieParser(process.env.COOKIE_SECRET));

// 6. HTTP Parameter Pollution protection
// Prevents ?sort=asc&sort=desc&sort=name polluting req.query.sort
app.use(hpp());

// 7. Response compression
app.use(compression());

// 8. Request logging (after body parse so logger can read req.body if needed)
const logFormat = process.env.NODE_ENV === 'production' ? 'combined' : 'dev';
app.use(morgan(logFormat));

// 9. Request ID
app.use(requestId);

// ── Routes ────────────────────────────────────
app.use('/api/v1', require('./routes'));

// ── 404 (after all routes) ────────────────────
app.use((req, res) =>
  res.status(404).json({ error: { code: 'NOT_FOUND', message: `Cannot ${req.method} ${req.path}` }})
);

// ── Error handler (must be LAST, must have 4 args) ─
app.use(require('./middleware/errorHandler'));

module.exports = app;

Middleware Execution Order — The Key Rules

  1. Middleware executes in the order it is registered with app.use() or the route method
  2. If a middleware calls next(), the next matching middleware runs. If it sends a response, the chain stops
  3. If a middleware calls next(err), Express skips all remaining regular middleware and jumps directly to the nearest error handler
  4. Error handlers must be registered after all routes — they can only catch errors from middleware registered before them
  5. You can have multiple error handlers in sequence — useful for separating logging from response sending

Multiple Error Handlers — Separation of Concerns

// Two error handlers in sequence is valid and useful

// First: logging
app.use((err, req, res, next) => {
  logger.error({ err, requestId: req.id }); // send to Sentry, Datadog etc.
  next(err); // ← pass to the next error handler
});

// Second: response formatting
app.use((err, req, res, next) => {
  res.status(err.statusCode ?? 500).json({ error: { message: err.message } });
});

🧠 Check Your Understanding


Go Deeper

Primary source: Express.js — Using Middleware

Error handling: Express.js — Error Handling Guide — read the entire page.

Security package: helmetjs.github.io — understand what each header does.

Ask your teacher: "Walk me through exactly what happens when next(err) is called inside a route handler." or "Why does middleware order matter? Show me a bug caused by wrong order."