Skip to main content

Express Middleware: The Checkpoint System Between Request and Response

Updated 11 min readMarkdown

Express Middleware: The Checkpoint System Between Request and Response

Every HTTP request that arrives at an Express application goes through more than just a route handler. Before your handler runs — and sometimes after — there is a chain of functions that inspect, transform, validate, or reject the request. Those functions are middleware.

Understanding middleware is not optional for serious Express development. Authentication, logging, request parsing, rate limiting, error handling — all of it is implemented as middleware. Once you see how the pieces fit together, Express applications become much easier to read, reason about, and extend.


What Middleware Is

In Express, middleware is any function with this signature:

function middleware(req, res, next) {
  // do something with req or res
  next(); // pass control to the next function
}

Three parameters: the request object, the response object, and next — a function that passes control to the next middleware in the chain. That is the entire contract.

A middleware function can:

  • Read or modify req (add properties, parse the body, verify a token)

  • Read or modify res (set headers, prepare response data)

  • End the request-response cycle by sending a response

  • Call next() to pass control to the next function

  • Call next(error) to pass an error to Express's error handling

What distinguishes middleware from a route handler is its position in the pipeline and its use of next. Route handlers are the final destination. Middleware is everything in between.


Where Middleware Sits in the Request Lifecycle

Before a request reaches its route handler, it passes through a series of middleware functions registered on the application. Each one runs in order, inspects or modifies the request, then either ends the cycle or passes control forward.

Incoming HTTP Request
        │
        ▼
┌───────────────────────────────────────────────────────────────┐
│                       Express Application                     │
│                                                               │
│  ┌─────────────────────────────────────────────────────────┐  │
│  │  Middleware 1: Request Logger                           │  │
│  │  logs method, URL, timestamp → calls next()            │  │
│  └──────────────────────────┬──────────────────────────────┘  │
│                             │ next()                          │
│  ┌──────────────────────────▼──────────────────────────────┐  │
│  │  Middleware 2: Body Parser (express.json)                │  │
│  │  parses JSON body → attaches to req.body → calls next() │  │
│  └──────────────────────────┬──────────────────────────────┘  │
│                             │ next()                          │
│  ┌──────────────────────────▼──────────────────────────────┐  │
│  │  Middleware 3: Auth Check                               │  │
│  │  verifies JWT → attaches req.user → calls next()        │  │
│  │  (or sends 401 if token missing/invalid)                │  │
│  └──────────────────────────┬──────────────────────────────┘  │
│                             │ next()                          │
│  ┌──────────────────────────▼──────────────────────────────┐  │
│  │  Route Handler: GET /api/profile                        │  │
│  │  reads req.user → sends response                        │  │
│  └──────────────────────────┬──────────────────────────────┘  │
│                             │                                 │
└─────────────────────────────┼─────────────────────────────────┘
                              │
                              ▼
                     HTTP Response sent

This is the pipeline model. Each checkpoint has the full request and response objects available. Each can modify them, terminate the cycle, or pass the request forward.


The next() Function

next() is what makes the chain work. When a middleware function calls next(), Express moves to the next registered middleware or route handler. If a middleware does not call next() and does not send a response, the request hangs — the client waits forever, and nothing else happens.

// This middleware works correctly
app.use((req, res, next) => {
  console.log(`\({req.method} \){req.url}`);
  next(); // passes control forward
});

// This middleware breaks the chain — request hangs
app.use((req, res, next) => {
  console.log(`\({req.method} \){req.url}`);
  // next() never called, response never sent — request stalls
});

next() can also accept an argument. Passing anything (typically an Error object) skips the remaining middleware and routes, and jumps directly to Express's error handling middleware:

app.use((req, res, next) => {
  if (!req.headers.authorization) {
    return next(new Error('No authorization header')); // skips to error handler
  }
  next(); // all good, continue
});

Execution Order Is Registration Order

Express runs middleware in the exact order it is registered with app.use() or the HTTP method shortcuts. This is not a detail — it is the central rule of middleware composition.

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

app.use((req, res, next) => {
  console.log('Middleware 1');
  next();
});

app.use((req, res, next) => {
  console.log('Middleware 2');
  next();
});

app.get('/hello', (req, res) => {
  console.log('Route handler');
  res.send('Hello');
});

For a GET /hello request, the output is always:

Middleware 1
Middleware 2
Route handler

If you place express.json() after your route handlers, req.body will be undefined inside those handlers. If you place your auth middleware after the routes it is supposed to protect, those routes run without authentication. Order matters unconditionally.


Types of Middleware

Application-Level Middleware

Registered directly on the app instance with app.use() or app.METHOD(). Applies to all requests (or requests to a specific path) across the entire application.

const app = express();

// Applies to ALL requests regardless of path
app.use((req, res, next) => {
  req.requestTime = Date.now();
  next();
});

// Applies only to requests starting with /api
app.use('/api', (req, res, next) => {
  console.log('API request received');
  next();
});

Application-level middleware is where global concerns live: logging, parsing, CORS headers, rate limiting.

Router-Level Middleware

Express's Router creates a mini-application — it has the same use() and HTTP method functions as app, but its middleware only applies to routes mounted through that router.

const { Router } = require('express');
const router = Router();

// Only runs for requests handled by this router
router.use((req, res, next) => {
  console.log('Router-level middleware running');
  next();
});

router.get('/profile', (req, res) => {
  res.json({ user: req.user });
});

// Mount the router on the app
app.use('/users', router);

Router-level middleware enables per-group configuration. You can add authentication middleware to an /admin router without affecting public routes, add rate limiting to a /api router without affecting static file serving, and so on.

Built-in Middleware

Express ships with a small number of built-in middleware functions that handle common tasks:

// Parse JSON request bodies → available as req.body
app.use(express.json());

// Parse URL-encoded form data (HTML forms)
app.use(express.urlencoded({ extended: true }));

// Serve static files from a directory
app.use(express.static('public'));
// Requests to /logo.png serve public/logo.png
// Requests to /css/style.css serve public/css/style.css

express.json() and express.urlencoded() are among the most commonly used. Without one of them, req.body is always undefined — a frequent source of confusion for developers new to Express.


Middleware Chain Visualization

When multiple middleware functions are in play, the execution path looks like this:

Request arrives
     │
     ▼
┌────────────┐  next()  ┌────────────┐  next()  ┌────────────┐  next()  ┌──────────────┐
│  Logger    │─────────►│   JSON     │─────────►│   Auth     │─────────►│    Route     │
│            │          │  Parser    │          │  Check     │          │   Handler    │
│ logs req   │          │ req.body   │          │ req.user   │          │ sends res    │
└────────────┘          └────────────┘          └─────┬──────┘          └──────────────┘
                                                      │
                                               If auth fails:
                                               res.status(401).json(...)
                                               Chain ends here — route handler
                                               never runs

The auth middleware has a fork: if authentication passes, next() is called and the route handler runs. If it fails, a response is sent directly and next() is never called. The route handler is protected — it only runs when every preceding middleware has passed the request through.


Real-World Examples

Logging Middleware

A request logger is the simplest real-world middleware. It records incoming requests and, with a small trick, the response status code.

function requestLogger(req, res, next) {
  const start = Date.now();

  // Run after response is sent by intercepting res.end
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`\({req.method} \){req.url} \({res.statusCode} - \){duration}ms`);
  });

  next();
}

app.use(requestLogger);

Output for a typical request:

GET /api/users 200 - 43ms
POST /api/users 201 - 67ms
GET /api/users/99 404 - 12ms

The res.on('finish', ...) listener fires after the response is sent, which is why you can log the status code — you have to wait until Express has decided what it is. The middleware itself calls next() immediately and does not slow down the request.

Authentication Middleware

Auth middleware reads a token from the request, verifies it, and either attaches the user identity to req or rejects the request before the route handler ever runs.

const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // "Bearer <token>"

  if (!token) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid or expired token' });
    }

    req.user = decoded; // attach decoded payload to request
    next();
  });
}

// Applied selectively — only protected routes use this middleware
app.get('/profile', authenticate, (req, res) => {
  res.json({ user: req.user });
});

app.get('/public', (req, res) => {
  res.send('Anyone can see this');
});

Notice that authenticate is passed directly as an argument to app.get(). Express accepts multiple functions in a route definition and runs them in order. This is per-route middleware — a clean alternative to applying auth globally when some routes need to stay public.

Request Validation Middleware

Validation middleware checks that incoming data meets the requirements before the route handler attempts to use it. Catching invalid data early produces clearer error messages and keeps route handlers clean.

function validateCreateUser(req, res, next) {
  const { name, email, password } = req.body;
  const errors = [];

  if (!name || name.trim().length < 2) {
    errors.push('name must be at least 2 characters');
  }

  if (!email || !email.includes('@')) {
    errors.push('valid email is required');
  }

  if (!password || password.length < 8) {
    errors.push('password must be at least 8 characters');
  }

  if (errors.length > 0) {
    return res.status(400).json({ errors });
  }

  next(); // data is valid, proceed to route handler
}

app.post('/users', express.json(), validateCreateUser, (req, res) => {
  // If we reach here, req.body is guaranteed to be valid
  const { name, email, password } = req.body;
  // ... create user
  res.status(201).json({ message: 'User created' });
});

The route handler does no validation — it trusts that middleware has already done it. This separation keeps each piece focused on one responsibility.


Error-Handling Middleware

Error-handling middleware has a slightly different signature: four parameters instead of three, with the error as the first argument.

function errorHandler(err, req, res, next) {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: err.message || 'Internal server error',
  });
}

// Must be registered after all other middleware and routes
app.use(errorHandler);

Any middleware that calls next(err) — or any route handler that throws an error — ends up here. Express identifies error-handling middleware by the four-parameter signature and routes errors to it automatically.

The placement rule matters: error-handling middleware must be registered last, after all routes and regular middleware. Otherwise Express will not route errors to it correctly.

app.use(express.json());
app.use(requestLogger);
app.use('/api', apiRouter);
app.use(errorHandler); // always last

Composing Middleware Correctly

A well-organized Express application has a clear middleware stack:

const app = express();

// 1. Foundational middleware — always runs, no auth required
app.use(requestLogger);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 2. Public routes — accessible without authentication
app.post('/auth/login', loginHandler);
app.get('/health', (req, res) => res.json({ status: 'ok' }));

// 3. Protected routes — auth middleware applied to this router
const protectedRouter = Router();
protectedRouter.use(authenticate);
protectedRouter.get('/profile', profileHandler);
protectedRouter.put('/profile', validateUpdateProfile, updateProfileHandler);

app.use('/api', protectedRouter);

// 4. Error handling — always last
app.use(errorHandler);

The structure is deliberate. Global middleware at the top, public routes before auth middleware, protected routes with auth applied via a router, error handling at the bottom. New routes added to protectedRouter are automatically authenticated. New routes added before the router are automatically public.


Summary

Middleware is Express's mechanism for composing request handling from discrete, reusable functions. Each middleware receives the request and response, does one thing, and either ends the cycle or passes control forward with next().

The rules that govern middleware behavior are few and consistent: order of registration determines order of execution, calling next() advances the chain, calling next(err) routes to error handling, and not calling either stalls the request. Everything else — logging, auth, validation, parsing, rate limiting — is an application of those rules.

Once you internalize the pipeline model, Express applications become easier to read as a sequence of concerns: what does this request pass through before reaching the handler? That question answers itself when middleware is organized clearly and each function has a single responsibility.


The natural extension of middleware knowledge is into error propagation patterns — how to create custom error classes, when to use next(err) vs try/catch in async route handlers, and how to structure global error responses consistently across an API.

No comments yet. Be the first to comment.

More from this blog