MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Express.js Middleware: Building a Custom Framework

Understand Express middleware: composition, error handling, and building reusable middleware.

ExpressNode.jsMiddlewareBackend

By MinhVo

Introduction

Express.js middleware is the backbone of the most popular Node.js web framework. Middleware functions have access to the request and response objects, and the next middleware function in the application's request-response cycle. Understanding how middleware works at a deep level—and how to build your own middleware framework from scratch—is essential for any Node.js developer. In this comprehensive guide, we will explore the middleware pattern, build a custom middleware framework, and implement production-grade middleware for authentication, logging, rate limiting, and error handling.

The middleware pattern is deceptively simple: a function that receives a request, processes it, and either responds or passes control to the next middleware. This simplicity belies incredible power. By composing small, focused middleware functions, you can build complex application logic from reusable, testable building blocks. Every Express application you have ever written is fundamentally a pipeline of middleware functions, whether you realize it or not.

Node.js Server Architecture

Understanding Middleware: Core Concepts

A middleware function in Express follows a specific signature: (req, res, next) => void. The req object represents the incoming HTTP request, res represents the outgoing HTTP response, and next is a function that passes control to the next middleware in the stack. If a middleware does not call next() and does not send a response, the request will hang indefinitely.

Middleware functions execute in the order they are registered. This ordering is critical: authentication middleware must run before route handlers, logging middleware should run before other middleware to capture the full request lifecycle, and error handling middleware must be registered last to catch errors from all preceding middleware.

There are several categories of middleware in a typical Express application. Application-level middleware are bound to the app instance and run for all requests. Router-level middleware are bound to specific routers and run only for routes handled by that router. Error-handling middleware have a special signature with four parameters: (err, req, res, next). Built-in middleware like express.json() and express.static() are provided by Express itself. Third-party middleware like cors, helmet, and morgan are installed as npm packages.

The next() function is the key to middleware composition. Calling next() without arguments passes control to the next middleware. Calling next(err) skips all remaining non-error middleware and passes control to the next error-handling middleware. This mechanism enables centralized error handling that catches errors from any middleware in the chain.

Sub-applications in Express allow you to mount middleware and routes at specific URL prefixes. This enables modular application architecture where different concerns (authentication, API versioning, admin panels) are encapsulated in separate sub-applications mounted at different paths.

Middleware Pipeline

Architecture and Design Patterns

Middleware Pipeline Pattern

The middleware pipeline is a chain of responsibility where each middleware can process the request, modify it, and pass control to the next middleware. This pattern enables separation of concerns: each middleware handles a single responsibility, and complex behavior emerges from composition.

Composability Pattern

Middleware functions should be composable—small, focused functions that can be combined to create complex behavior. A logging middleware logs request details, an authentication middleware validates tokens, and a validation middleware checks request data. Together, they form a complete request processing pipeline.

Error Propagation Pattern

Errors in middleware are propagated to error-handling middleware via next(err). This centralizes error handling and ensures consistent error responses. Error-handling middleware can log errors, send appropriate HTTP responses, and perform cleanup operations.

Conditional Middleware Pattern

Sometimes middleware should only run for certain requests. Express supports conditional middleware through route parameters, middleware factories that return middleware based on configuration, and sub-application mounting.

Middleware Factory Pattern

Middleware factories are functions that return middleware functions configured with specific options. This pattern enables reusable middleware that can be configured differently for different routes or environments.

Step-by-Step Implementation

Let us build a custom middleware framework from scratch to understand the internals of how Express middleware works.

First, implement the core middleware engine:

type NextFunction = (error?: Error) => void;
type RequestHandler = (req: IncomingMessage, res: ServerResponse, next: NextFunction) => void;
type ErrorHandler = (err: Error, req: IncomingMessage, res: ServerResponse, next: NextFunction) => void;
 
class MiddlewareEngine {
  private middlewares: Array<{ path: string; handler: RequestHandler | ErrorHandler; isError: boolean }> = [];
 
  use(pathOrHandler: string | RequestHandler, handler?: RequestHandler | ErrorHandler): void {
    if (typeof pathOrHandler === 'string' && handler) {
      this.middlewares.push({ path: pathOrHandler, handler, isError: handler.length === 4 });
    } else if (typeof pathOrHandler === 'function') {
      this.middlewares.push({ path: '/', handler: pathOrHandler, isError: pathOrHandler.length === 4 });
    }
  }
 
  async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
    let index = 0;
 
    const next = (error?: Error): void => {
      if (res.writableEnded) return;
 
      while (index < this.middlewares.length) {
        const { path, handler, isError } = this.middlewares[index++];
 
        // Check if the middleware matches the request path
        if (!this.pathMatches(req.url || '/', path)) continue;
 
        // Error handlers only run when there's an error
        if (isError && !error) continue;
        if (!isError && error) continue;
 
        try {
          if (isError) {
            (handler as ErrorHandler)(error!, req, res, next);
          } else {
            (handler as RequestHandler)(req, res, next);
          }
          return;
        } catch (err) {
          next(err as Error);
          return;
        }
      }
    };
 
    next();
  }
 
  private pathMatches(requestPath: string, middlewarePath: string): boolean {
    if (middlewarePath === '/') return true;
    return requestPath.startsWith(middlewarePath);
  }
}

Implement common middleware functions:

// JSON body parser middleware
function bodyParser(): RequestHandler {
  return (req, res, next) => {
    if (req.method === 'GET' || req.method === 'HEAD') {
      next();
      return;
    }
 
    let body = '';
    req.on('data', chunk => {
      body += chunk.toString();
      if (body.length > 1e6) { // 1MB limit
        req.destroy();
        res.writeHead(413, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Payload too large' }));
      }
    });
 
    req.on('end', () => {
      try {
        (req as any).body = body ? JSON.parse(body) : {};
        next();
      } catch {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Invalid JSON' }));
      }
    });
  };
}
 
// Request logging middleware
function logger(): RequestHandler {
  return (req, res, next) => {
    const start = Date.now();
    const { method, url } = req;
 
    res.on('finish', () => {
      const duration = Date.now() - start;
      const log = {
        method,
        url,
        status: res.statusCode,
        duration: `${duration}ms`,
        timestamp: new Date().toISOString(),
      };
      console.log(JSON.stringify(log));
    });
 
    next();
  };
}
 
// CORS middleware
function cors(options: { origin?: string | string[]; methods?: string[] } = {}): RequestHandler {
  const { origin = '*', methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] } = options;
 
  return (req, res, next) => {
    const requestOrigin = req.headers.origin || '*';
    const allowedOrigin = Array.isArray(origin)
      ? origin.includes(requestOrigin) ? requestOrigin : origin[0]
      : origin;
 
    res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
    res.setHeader('Access-Control-Allow-Methods', methods.join(', '));
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '86400');
 
    if (req.method === 'OPTIONS') {
      res.writeHead(204);
      res.end();
      return;
    }
 
    next();
  };
}

Implement authentication middleware:

// JWT authentication middleware
function authenticate(options: { secret: string; exclude?: string[] }): RequestHandler {
  return (req, res, next) => {
    // Check excluded paths
    if (options.exclude?.some(path => req.url?.startsWith(path))) {
      next();
      return;
    }
 
    const authHeader = req.headers.authorization;
    if (!authHeader?.startsWith('Bearer ')) {
      res.writeHead(401, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Authentication required' }));
      return;
    }
 
    const token = authHeader.slice(7);
    try {
      const payload = verifyJWT(token, options.secret);
      (req as any).user = payload;
      next();
    } catch (error) {
      res.writeHead(401, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Invalid token' }));
    }
  };
}
 
// Role-based authorization middleware
function authorize(roles: string[]): RequestHandler {
  return (req, res, next) => {
    const user = (req as any).user;
    if (!user) {
      res.writeHead(401, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Authentication required' }));
      return;
    }
 
    if (!roles.includes(user.role)) {
      res.writeHead(403, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Insufficient permissions' }));
      return;
    }
 
    next();
  };
}

Implement rate limiting middleware:

// Sliding window rate limiter
function rateLimit(options: { windowMs: number; max: number; keyGenerator?: (req: IncomingMessage) => string }): RequestHandler {
  const { windowMs, max, keyGenerator = (req) => req.socket.remoteAddress || 'unknown' } = options;
  const clients = new Map<string, { count: number; resetTime: number }>();
 
  // Cleanup expired entries periodically
  setInterval(() => {
    const now = Date.now();
    for (const [key, value] of clients) {
      if (now > value.resetTime) {
        clients.delete(key);
      }
    }
  }, windowMs);
 
  return (req, res, next) => {
    const key = keyGenerator(req);
    const now = Date.now();
    const client = clients.get(key);
 
    if (!client || now > client.resetTime) {
      clients.set(key, { count: 1, resetTime: now + windowMs });
      next();
      return;
    }
 
    if (client.count >= max) {
      res.writeHead(429, {
        'Content-Type': 'application/json',
        'Retry-After': String(Math.ceil((client.resetTime - now) / 1000)),
      });
      res.end(JSON.stringify({ error: 'Too many requests' }));
      return;
    }
 
    client.count++;
    next();
  };
}

Implement error handling middleware:

// Centralized error handler
function errorHandler(): ErrorHandler {
  return (err, req, res, next) => {
    const statusCode = err.statusCode || 500;
    const isProduction = process.env.NODE_ENV === 'production';
 
    const errorResponse = {
      error: {
        message: isProduction && statusCode === 500 ? 'Internal server error' : err.message,
        code: err.code || 'INTERNAL_ERROR',
        ...(isProduction ? {} : { stack: err.stack }),
      },
    };
 
    console.error(`[${new Date().toISOString()}] Error:`, {
      message: err.message,
      statusCode,
      url: req.url,
      method: req.method,
      stack: err.stack,
    });
 
    res.writeHead(statusCode, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(errorResponse));
  };
}
 
// Async error wrapper
function asyncHandler(fn: RequestHandler): RequestHandler {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

Wire everything together:

// Complete application setup
import { createServer } from 'http';
 
const engine = new MiddlewareEngine();
 
// Middleware registration order matters
engine.use(logger());
engine.use(cors({ origin: ['http://localhost:3000'] }));
engine.use(bodyParser());
engine.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
engine.use(authenticate({ secret: process.env.JWT_SECRET!, exclude: ['/api/health', '/api/auth'] }));
 
// Routes
engine.use('/api/health', (req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ status: 'ok', timestamp: new Date().toISOString() }));
});
 
engine.use('/api/users', asyncHandler(async (req, res, next) => {
  authorize(['admin'])(req, res, () => {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ users: [] }));
  });
}));
 
// Error handler must be last
engine.use(errorHandler());
 
const server = createServer((req, res) => engine.handle(req, res));
server.listen(3000, () => console.log('Server running on port 3000'));

Server Monitoring

Real-World Use Cases and Case Studies

Use Case 1: API Gateway

An API gateway uses middleware to handle cross-cutting concerns for multiple backend services. Authentication middleware validates JWT tokens, rate limiting middleware prevents abuse, request transformation middleware normalizes incoming requests, and response caching middleware reduces backend load. The middleware pipeline processes every request before routing it to the appropriate backend service.

Use Case 2: Multi-Tenant SaaS Platform

A multi-tenant SaaS platform uses middleware to identify the tenant from the request (subdomain, header, or URL path), load tenant-specific configuration, apply tenant-specific rate limits, and ensure data isolation. Each tenant has its own database schema, and the middleware sets the schema context before the route handler executes.

Use Case 3: Microservice Authentication

In a microservice architecture, an authentication middleware validates JWT tokens and extracts user claims. The middleware enriches the request with user information that downstream middleware and route handlers use for authorization. Token refresh middleware automatically refreshes expired tokens using refresh tokens, providing a seamless user experience.

Use Case 4: Request/Response Transformation

A B2B API platform uses middleware to transform requests and responses between different API versions. The middleware intercepts requests to deprecated API versions, transforms them to the current format, and transforms responses back to the deprecated format. This enables backward compatibility without duplicating route handlers.

Best Practices for Production

  1. Register middleware in the correct order: The order of middleware registration determines execution order. Put logging middleware first, followed by security middleware, then parsing middleware, then authentication, then authorization, then route handlers, and finally error handling.

  2. Always call next() or send a response: A middleware must either call next() to pass control to the next middleware or send a response. If neither happens, the request will hang until the client times out. Use linting rules or runtime checks to prevent this.

  3. Use middleware factories for configurable middleware: Instead of hardcoding configuration, create factory functions that return middleware functions. This enables reusing the same middleware with different configurations for different routes or environments.

  4. Handle async errors explicitly: Express does not catch errors from async middleware functions. Always wrap async middleware in a try-catch block or use a wrapper function like asyncHandler that catches rejected promises and forwards them to next().

  5. Implement request timeout middleware: Prevent long-running requests from consuming server resources by implementing timeout middleware that sends a 408 response if the request takes too long.

  6. Use helmet for security headers: The helmet middleware sets various HTTP headers that improve security: Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, and more. Always use helmet in production.

  7. Implement request ID middleware: Generate a unique request ID for each request and attach it to the request object and response headers. This enables distributed tracing and makes debugging production issues much easier.

  8. Test middleware in isolation: Middleware functions are pure functions that take inputs and produce outputs. Test them independently by mocking the request, response, and next function. This is much faster and more reliable than testing through HTTP requests.

Common Pitfalls and Solutions

PitfallImpactSolution
Not calling next() or sending responseRequest hangs indefinitelyUse linting rules; always handle both paths
Missing async error handlingUnhandled promise rejections crash the serverWrap async middleware in try-catch or asyncHandler
Wrong middleware orderAuthentication bypassed, security vulnerabilitiesDocument and enforce middleware order
Synchronous operations in middlewareBlocks the event loop, degrades performanceUse async operations; avoid synchronous I/O
Not cleaning up resourcesMemory leaks, connection pool exhaustionUse response finish events for cleanup
Overly broad middlewarePerformance overhead for all requestsUse path-based middleware mounting

Performance Optimization

Middleware performance directly impacts request throughput. Optimize by minimizing middleware chain length, using efficient algorithms, and caching results where possible.

// Compiled middleware chain for better performance
class CompiledMiddlewareChain {
  private chain: Array<(req: any, res: any, next: NextFunction) => void> = [];
 
  compile(middlewares: RequestHandler[]): void {
    this.chain = middlewares.map(middleware => {
      // Pre-bind middleware to avoid repeated function creation
      return (req: any, res: any, next: NextFunction) => middleware(req, res, next);
    });
  }
 
  async execute(req: any, res: any): Promise<void> {
    let index = 0;
 
    const next = (error?: Error): void => {
      if (index >= this.chain.length || res.writableEnded) return;
 
      const middleware = this.chain[index++];
      try {
        middleware(req, res, next);
      } catch (err) {
        next(err as Error);
      }
    };
 
    next();
  }
}

Comparison with Alternatives

FeatureExpressKoaFastifyHono
Middleware ModelCallback-basedAsync/awaitPlugin-basedMiddleware chain
Error HandlingManual (error middleware)try-catch built-inEncapsulatedtry-catch
PerformanceGoodGoodExcellentExcellent
TypeScript SupportCommunityCommunityBuilt-inBuilt-in
EcosystemLargestLargeGrowingGrowing
Learning CurveLowLowLowVery low
Async SupportManualNativeNativeNative

Advanced Patterns

Middleware Composition

Compose multiple middleware functions into a single middleware for cleaner route definitions.

function compose(...middlewares: RequestHandler[]): RequestHandler {
  return (req, res, next) => {
    let index = 0;
 
    function dispatch(error?: Error): void {
      if (error) {
        next(error);
        return;
      }
 
      if (index >= middlewares.length) {
        next();
        return;
      }
 
      const middleware = middlewares[index++];
      try {
        middleware(req, res, dispatch);
      } catch (err) {
        dispatch(err as Error);
      }
    }
 
    dispatch();
  };
}
 
// Usage
const protectedRoute = compose(
  authenticate({ secret: 'secret' }),
  authorize(['admin']),
  rateLimit({ windowMs: 60000, max: 10 })
);
 
engine.use('/api/admin', protectedRoute, adminHandler);

Testing Strategies

Test middleware functions in isolation using mock request and response objects.

import { describe, it, expect, jest } from '@jest/globals';
 
function createMockReqRes(overrides: Partial<IncomingMessage> = {}) {
  const req = { headers: {}, method: 'GET', url: '/', ...overrides } as unknown as IncomingMessage;
  const res = {
    writeHead: jest.fn(),
    end: jest.fn(),
    setHeader: jest.fn(),
    statusCode: 200,
    writableEnded: false,
    on: jest.fn(),
  } as unknown as ServerResponse;
  const next = jest.fn();
  return { req, res, next };
}
 
describe('authenticate middleware', () => {
  it('should pass with valid token', () => {
    const { req, res, next } = createMockReqRes({
      headers: { authorization: 'Bearer valid-token' },
    });
 
    const middleware = authenticate({ secret: 'test-secret' });
    middleware(req, res, next);
 
    expect(next).toHaveBeenCalled();
    expect((req as any).user).toBeDefined();
  });
 
  it('should reject without token', () => {
    const { req, res, next } = createMockReqRes();
    const middleware = authenticate({ secret: 'test-secret' });
 
    middleware(req, res, next);
 
    expect(res.writeHead).toHaveBeenCalledWith(401, { 'Content-Type': 'application/json' });
    expect(next).not.toHaveBeenCalled();
  });
});

Future Outlook

The middleware pattern continues to evolve with the adoption of edge computing and serverless architectures. Edge middleware, as implemented by Cloudflare Workers and Vercel Edge Functions, runs middleware at CDN edge locations for lower latency. The pattern remains the same—functions that process requests and pass control—but the execution environment changes.

The adoption of TypeScript has improved middleware type safety, enabling compile-time verification of request transformations. Frameworks like Fastify and Hono provide built-in TypeScript support with type-safe middleware composition. The future of middleware is type-safe, composable, and distributed across edge and origin servers.

Conclusion

Express.js middleware is a powerful pattern for building modular, maintainable web applications. Understanding the middleware chain, error propagation, and composition patterns is essential for building production-grade Node.js applications. The patterns we explored—from custom middleware frameworks to authentication, rate limiting, and error handling—form the foundation of every Express application.

Key takeaways: (1) Middleware executes in registration order; (2) Always call next() or send a response; (3) Use middleware factories for configurable, reusable middleware; (4) Wrap async middleware to catch errors; (5) Register error-handling middleware last; (6) Test middleware in isolation with mock objects.

The middleware pattern is not specific to Express—it is a fundamental software design pattern that appears in many frameworks and contexts. Mastering middleware in Express gives you skills that transfer to Koa, Fastify, Hono, and any framework that uses the chain of responsibility pattern. Start building your own middleware libraries, and you will discover how much control and flexibility this simple pattern provides.