Back to blog

Express.js Middleware Deep Dive

expressnodejsbackendjavascriptmiddleware
Express.js Middleware Deep Dive

In the previous post, you built your first Express server and got a taste of middleware. Now it's time to go deeper.

Middleware is the backbone of every Express application. Understanding how it works — and how to wield it effectively — is what separates beginner Express code from production-grade code.

What You'll Learn

Learning Outcomes:
✅ Understand exactly how middleware works under the hood
✅ Know the difference between application-level and router-level middleware
✅ Create custom middleware for logging, authentication, and validation
✅ Integrate popular third-party middleware (morgan, cors, helmet, compression)
✅ Implement centralized error handling with custom error classes
✅ Handle async errors properly without crashing your server

How Middleware Works

A middleware function is any function with this signature:

function middleware(req, res, next) {
  // 1. Do something (modify req/res, validate, log, etc.)
  // 2. Either call next() OR send a response
  next();
}

When a request comes in, Express runs each middleware function in the order you registered it. Each middleware either:

  • Calls next() to pass control to the next middleware/route
  • Sends a response to end the cycle (res.json(), res.send(), etc.)

The Execution Flow

If any middleware sends a response, the chain stops — subsequent middleware and route handlers are skipped.

Application-level vs Router-level

Application-level middleware runs on every request (or a path prefix):

const app = express();
 
// Runs on ALL requests
app.use(express.json());
 
// Runs only on requests to /api/*
app.use('/api', authMiddleware);

Router-level middleware is scoped to an Express Router instance:

const router = express.Router();
 
// Only applies to this router's routes
router.use(validateApiKey);
 
router.get('/users', getUsers);
router.post('/users', createUser);

Router-level middleware is the key to building modular, maintainable apps. We'll use it heavily in the next post.

Built-in Middleware

Express 4.x includes several built-in middleware functions — no extra packages needed.

express.json()

Parses incoming requests with JSON bodies. Without it, req.body is undefined.

app.use(express.json());
 
app.post('/todos', (req, res) => {
  console.log(req.body); // { title: "Learn Express" }
  res.status(201).json({ message: 'Created' });
});

You can configure limits:

app.use(express.json({ limit: '10mb' })); // Max body size

express.urlencoded()

Parses URL-encoded form data (application/x-www-form-urlencoded), the format HTML forms use by default.

app.use(express.urlencoded({ extended: true }));
 
app.post('/contact', (req, res) => {
  const { name, email, message } = req.body;
  // Process form data
});

The extended: true option enables parsing of nested objects. Use false for simple key-value pairs.

express.static()

Serves static files (HTML, CSS, images, etc.) from a directory:

// Serve files from "public" folder
app.use(express.static('public'));
 
// With a URL prefix
app.use('/assets', express.static('public'));
 
// With options
app.use(express.static('public', {
  maxAge: '1d',     // Cache for 1 day
  etag: false,      // Disable ETags
}));

express.raw() and express.text()

For specialized use cases:

// Parse raw binary data (e.g., file uploads, webhooks)
app.use('/webhooks', express.raw({ type: 'application/json' }));
 
// Parse plain text bodies
app.use(express.text({ type: 'text/plain' }));

Custom Middleware

This is where the real power of Express comes in. You can create middleware for any cross-cutting concern in your application.

Request Logging Middleware

A simple logger that records method, URL, and duration:

function requestLogger(req, res, next) {
  const start = Date.now();
 
  // Listen for the response to finish
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(
      `${req.method} ${req.originalUrl} ${res.statusCode} - ${duration}ms`
    );
  });
 
  next();
}
 
app.use(requestLogger);

Using res.on('finish') is key — it lets you log the status code after the response is actually sent, not before.

Request ID Middleware

Assign a unique ID to each request for tracing:

const { randomUUID } = require('crypto');
 
function requestId(req, res, next) {
  req.id = randomUUID();
  res.set('X-Request-ID', req.id);
  next();
}
 
app.use(requestId);
 
// Now every req.id is unique — useful for log correlation

Authentication Middleware

Protect routes by verifying a token:

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
 
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      success: false,
      message: 'No token provided',
    });
  }
 
  const token = authHeader.split(' ')[1];
 
  try {
    // In a real app, verify a JWT here
    // const decoded = jwt.verify(token, process.env.JWT_SECRET);
    // req.user = decoded;
 
    // For demo purposes:
    if (token !== 'demo-token') {
      throw new Error('Invalid token');
    }
    req.user = { id: 1, email: 'user@example.com' };
    next();
  } catch (error) {
    return res.status(401).json({
      success: false,
      message: 'Invalid or expired token',
    });
  }
}
 
// Apply to specific routes
app.get('/api/profile', authenticate, (req, res) => {
  res.json({ user: req.user });
});
 
// Or apply to all routes under a prefix
app.use('/api/protected', authenticate);

Request Validation Middleware

Validate required fields before reaching your route handler:

function validateTodo(req, res, next) {
  const { title } = req.body;
 
  const errors = [];
 
  if (!title) {
    errors.push('title is required');
  } else if (typeof title !== 'string') {
    errors.push('title must be a string');
  } else if (title.length > 200) {
    errors.push('title must be 200 characters or fewer');
  }
 
  if (errors.length > 0) {
    return res.status(400).json({
      success: false,
      message: 'Validation failed',
      errors,
    });
  }
 
  next();
}
 
app.post('/api/todos', validateTodo, (req, res) => {
  // We know req.body.title is valid here
  const todo = createTodo(req.body.title);
  res.status(201).json({ success: true, data: todo });
});

Middleware Factories

A middleware factory is a function that returns middleware. This is useful when your middleware needs to be configurable:

// Factory: create middleware that checks for specific roles
function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ message: 'Not authenticated' });
    }
 
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        message: `Requires one of these roles: ${roles.join(', ')}`,
      });
    }
 
    next();
  };
}
 
// Usage
app.delete('/api/users/:id', authenticate, requireRole('admin'), deleteUser);
app.get('/api/reports', authenticate, requireRole('admin', 'manager'), getReports);
// Factory: rate limiter per route
function rateLimit(maxRequests, windowMs) {
  const requests = new Map();
 
  return (req, res, next) => {
    const key = req.ip;
    const now = Date.now();
    const windowStart = now - windowMs;
 
    // Clean up old entries
    const userRequests = (requests.get(key) || []).filter(
      time => time > windowStart
    );
 
    if (userRequests.length >= maxRequests) {
      return res.status(429).json({
        success: false,
        message: 'Too many requests. Please try again later.',
      });
    }
 
    userRequests.push(now);
    requests.set(key, userRequests);
    next();
  };
}
 
// 5 requests per minute for login
app.post('/api/auth/login', rateLimit(5, 60 * 1000), loginHandler);
 
// 100 requests per minute for general API
app.use('/api', rateLimit(100, 60 * 1000));

Third-party Middleware

These packages handle common production concerns, battle-tested and ready to use.

morgan — HTTP Request Logger

Morgan logs HTTP requests in a standardized format. Much more powerful than a hand-rolled logger.

npm install morgan
const morgan = require('morgan');
 
// Pre-defined formats
app.use(morgan('dev'));       // Colorized, concise (great for dev)
app.use(morgan('combined')); // Apache combined log (great for prod)
app.use(morgan('short'));    // Shorter than combined
app.use(morgan('tiny'));     // Minimal output
 
// Custom format
app.use(morgan(':method :url :status :response-time ms - :res[content-length]'));

Output of morgan('dev'):

GET /api/todos 200 4.823 ms - 245
POST /api/todos 201 2.156 ms - 89
GET /api/todos/999 404 1.203 ms - 47

Write logs to a file in production:

const fs = require('fs');
const path = require('path');
 
// Create a write stream
const accessLogStream = fs.createWriteStream(
  path.join(__dirname, 'logs', 'access.log'),
  { flags: 'a' } // Append mode
);
 
app.use(morgan('combined', { stream: accessLogStream }));

cors — Cross-Origin Resource Sharing

Controls which domains can access your API from a browser.

npm install cors
const cors = require('cors');
 
// Allow ALL origins (not safe for production APIs with sensitive data)
app.use(cors());
 
// Allow specific origins
app.use(cors({
  origin: 'https://myapp.com',
}));
 
// Allow multiple origins
app.use(cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
}));
 
// Dynamic origin check
app.use(cors({
  origin: (origin, callback) => {
    const allowedOrigins = ['https://myapp.com', 'https://staging.myapp.com'];
 
    // Allow requests with no origin (e.g., mobile apps, curl)
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,         // Allow cookies / Authorization headers
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

Apply CORS per route for fine-grained control:

// Public endpoint
app.get('/api/public', cors(), getPublicData);
 
// Restricted endpoint
app.get('/api/admin', cors({ origin: 'https://admin.myapp.com' }), getAdminData);

helmet — Security Headers

Sets HTTP security headers to protect against common web vulnerabilities. Use it in every production Express app.

npm install helmet
const helmet = require('helmet');
 
// Enable all defaults (recommended)
app.use(helmet());

helmet() sets these headers by default:

HeaderProtection
Content-Security-PolicyXSS, clickjacking
X-DNS-Prefetch-ControlDNS prefetch leaks
X-Frame-OptionsClickjacking
X-XSS-ProtectionLegacy XSS filter
X-Content-Type-OptionsMIME sniffing
Strict-Transport-SecurityForces HTTPS
Referrer-PolicyReferrer leaks

Customize individual protections:

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", 'cdn.jsdelivr.net'],
      styleSrc: ["'self'", "'unsafe-inline'"],
    },
  },
  crossOriginEmbedderPolicy: false, // Disable if embedding content
}));

compression — Response Compression

Gzip/Brotli compress responses, dramatically reducing payload size.

npm install compression
const compression = require('compression');
 
// Compress all responses
app.use(compression());
 
// Only compress responses larger than 1KB
app.use(compression({ threshold: 1024 }));
 
// Custom filter — don't compress certain content types
app.use(compression({
  filter: (req, res) => {
    // Don't compress if client explicitly opts out
    if (req.headers['x-no-compression']) return false;
    // Use default filter otherwise
    return compression.filter(req, res);
  },
}));

Response size comparison (typical JSON payload):

  • Without compression: ~50KB
  • With gzip: ~8KB (84% smaller)

Parses the Cookie header and populates req.cookies:

npm install cookie-parser
const cookieParser = require('cookie-parser');
 
app.use(cookieParser());
 
// Optionally, sign cookies with a secret
app.use(cookieParser('my-secret-key'));
 
// Read cookies
app.get('/profile', (req, res) => {
  console.log(req.cookies.sessionId);       // Unsigned cookie
  console.log(req.signedCookies.userId);    // Signed cookie
});
 
// Set cookies
app.post('/login', (req, res) => {
  res.cookie('sessionId', 'abc123', {
    httpOnly: true,   // Not accessible via JS
    secure: true,     // HTTPS only
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    sameSite: 'strict',
  });
  res.json({ message: 'Logged in' });
});

Error Handling Middleware

Error handling is the most important and most misunderstood part of Express middleware. Let's get it right.

The Error Handler Signature

Error-handling middleware takes four parameters — the extra err parameter is how Express knows it's an error handler:

// Regular middleware: 3 params
function middleware(req, res, next) { ... }
 
// Error handler: 4 params (err MUST be first)
function errorHandler(err, req, res, next) { ... }

Critical rule: Always register error-handling middleware LAST, after all routes and other middleware.

Triggering Error Handlers

Pass an error to next() to skip all remaining regular middleware and jump to the error handler:

app.get('/users/:id', (req, res, next) => {
  try {
    const user = getUserById(req.params.id);
    if (!user) {
      // Pass an error to next()
      const err = new Error('User not found');
      err.status = 404;
      return next(err);
    }
    res.json(user);
  } catch (error) {
    next(error); // Pass unexpected errors to error handler
  }
});

Custom Error Classes

Custom error classes make it easy to distinguish operational errors (bad input, not found) from programming errors (bugs):

// errors.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.isOperational = true; // Expected, user-facing errors
    Error.captureStackTrace(this, this.constructor);
  }
}
 
class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404);
  }
}
 
class ValidationError extends AppError {
  constructor(message, errors = []) {
    super(message, 400);
    this.errors = errors;
  }
}
 
class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401);
  }
}
 
class ForbiddenError extends AppError {
  constructor(message = 'Access denied') {
    super(message, 403);
  }
}
 
module.exports = { AppError, NotFoundError, ValidationError, UnauthorizedError, ForbiddenError };

Centralized Error Handler

One error handler to rule them all:

const { AppError } = require('./errors');
 
function errorHandler(err, req, res, next) {
  // Default to 500 if no status code set
  const statusCode = err.statusCode || err.status || 500;
  const isProduction = process.env.NODE_ENV === 'production';
 
  // Log the error (always)
  if (statusCode >= 500) {
    console.error('💥 Server error:', {
      message: err.message,
      stack: err.stack,
      url: req.originalUrl,
      method: req.method,
    });
  }
 
  // Operational errors: safe to expose to client
  if (err.isOperational) {
    return res.status(statusCode).json({
      success: false,
      message: err.message,
      ...(err.errors && { errors: err.errors }), // Include field errors if present
    });
  }
 
  // Programming/unexpected errors: hide details in production
  res.status(500).json({
    success: false,
    message: isProduction ? 'Something went wrong' : err.message,
    ...(isProduction ? {} : { stack: err.stack }),
  });
}
 
// Register LAST
app.use(errorHandler);

Using the custom errors in routes:

const { NotFoundError, ValidationError, ForbiddenError } = require('./errors');
 
app.get('/api/todos/:id', (req, res, next) => {
  try {
    const todo = todos.find(t => t.id === parseInt(req.params.id));
    if (!todo) throw new NotFoundError('Todo');
    res.json({ success: true, data: todo });
  } catch (error) {
    next(error);
  }
});
 
app.post('/api/todos', (req, res, next) => {
  try {
    const { title } = req.body;
    if (!title) {
      throw new ValidationError('Validation failed', ['title is required']);
    }
    const todo = { id: Date.now(), title, completed: false };
    res.status(201).json({ success: true, data: todo });
  } catch (error) {
    next(error);
  }
});

Async Error Handling

Express 4.x does not automatically catch errors thrown inside async functions. You must catch them yourself:

// ❌ Wrong — unhandled promise rejection crashes the server
app.get('/api/users', async (req, res) => {
  const users = await User.findAll(); // If this throws, Express won't catch it
  res.json(users);
});
 
// ✅ Option 1 — try/catch in every async handler
app.get('/api/users', async (req, res, next) => {
  try {
    const users = await User.findAll();
    res.json(users);
  } catch (error) {
    next(error); // Forward to error handler
  }
});
 
// ✅ Option 2 — asyncHandler wrapper (eliminates boilerplate)
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}
 
app.get('/api/users', asyncHandler(async (req, res) => {
  const users = await User.findAll();
  res.json(users);
}));
 
app.post('/api/todos', asyncHandler(async (req, res) => {
  const todo = await Todo.create(req.body);
  res.status(201).json({ success: true, data: todo });
}));

The asyncHandler wrapper is a simple but powerful pattern. Many teams extract it to a shared utilities file and use it everywhere.

Note: Express 5 (currently in beta) automatically handles async errors, eliminating the need for asyncHandler. For Express 4, the wrapper is the cleanest solution.

Handling 404s

A "not found" handler catches requests to undefined routes. Place it after all your routes but before the error handler:

// All routes defined above...
 
// 404 — no route matched
app.use((req, res, next) => {
  next(new NotFoundError(`Route ${req.method} ${req.originalUrl}`));
});
 
// Error handler — must be last
app.use(errorHandler);

Hands-on Project: Upgrading the Todo API

Let's upgrade the Todo API from Post 1 with production-grade middleware.

Project Structure

express-todo-api/
├── index.js
├── errors.js        # Custom error classes
├── middleware/
│   ├── auth.js      # Authentication
│   ├── logger.js    # Request logging
│   └── validate.js  # Input validation
└── package.json

errors.js

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}
 
class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404);
  }
}
 
class ValidationError extends AppError {
  constructor(message, errors = []) {
    super(message, 400);
    this.errors = errors;
  }
}
 
class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401);
  }
}
 
module.exports = { AppError, NotFoundError, ValidationError, UnauthorizedError };

middleware/logger.js

function requestLogger(req, res, next) {
  const start = Date.now();
 
  res.on('finish', () => {
    const duration = Date.now() - start;
    const color =
      res.statusCode >= 500 ? '\x1b[31m' : // Red
      res.statusCode >= 400 ? '\x1b[33m' : // Yellow
      res.statusCode >= 300 ? '\x1b[36m' : // Cyan
      '\x1b[32m';                           // Green
    const reset = '\x1b[0m';
 
    console.log(
      `${color}${req.method}${reset} ${req.originalUrl} ` +
      `${color}${res.statusCode}${reset} ${duration}ms`
    );
  });
 
  next();
}
 
module.exports = requestLogger;

middleware/auth.js

const { UnauthorizedError } = require('../errors');
 
function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
 
  if (!authHeader?.startsWith('Bearer ')) {
    return next(new UnauthorizedError('No token provided'));
  }
 
  const token = authHeader.split(' ')[1];
 
  try {
    // Replace with real JWT verification:
    // const decoded = jwt.verify(token, process.env.JWT_SECRET);
    // req.user = decoded;
 
    if (token !== 'demo-token') throw new Error('Invalid token');
    req.user = { id: 1, email: 'demo@example.com' };
    next();
  } catch {
    next(new UnauthorizedError('Invalid or expired token'));
  }
}
 
module.exports = authenticate;

middleware/validate.js

const { ValidationError } = require('../errors');
 
function validateTodo(req, res, next) {
  const { title } = req.body;
  const errors = [];
 
  if (!title) {
    errors.push('title is required');
  } else if (typeof title !== 'string') {
    errors.push('title must be a string');
  } else if (title.trim().length === 0) {
    errors.push('title cannot be empty');
  } else if (title.length > 200) {
    errors.push('title must be 200 characters or fewer');
  }
 
  if (errors.length > 0) {
    return next(new ValidationError('Validation failed', errors));
  }
 
  // Sanitize
  req.body.title = req.body.title.trim();
  next();
}
 
module.exports = { validateTodo };

index.js — Full Upgraded App

const express = require('express');
const morgan = require('morgan');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
 
const requestLogger = require('./middleware/logger');
const authenticate = require('./middleware/auth');
const { validateTodo } = require('./middleware/validate');
const { AppError, NotFoundError } = require('./errors');
 
const app = express();
const PORT = process.env.PORT || 3000;
 
// ─── Security & Performance ─────────────────────────────────────────────
app.use(helmet());
app.use(compression());
 
// ─── CORS ───────────────────────────────────────────────────────────────
app.use(cors({
  origin: process.env.ALLOWED_ORIGIN || '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));
 
// ─── Body Parsing ────────────────────────────────────────────────────────
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
 
// ─── Logging ─────────────────────────────────────────────────────────────
app.use(morgan('dev'));         // HTTP request logging
app.use(requestLogger);        // Custom timing logger
 
// ─── In-memory Database ─────────────────────────────────────────────────
let todos = [
  { id: 1, title: 'Learn Express.js', completed: false, createdAt: new Date() },
  { id: 2, title: 'Master Middleware', completed: false, createdAt: new Date() },
];
let nextId = 3;
 
// ─── Public Routes ───────────────────────────────────────────────────────
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date() });
});
 
// ─── Protected Routes ────────────────────────────────────────────────────
// GET /api/todos
app.get('/api/todos', authenticate, (req, res) => {
  const { completed } = req.query;
 
  let result = todos;
  if (completed !== undefined) {
    result = todos.filter(t => t.completed === (completed === 'true'));
  }
 
  res.json({ success: true, data: result, total: result.length });
});
 
// GET /api/todos/:id
app.get('/api/todos/:id', authenticate, (req, res, next) => {
  const id = parseInt(req.params.id);
  if (isNaN(id)) {
    return next(new AppError('Invalid ID format', 400));
  }
 
  const todo = todos.find(t => t.id === id);
  if (!todo) return next(new NotFoundError('Todo'));
 
  res.json({ success: true, data: todo });
});
 
// POST /api/todos
app.post('/api/todos', authenticate, validateTodo, (req, res) => {
  const todo = {
    id: nextId++,
    title: req.body.title,
    completed: false,
    createdAt: new Date(),
  };
  todos.push(todo);
  res.status(201).json({ success: true, data: todo });
});
 
// PUT /api/todos/:id
app.put('/api/todos/:id', authenticate, (req, res, next) => {
  const id = parseInt(req.params.id);
  const index = todos.findIndex(t => t.id === id);
  if (index === -1) return next(new NotFoundError('Todo'));
 
  const { title, completed } = req.body;
  if (title !== undefined) todos[index].title = title.trim();
  if (completed !== undefined) todos[index].completed = Boolean(completed);
 
  res.json({ success: true, data: todos[index] });
});
 
// DELETE /api/todos/:id
app.delete('/api/todos/:id', authenticate, (req, res, next) => {
  const id = parseInt(req.params.id);
  const index = todos.findIndex(t => t.id === id);
  if (index === -1) return next(new NotFoundError('Todo'));
 
  const [deleted] = todos.splice(index, 1);
  res.json({ success: true, data: deleted });
});
 
// ─── 404 Handler ─────────────────────────────────────────────────────────
app.use((req, res, next) => {
  next(new NotFoundError(`Route ${req.method} ${req.originalUrl}`));
});
 
// ─── Error Handler (always last) ─────────────────────────────────────────
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const isProduction = process.env.NODE_ENV === 'production';
 
  if (statusCode >= 500) {
    console.error('💥 Error:', err.message, '\n', err.stack);
  }
 
  if (err.isOperational) {
    return res.status(statusCode).json({
      success: false,
      message: err.message,
      ...(err.errors && { errors: err.errors }),
    });
  }
 
  res.status(500).json({
    success: false,
    message: isProduction ? 'Internal server error' : err.message,
  });
});
 
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Install dependencies:

npm install morgan cors helmet compression cookie-parser

Testing the Upgraded API

# Health check (no auth needed)
curl http://localhost:3000/health
 
# Get todos (requires auth)
curl http://localhost:3000/api/todos \
  -H "Authorization: Bearer demo-token"
 
# Create a todo
curl -X POST http://localhost:3000/api/todos \
  -H "Authorization: Bearer demo-token" \
  -H "Content-Type: application/json" \
  -d '{"title":"Master middleware"}'
 
# Filter by completed
curl "http://localhost:3000/api/todos?completed=false" \
  -H "Authorization: Bearer demo-token"
 
# Trigger 401
curl http://localhost:3000/api/todos
 
# Trigger 400 validation error
curl -X POST http://localhost:3000/api/todos \
  -H "Authorization: Bearer demo-token" \
  -H "Content-Type: application/json" \
  -d '{}'
 
# Trigger 404
curl http://localhost:3000/api/todos/9999 \
  -H "Authorization: Bearer demo-token"

Middleware Order: The Golden Rules

Getting middleware order wrong is one of the most common Express mistakes. Here's the rule:

Why this order matters:

  • Security headers should be set on every response, including error responses — so they go first
  • Compression should wrap the full response — it goes near the top
  • Body parsers must run before any route or middleware that reads req.body
  • Logging should capture all requests, including those that hit auth or route errors
  • Auth must run before routes that need user data
  • Error handler must be last — it handles errors thrown by everything above it

Summary and Key Takeaways

What We Covered:
✅ Middleware is functions with (req, res, next) that form a processing pipeline
✅ Application-level middleware applies globally; router-level middleware is scoped
✅ Built-in middleware: express.json(), express.urlencoded(), express.static()
✅ Custom middleware for logging, auth, validation, and rate limiting
✅ Middleware factories return configured middleware — great for reusable, parameterized logic
✅ Third-party: morgan for logging, cors for CORS, helmet for security headers, compression for gzip
✅ Error handlers have 4 params (err, req, res, next) and must be registered last
✅ Use custom error classes to distinguish operational errors from bugs
✅ Wrap async handlers with asyncHandler or try/catch to avoid unhandled promise rejections

The Golden Rule: Middleware order matters. Security → Parsing → Logging → Auth → Routes → 404 → Error Handler.

Practice Exercises

  1. Add morgan file logging — Write access logs to logs/access.log in Apache combined format, while keeping dev format in the console.

  2. IP-based rate limiter — Build a middleware factory that limits requests per IP with configurable window and max requests. Use a Map to track request timestamps.

  3. Request correlation — Add a requestId middleware that generates a UUID, attaches it to req and adds it as a response header. Update the logger to include the request ID.

  4. Conditional CORS — Configure CORS to allow localhost:3000 and localhost:5173 in development, and only https://myapp.com in production, based on NODE_ENV.

  5. Graceful error messages — Extend the error handler to catch Mongoose ValidationError and CastError (or simulate them) and return clean 400/404 responses instead of exposing Mongoose internals.

What's Next?

In the next post, we'll tackle Routing and Request Handling in depth:

  • Organizing routes with Express Router
  • Nested and modular route structures
  • Validating requests with express-validator
  • Handling file uploads with multer
  • RESTful API design best practices

The patterns we covered here — especially the error handling and middleware factories — will be used throughout the rest of the series.

Additional Resources

Next Steps in This Series:

  • Post 3: Routing and Request Handling
  • Post 4: Working with MongoDB and Mongoose
  • Post 5: Working with PostgreSQL and Prisma

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.