Middleware — What It Is, How It Works, and Execution Order

Middleware is the single most important concept in Express. Almost everything you do in an Express application is middleware — body parsing, authentication, logging, CORS, compression, validation, error handling. A middleware function is simply a function that receives (req, res, next) and either completes the response or calls next() to pass control to the next function in the chain. Understanding how the middleware chain works — execution order, how data is attached to req, how errors propagate, and how to write your own — gives you complete control over every request that enters your application.

Middleware Types

Type Registered With Runs For Example
Application-level app.use(fn) Every request Body parser, CORS, logging
Router-level router.use(fn) Requests matched by this router Auth on all task routes
Route-level app.get('/path', fn1, fn2) Only this specific route Validate before controller
Error-handling app.use((err, req, res, next) => {}) When next(err) is called Global error formatter
Built-in app.use(express.json()) Configured request types Body parsing, static files
Third-party app.use(cors()) All or specific requests cors, helmet, morgan, compression

The Middleware Signature

Signature Type Called When
(req, res, next) Regular middleware Always — for request processing
(err, req, res, next) Error-handling middleware Only when next(err) is called

next() Behaviour

Call Effect
next() Pass to the next matching middleware or route handler
next(err) Skip all remaining middleware and jump to the nearest error handler
next('route') Skip remaining handlers for the current route, try the next matching route
Not called The request hangs — no response is sent (client times out)
Note: The order middleware is registered in Express determines the order it executes. Middleware registered with app.use() before routes runs for every request. Middleware registered after a route only runs for requests that do not match any earlier route. This means you must register essential middleware (body parsers, CORS, logging) at the very top of app.js, before any route definitions.
Tip: Attaching data to the req object inside middleware is the standard Express pattern for sharing data between middleware functions and route handlers. Authentication middleware reads the JWT, verifies it, and attaches the user: req.user = decodedPayload. Every subsequent middleware and route handler in the same request chain can then access req.user without re-reading the token.
Warning: Error-handling middleware must have exactly four parameters: (err, req, res, next). If you define it with three parameters — even if you never use next — Express will treat it as regular middleware and it will never be called when next(err) is invoked. The four-parameter signature is how Express identifies error handlers. If your error handler does not call next(err) for errors it cannot handle, errors will be silently swallowed.

Basic Example — Writing Middleware

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

// ── Basic middleware structure ────────────────────────────────────────────
const logger = (req, res, next) => {
    console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
    next();   // MUST call next() or the request hangs
};

const timer = (req, res, next) => {
    req.startTime = Date.now();   // attach data to req for later use
    res.on('finish', () => {
        console.log(`${req.method} ${req.path} — ${Date.now() - req.startTime}ms`);
    });
    next();
};

// ── Application-level middleware — runs for every request ─────────────────
app.use(express.json());
app.use(logger);
app.use(timer);

// ── Route-level middleware — only this route ──────────────────────────────
const validateId = (req, res, next) => {
    const { id } = req.params;
    if (!/^[0-9a-fA-F]{24}$/.test(id)) {
        return next(new Error('Invalid MongoDB ObjectId format'));
    }
    next();
};

app.get('/api/tasks/:id', validateId, (req, res) => {
    res.json({ id: req.params.id });
});

// ── Authentication middleware ─────────────────────────────────────────────
const jwt = require('jsonwebtoken');

const 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 {
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        req.user = decoded;   // attach user to req — available downstream
        next();
    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            return res.status(401).json({ success: false, message: 'Token expired' });
        }
        return res.status(401).json({ success: false, message: 'Invalid token' });
    }
};

// ── Router-level middleware — protects all task routes ────────────────────
const taskRouter = express.Router();
taskRouter.use(authenticate);   // all routes in this router require auth
taskRouter.get('/', (req, res) => {
    res.json({ userId: req.user.id });  // req.user set by authenticate above
});
app.use('/api/tasks', taskRouter);

// ── Middleware array — reusable combination ───────────────────────────────
const { body, validationResult } = require('express-validator');

const createTaskRules = [
    body('title').trim().notEmpty().withMessage('Title is required'),
    body('priority').isIn(['low', 'medium', 'high']).withMessage('Invalid priority'),
];

const handleValidation = (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).json({ success: false, errors: errors.array() });
    }
    next();
};

app.post('/api/tasks', authenticate, createTaskRules, handleValidation,
    async (req, res) => {
        // Only reaches here if authenticated and validation passed
        res.status(201).json({ success: true });
    }
);

// ── Error-handling middleware — MUST have 4 parameters ────────────────────
app.use((err, req, res, next) => {
    const status  = err.status  || 500;
    const message = err.message || 'Internal Server Error';

    res.status(status).json({
        success: false,
        message: process.env.NODE_ENV === 'production' ? 'Server Error' : message,
    });
});

Middleware Execution Order — Visualised

Incoming Request: POST /api/tasks { title: "Buy milk" }
         |
         v
  ┌─────────────────────────────────────────────────────────┐
  │  app.use(express.json())      — parse JSON body          │
  │  app.use(cors())              — add CORS headers         │
  │  app.use(helmet())            — add security headers     │
  │  app.use(morgan('dev'))       — log request              │
  └────────────────────┬────────────────────────────────────┘
                       |
                       v
  ┌─────────────────────────────────────────────────────────┐
  │  router.use(authenticate)     — verify JWT, set req.user │
  └────────────────────┬────────────────────────────────────┘
                       |
                       v
  ┌─────────────────────────────────────────────────────────┐
  │  POST '/' — createTaskRules[] — validate body           │
  │           — handleValidation  — check errors            │
  │           — controller.create — create in DB, respond   │
  └────────────────────┬────────────────────────────────────┘
                       |
              (if next(err) called)
                       v
  ┌─────────────────────────────────────────────────────────┐
  │  app.use(errorHandler)        — format and send error    │
  └─────────────────────────────────────────────────────────┘
                       |
                       v
       Response: 201 { success: true, data: {...} }

How It Works

Step 1 — Every Middleware Gets the Same req and res Objects

The same req and res objects are passed through the entire middleware chain for one request. Any middleware can read from req and write to it. Authentication middleware writes req.user. Timing middleware writes req.startTime. Logging middleware reads req.method and req.path. They all see the same object, making data sharing between middleware functions simple and idiomatic.

Step 2 — next() Is the Pipeline Connector

Calling next() transfers control to the next registered middleware or route handler. Not calling next() (and not sending a response) leaves the client’s request hanging indefinitely until it times out. Every middleware must either send a response — res.json(), res.send() — or call next(). Sending a response and calling next() in the same middleware causes the “headers already sent” error.

Step 3 — next(err) Jumps to the Error Handler

Passing any truthy value to next(err) causes Express to skip all remaining regular middleware and route handlers and jump directly to the first error-handling middleware (the one with four parameters). This is the correct way to propagate errors in Express. The asyncHandler utility wraps async functions so any thrown error is automatically passed to next(err).

Step 4 — Built-in Middleware Factories Return Middleware Functions

express.json() is a factory function — it takes configuration options and returns a middleware function. The same is true for cors(options), helmet(), morgan('dev'), and most npm middleware packages. Calling the factory configures the middleware; the returned function is what Express actually calls for each request.

Step 5 — Middleware Can Be Reused Across Multiple Routes

Because middleware is just a function, you can apply it selectively. Pass it as a second argument to a specific route: app.get('/admin', requireAdmin, adminHandler). Pass it to a router: router.use(authenticate). Or register it globally: app.use(logger). This composability is what makes Express so flexible — you assemble the exact pipeline each route needs.

Real-World Example: Complete Middleware Stack

// middleware/authenticate.js
const jwt = require('jsonwebtoken');
const User = require('../models/user.model');

const authenticate = async (req, res, next) => {
    try {
        const header = req.headers.authorization;
        if (!header?.startsWith('Bearer ')) {
            return res.status(401).json({ success: false, message: 'Authentication required' });
        }

        const token   = header.split(' ')[1];
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        const user    = await User.findById(decoded.id).select('-password').lean();

        if (!user) return res.status(401).json({ success: false, message: 'User no longer exists' });

        req.user = user;    // attach to req for downstream use
        next();
    } catch (err) {
        const message = err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token';
        res.status(401).json({ success: false, message });
    }
};

module.exports = authenticate;

// middleware/asyncHandler.js
const asyncHandler = fn => (req, res, next) =>
    Promise.resolve(fn(req, res, next)).catch(next);

module.exports = asyncHandler;

// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
    let status  = err.status  || 500;
    let message = err.message || 'Internal Server Error';

    // Mongoose validation error
    if (err.name === 'ValidationError') {
        status  = 400;
        message = Object.values(err.errors).map(e => e.message).join(', ');
    }

    // Mongoose duplicate key
    if (err.code === 11000) {
        status  = 409;
        const field = Object.keys(err.keyPattern)[0];
        message = `${field} already exists`;
    }

    // Mongoose bad ObjectId
    if (err.name === 'CastError') {
        status  = 400;
        message = `Invalid ${err.path}: ${err.value}`;
    }

    const isDev = process.env.NODE_ENV !== 'production';
    res.status(status).json({
        success: false,
        message: isDev ? message : (status < 500 ? message : 'Server Error'),
        ...(isDev && status === 500 && { stack: err.stack }),
    });
};

module.exports = errorHandler;

Common Mistakes

Mistake 1 — Forgetting to call next() or send a response

❌ Wrong — request hangs, client eventually times out:

const checkFeatureFlag = (req, res, next) => {
    if (!featureEnabled) {
        console.log('Feature disabled');
        // Missing: return res.status(403).json(...) OR next()
        // The request just hangs here forever
    }
    next();
};

✅ Correct — always end the chain:

const checkFeatureFlag = (req, res, next) => {
    if (!featureEnabled) {
        return res.status(403).json({ message: 'Feature not available' });
    }
    next();
};

Mistake 2 — Error handler with 3 parameters is never called

❌ Wrong — Express ignores this as an error handler:

// 3 parameters — Express treats this as regular middleware!
app.use((err, req, res) => {
    res.status(500).json({ message: err.message });
});

✅ Correct — error handlers MUST have exactly 4 parameters:

// 4 parameters — Express identifies this as an error handler
app.use((err, req, res, next) => {
    res.status(err.status || 500).json({ message: err.message });
});

Mistake 3 — Calling next() after sending a response

❌ Wrong — response sent and next() called — headers already sent error:

app.get('/users', (req, res, next) => {
    res.json({ users: [] });
    next();   // response already sent — any downstream handler will error
});

✅ Correct — never call next() after sending a response:

app.get('/users', (req, res, next) => {
    return res.json({ users: [] });   // return immediately — no next()
});

Quick Reference

Task Code
Global middleware app.use(middlewareFn)
Path-scoped middleware app.use('/api', middlewareFn)
Route-level middleware app.get('/path', mw1, mw2, handler)
Router-level middleware router.use(authenticate)
Pass to next middleware next()
Trigger error handler next(new Error('message')) or next(err)
Attach data to request req.user = decodedToken
Error handler signature (err, req, res, next) => {} — exactly 4 params
Wrap async handler asyncHandler(async (req, res) => { ... })

🧠 Test Yourself

An Express error-handling middleware is registered with three parameters (err, req, res). When next(err) is called, what happens?