Nodejs

Express.js Middleware Architecture Deep Dive

A comprehensive guide to Express.js middleware architecture covering execution order, custom middleware patterns, async error handling, middleware factories, and building a production middleware stack.

Express.js Middleware Architecture Deep Dive

Overview

Express.js middleware is the backbone of every Node.js web application built on the framework -- it controls how requests flow from the moment they arrive to the moment a response goes out the door. Understanding the middleware stack is not optional knowledge; it is the difference between an application that scales cleanly and one that turns into an unmaintainable mess of route-level hacks. This article covers the full architecture: execution order, middleware types, custom middleware patterns, async error propagation, middleware factories, and a production-ready layered stack you can steal for your next project.

Prerequisites

  • Node.js 18+ installed
  • Working knowledge of Express.js routing basics
  • Familiarity with HTTP request/response lifecycle
  • Basic understanding of Promises and async/await

How the Middleware Stack Works

At its core, Express is a middleware runner. When a request arrives, Express walks through a stack of functions in order. Each function receives three arguments -- req, res, and next -- and has two choices: end the request by sending a response, or call next() to hand control to the next function in the stack.

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

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

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

app.get('/', function(req, res) {
    res.send('Hello');
});

When a GET / request arrives, the output is:

Middleware 1
Middleware 2

The request flows top-to-bottom through middleware registered with app.use(), then matches the route handler. If you forget to call next(), the request hangs. If you call next() after sending a response, you get the dreaded ERR_HTTP_HEADERS_SENT error. The stack is a pipeline -- respect the flow.

The next() Function

The next function does more than just advance the stack. It has two modes:

  • next() -- passes control to the next middleware
  • next(err) -- skips all remaining non-error middleware and jumps to the first error-handling middleware
app.use(function(req, res, next) {
    var token = req.headers['x-api-key'];
    if (!token) {
        return next(new Error('API key is required'));
    }
    next();
});

Calling next('route') is a third option, but only works inside route handlers registered with app.METHOD() or router.METHOD(). It skips the remaining callbacks for the current route and passes control to the next matching route.


Middleware Types

Express has three distinct categories of middleware, and they behave differently.

Application-Level Middleware

Registered on the app object with app.use() or app.METHOD(). These run for every request (or every request matching a path prefix).

var app = express();

// Runs for ALL requests
app.use(function(req, res, next) {
    req.requestTime = Date.now();
    next();
});

// Runs only for GET /api/users
app.get('/api/users', function(req, res) {
    res.json({ requestTime: req.requestTime, users: [] });
});

Router-Level Middleware

Identical to application-level, but bound to an express.Router() instance. This is how you scope middleware to a subset of routes without polluting the global stack.

var router = express.Router();

// Only runs for routes mounted on this router
router.use(function(req, res, next) {
    console.log('Router middleware hit: ' + req.method + ' ' + req.path);
    next();
});

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

// Mount the router -- middleware only fires for /api/users/*
app.use('/api/users', router);

Error-Handling Middleware

The signature is the key: four arguments instead of three. Express identifies error handlers by the function's arity.

// This MUST have exactly 4 parameters -- Express checks the function length
app.use(function(err, req, res, next) {
    console.error('Error caught:', err.message);
    res.status(err.status || 500).json({
        error: err.message,
        stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
    });
});

If you name the parameters wrong or skip one, Express will not recognize it as an error handler. The four-argument signature is not a suggestion -- it is a contract.


Middleware Execution Order and Why It Matters

The order you register middleware determines the order it executes. This sounds obvious, but it is the source of most middleware bugs I have seen in production.

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

// WRONG ORDER -- authentication runs after the route handler
app.get('/api/secret', function(req, res) {
    res.json({ secret: 'exposed' });
});

app.use(function(req, res, next) {
    if (!req.headers.authorization) {
        return res.status(401).json({ error: 'Unauthorized' });
    }
    next();
});

In the above code, GET /api/secret returns data without any authentication check because the route handler is registered before the auth middleware. Express matched the route and sent the response before the auth middleware ever had a chance to run.

The correct order:

// 1. Security headers
app.use(helmet());

// 2. CORS
app.use(cors({ origin: 'https://myapp.com' }));

// 3. Body parsing
app.use(express.json());

// 4. Logging / observability
app.use(requestLogger);

// 5. Public routes
app.use('/health', healthRouter);
app.use('/auth', authRouter);

// 6. Authentication barrier
app.use('/api', authenticate);

// 7. Protected routes
app.use('/api/users', userRouter);
app.use('/api/admin', adminRouter);

// 8. 404 handler
app.use(notFoundHandler);

// 9. Error handler (MUST be last)
app.use(errorHandler);

This is not arbitrary. Security headers and CORS must come first so every response gets them. Body parsing must happen before any handler that reads req.body. Logging happens early so you can observe failed requests too. Auth sits before protected routes. Error handlers sit at the bottom to catch anything that falls through.


Built-In Middleware

Express ships with three built-in middleware functions. You do not need to install anything extra for these.

express.json()

Parses incoming JSON payloads. Sets req.body to the parsed object.

app.use(express.json({ limit: '10kb' }));

The limit option is critical for production. Without it, someone can POST a 100MB JSON payload and eat your server's memory. I set this to 10kb for most APIs and increase it only on specific routes that need it.

express.urlencoded()

Parses URL-encoded form data (like HTML form submissions).

app.use(express.urlencoded({ extended: true, limit: '10kb' }));

The extended: true option uses the qs library for parsing, which supports nested objects. Set it to false if you only need flat key-value pairs.

express.static()

Serves static files from a directory.

app.use('/assets', express.static('static', {
    maxAge: '1d',
    etag: true,
    lastModified: true
}));

In production, put a CDN or reverse proxy in front of static file serving. Express can serve files, but it should not be your primary static file server at scale.


Writing Custom Middleware

Request ID Middleware

Every production API should tag requests with a unique ID for tracing. This is one of the most valuable pieces of middleware you can write.

var crypto = require('crypto');

var requestId = function(req, res, next) {
    var id = req.headers['x-request-id'] || crypto.randomUUID();
    req.id = id;
    res.setHeader('X-Request-Id', id);
    next();
};

app.use(requestId);

Now every log entry, error report, and downstream service call can include req.id for correlation. If the client sends an X-Request-Id header, we honor it -- this is essential for distributed tracing across microservices.

Request Timing Middleware

Measure how long each request takes and expose it in the response headers.

var timing = function(req, res, next) {
    var start = process.hrtime.bigint();

    res.on('finish', function() {
        var end = process.hrtime.bigint();
        var durationMs = Number(end - start) / 1e6;
        console.log(req.method + ' ' + req.originalUrl + ' ' + res.statusCode + ' ' + durationMs.toFixed(2) + 'ms');
    });

    next();
};

app.use(timing);

Output:

GET /api/users 200 12.34ms
POST /api/orders 201 45.67ms
GET /api/products?category=electronics 200 8.91ms

Custom Logging Middleware

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

    res.on('finish', function() {
        var duration = Date.now() - start;
        var logEntry = {
            timestamp: new Date().toISOString(),
            method: req.method,
            url: req.originalUrl,
            status: res.statusCode,
            duration: duration + 'ms',
            requestId: req.id,
            ip: req.ip,
            userAgent: req.headers['user-agent']
        };
        console.log(JSON.stringify(logEntry));
    });

    next();
};

Output:

{"timestamp":"2026-02-08T14:30:00.000Z","method":"GET","url":"/api/users","status":200,"duration":"15ms","requestId":"a1b2c3d4","ip":"::1","userAgent":"Mozilla/5.0"}

Conditional Middleware

Sometimes you need middleware to run only for certain routes or conditions. Do not litter your middleware with if statements -- use a wrapper.

var unless = function(paths, middleware) {
    return function(req, res, next) {
        var skip = paths.some(function(path) {
            if (typeof path === 'string') {
                return req.path.startsWith(path);
            }
            return path.test(req.path);
        });

        if (skip) {
            return next();
        }

        middleware(req, res, next);
    };
};

// Skip authentication for public routes
app.use(unless(['/health', '/auth', /^\/public/], authenticate));

This is cleaner than registering the middleware on every individual route. You define the exceptions once and move on.

Environment-Based Middleware

var devOnly = function(middleware) {
    return function(req, res, next) {
        if (process.env.NODE_ENV === 'development') {
            return middleware(req, res, next);
        }
        next();
    };
};

// Only log request bodies in development
app.use(devOnly(function(req, res, next) {
    if (req.body && Object.keys(req.body).length > 0) {
        console.log('Request body:', JSON.stringify(req.body, null, 2));
    }
    next();
}));

Middleware Composition Patterns

When you have multiple middleware that always run together, compose them into a single unit. This reduces boilerplate and makes the intent clear.

var compose = function() {
    var middlewares = Array.prototype.slice.call(arguments);
    return function(req, res, next) {
        var index = 0;
        function run(err) {
            if (err) return next(err);
            if (index >= middlewares.length) return next();
            var mw = middlewares[index++];
            mw(req, res, run);
        }
        run();
    };
};

var apiMiddleware = compose(
    express.json({ limit: '10kb' }),
    requestId,
    timing,
    authenticate
);

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

Express also supports passing arrays of middleware to route definitions, which is simpler for most cases:

var adminMiddleware = [authenticate, authorize('admin'), auditLog];

app.get('/api/admin/users', adminMiddleware, function(req, res) {
    res.json({ users: [] });
});

Async Middleware and Error Propagation

This is where most Express applications break. Express 4 does not catch rejected Promises or thrown errors inside async middleware. You have to handle it yourself.

// BROKEN -- unhandled rejection, server hangs or crashes
app.get('/api/users', async function(req, res) {
    var users = await db.query('SELECT * FROM users'); // throws if DB is down
    res.json(users);
});

If the db.query() call rejects, Express does not catch it. The request hangs, the client times out, and your process might crash with an UnhandledPromiseRejection warning.

The asyncHandler Wrapper

var asyncHandler = function(fn) {
    return function(req, res, next) {
        Promise.resolve(fn(req, res, next)).catch(next);
    };
};

// Now errors are caught and forwarded to the error handler
app.get('/api/users', asyncHandler(function(req, res) {
    var users = await db.query('SELECT * FROM users');
    res.json(users);
}));

This is such a common pattern that libraries like express-async-errors exist to monkey-patch Express and handle it automatically. But I prefer being explicit -- patching the framework's internals is a debugging nightmare when something goes wrong.

Note: Express 5 (currently in beta) handles async errors natively. Until it is stable, use the wrapper.

Async Error-Handling Middleware

Your error handler itself can be async:

app.use(function(err, req, res, next) {
    console.error({
        message: err.message,
        stack: err.stack,
        requestId: req.id,
        method: req.method,
        url: req.originalUrl
    });

    var status = err.status || err.statusCode || 500;

    res.status(status).json({
        error: {
            message: status === 500 ? 'Internal Server Error' : err.message,
            requestId: req.id
        }
    });
});

Never leak stack traces or internal error details to clients in production. Log the full error server-side, send a sanitized message to the client.


Middleware Factories (Parameterized Middleware)

A middleware factory is a function that returns middleware. This is the standard pattern for configurable, reusable middleware.

Rate Limiter Factory

var rateLimiter = function(options) {
    var windowMs = options.windowMs || 60000;
    var max = options.max || 100;
    var message = options.message || 'Too many requests';
    var store = {};

    // Clean up expired entries every minute
    setInterval(function() {
        var now = Date.now();
        Object.keys(store).forEach(function(key) {
            if (store[key].resetTime < now) {
                delete store[key];
            }
        });
    }, 60000);

    return function(req, res, next) {
        var key = req.ip;
        var now = Date.now();

        if (!store[key] || store[key].resetTime < now) {
            store[key] = { count: 0, resetTime: now + windowMs };
        }

        store[key].count++;

        res.setHeader('X-RateLimit-Limit', max);
        res.setHeader('X-RateLimit-Remaining', Math.max(0, max - store[key].count));
        res.setHeader('X-RateLimit-Reset', Math.ceil(store[key].resetTime / 1000));

        if (store[key].count > max) {
            return res.status(429).json({ error: message });
        }

        next();
    };
};

// Different limits for different routes
app.use('/api/auth/login', rateLimiter({ windowMs: 900000, max: 5, message: 'Too many login attempts' }));
app.use('/api', rateLimiter({ windowMs: 60000, max: 100 }));

Authorization Factory

var authorize = function() {
    var allowedRoles = Array.prototype.slice.call(arguments);
    return function(req, res, next) {
        if (!req.user) {
            return res.status(401).json({ error: 'Authentication required' });
        }

        var hasRole = allowedRoles.some(function(role) {
            return req.user.roles && req.user.roles.indexOf(role) !== -1;
        });

        if (!hasRole) {
            return res.status(403).json({
                error: 'Insufficient permissions',
                required: allowedRoles,
                current: req.user.roles
            });
        }

        next();
    };
};

app.delete('/api/users/:id', authorize('admin'), deleteUserHandler);
app.put('/api/posts/:id', authorize('admin', 'editor'), updatePostHandler);

Response Format Factory

var requireContentType = function(type) {
    return function(req, res, next) {
        if (req.method === 'GET' || req.method === 'DELETE') {
            return next();
        }
        var contentType = req.headers['content-type'];
        if (!contentType || contentType.indexOf(type) === -1) {
            return res.status(415).json({
                error: 'Unsupported Media Type',
                expected: type,
                received: contentType || 'none'
            });
        }
        next();
    };
};

app.use('/api', requireContentType('application/json'));

Per-Route Middleware Stacks

Express lets you pass multiple middleware functions to a single route. Each one runs in order, and any of them can short-circuit the chain.

var validateBody = function(schema) {
    return function(req, res, next) {
        var errors = [];
        Object.keys(schema).forEach(function(field) {
            var rules = schema[field];
            if (rules.required && !req.body[field]) {
                errors.push(field + ' is required');
            }
            if (rules.type && req.body[field] && typeof req.body[field] !== rules.type) {
                errors.push(field + ' must be a ' + rules.type);
            }
        });

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

app.post('/api/users',
    authenticate,
    authorize('admin'),
    validateBody({
        email: { required: true, type: 'string' },
        name: { required: true, type: 'string' },
        role: { required: false, type: 'string' }
    }),
    asyncHandler(function(req, res) {
        var user = await userService.create(req.body);
        res.status(201).json(user);
    })
);

This reads like a pipeline: authenticate, check permissions, validate input, then handle the request. Each concern is isolated. If validation fails, the handler never runs.


Mounting Middleware on Sub-Paths

When you pass a path to app.use(), the middleware only fires for requests matching that prefix. Express strips the prefix from req.path inside the middleware (but req.originalUrl retains the full path).

var apiV1 = express.Router();
var apiV2 = express.Router();

apiV1.get('/users', function(req, res) {
    res.json({ version: 'v1', users: [] });
});

apiV2.get('/users', function(req, res) {
    res.json({ version: 'v2', users: [], pagination: {} });
});

app.use('/api/v1', apiV1);
app.use('/api/v2', apiV2);

Middleware for API Versioning

var versionRouter = function(versionMap) {
    return function(req, res, next) {
        var version = req.headers['api-version'] || req.query.version || 'v1';
        var router = versionMap[version];

        if (!router) {
            return res.status(400).json({
                error: 'Unsupported API version',
                supported: Object.keys(versionMap)
            });
        }

        router(req, res, next);
    };
};

app.use('/api', versionRouter({
    'v1': apiV1,
    'v2': apiV2
}));

Now clients can specify the version via header:

curl -H "Api-Version: v2" https://myapp.com/api/users

The req/res Extension Pattern

Middleware can attach properties to req and res that downstream middleware and handlers can use. This is how Express applications share state across the request lifecycle.

// Attach database connection from pool
var attachDb = function(pool) {
    return function(req, res, next) {
        req.db = pool;
        next();
    };
};

// Attach user preferences after authentication
var attachPreferences = function(req, res, next) {
    if (!req.user) return next();

    req.db.query('SELECT * FROM preferences WHERE user_id = $1', [req.user.id])
        .then(function(result) {
            req.user.preferences = result.rows[0] || {};
            next();
        })
        .catch(next);
};

app.use(attachDb(pool));
app.use('/api', authenticate);
app.use('/api', attachPreferences);

This is powerful but requires discipline. Document what properties each middleware attaches. I have debugged too many applications where req.user, req.tenant, req.db, and req.permissions were all set by different middleware and nobody knew which one or in what order.


Third-Party Middleware Worth Knowing

You do not need to build everything from scratch. These are battle-tested and belong in most Express applications.

npm install helmet cors morgan compression cookie-parser
var helmet = require('helmet');
var cors = require('cors');
var morgan = require('morgan');
var compression = require('compression');
var cookieParser = require('cookie-parser');

// Security headers (CSP, HSTS, X-Frame-Options, etc.)
app.use(helmet());

// CORS with specific origin whitelist
app.use(cors({
    origin: ['https://myapp.com', 'https://admin.myapp.com'],
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    credentials: true,
    maxAge: 86400
}));

// HTTP request logging
app.use(morgan('combined'));

// Response compression (gzip/brotli)
app.use(compression({ threshold: 1024 }));

// Cookie parsing
app.use(cookieParser(process.env.COOKIE_SECRET));

Helmet is non-negotiable for production. It sets a dozen security headers with sane defaults. Without it, your app is missing basic protections against clickjacking, MIME sniffing, and XSS.

Morgan is fine for development, but in production I switch to structured JSON logging (like the custom logger shown earlier) for better integration with log aggregation tools.

Compression should sit early in the stack so all responses benefit from it. The threshold option avoids compressing tiny responses where the overhead is not worth it.


Performance Considerations

Middleware ordering directly impacts performance. Every middleware function runs for every matching request, so unnecessary work on hot paths costs you.

Short-Circuit Early

Put middleware that rejects requests (rate limiting, authentication) before middleware that does expensive work (body parsing, database lookups).

// GOOD -- reject unauthenticated requests before parsing the body
app.use('/api', authenticate);
app.use('/api', express.json({ limit: '10kb' }));

// BAD -- parse the body for every request, even unauthorized ones
app.use('/api', express.json({ limit: '10kb' }));
app.use('/api', authenticate);

Scope Middleware Narrowly

Do not run middleware globally when it only applies to a subset of routes.

// BAD -- parses JSON for static file requests too
app.use(express.json());
app.use(express.static('public'));

// GOOD -- only parse JSON for API routes
app.use(express.static('public'));
app.use('/api', express.json());

Avoid Synchronous Blocking

Never do CPU-intensive work in middleware on the main thread. A middleware that runs a regex over a large request body or performs synchronous file I/O blocks the entire event loop.

// BAD -- synchronous file read in middleware
var fs = require('fs');
app.use(function(req, res, next) {
    var config = JSON.parse(fs.readFileSync('config.json', 'utf8')); // blocks!
    req.config = config;
    next();
});

// GOOD -- read config once at startup
var config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
app.use(function(req, res, next) {
    req.config = config;
    next();
});

Complete Working Example: Production Middleware Stack

Here is a full Express application skeleton with a layered middleware architecture. Each layer has a single responsibility, and the layers stack in the correct order.

var express = require('express');
var helmet = require('helmet');
var cors = require('cors');
var compression = require('compression');
var cookieParser = require('cookie-parser');
var crypto = require('crypto');

var app = express();

// =============================================================================
// LAYER 1: Security
// =============================================================================

app.use(helmet({
    contentSecurityPolicy: {
        directives: {
            defaultSrc: ["'self'"],
            scriptSrc: ["'self'"],
            styleSrc: ["'self'", "'unsafe-inline'"],
            imgSrc: ["'self'", "data:", "https:"],
            connectSrc: ["'self'"]
        }
    },
    hsts: { maxAge: 31536000, includeSubDomains: true }
}));

app.use(cors({
    origin: function(origin, callback) {
        var whitelist = [
            'https://myapp.com',
            'https://admin.myapp.com'
        ];
        if (!origin || whitelist.indexOf(origin) !== -1) {
            callback(null, true);
        } else {
            callback(new Error('CORS not allowed'));
        }
    },
    credentials: true,
    maxAge: 86400
}));

// =============================================================================
// LAYER 2: Request Processing
// =============================================================================

app.use(compression({ threshold: 1024 }));
app.use(cookieParser(process.env.COOKIE_SECRET));

// Static files (before body parsing -- static files do not need it)
app.use('/assets', express.static('static', {
    maxAge: '7d',
    etag: true
}));

// Body parsing only for API routes
app.use('/api', express.json({ limit: '10kb' }));
app.use('/api', express.urlencoded({ extended: true, limit: '10kb' }));

// =============================================================================
// LAYER 3: Observability
// =============================================================================

var requestId = function(req, res, next) {
    var id = req.headers['x-request-id'] || crypto.randomUUID();
    req.id = id;
    res.setHeader('X-Request-Id', id);
    next();
};

var requestTimer = function(req, res, next) {
    var start = process.hrtime.bigint();
    res.on('finish', function() {
        var durationMs = Number(process.hrtime.bigint() - start) / 1e6;
        console.log(JSON.stringify({
            timestamp: new Date().toISOString(),
            requestId: req.id,
            method: req.method,
            url: req.originalUrl,
            status: res.statusCode,
            duration: durationMs.toFixed(2) + 'ms',
            ip: req.ip
        }));
    });
    next();
};

app.use(requestId);
app.use(requestTimer);

// =============================================================================
// LAYER 4: Public Routes (no auth required)
// =============================================================================

app.get('/health', function(req, res) {
    res.json({ status: 'ok', uptime: process.uptime() });
});

var authRouter = require('./routes/auth');
app.use('/auth', authRouter);

// =============================================================================
// LAYER 5: Authentication Barrier
// =============================================================================

var jwt = require('jsonwebtoken');

var authenticate = function(req, res, next) {
    var authHeader = req.headers.authorization;
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return res.status(401).json({
            error: 'MISSING_TOKEN',
            message: 'Bearer token is required',
            requestId: req.id
        });
    }

    var token = authHeader.substring(7);
    try {
        var decoded = jwt.verify(token, process.env.JWT_SECRET, {
            issuer: 'myapp',
            algorithms: ['HS256']
        });
        req.user = decoded;
        next();
    } catch (err) {
        var message = 'Invalid token';
        if (err.name === 'TokenExpiredError') {
            message = 'Token has expired';
        }
        return res.status(401).json({
            error: 'INVALID_TOKEN',
            message: message,
            requestId: req.id
        });
    }
};

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

// =============================================================================
// LAYER 6: Protected Routes
// =============================================================================

var asyncHandler = function(fn) {
    return function(req, res, next) {
        Promise.resolve(fn(req, res, next)).catch(next);
    };
};

var authorize = function() {
    var roles = Array.prototype.slice.call(arguments);
    return function(req, res, next) {
        if (!req.user || !req.user.roles) {
            return res.status(403).json({ error: 'No roles assigned' });
        }
        var hasRole = roles.some(function(role) {
            return req.user.roles.indexOf(role) !== -1;
        });
        if (!hasRole) {
            return res.status(403).json({ error: 'Insufficient permissions' });
        }
        next();
    };
};

app.get('/api/users', asyncHandler(function(req, res) {
    var users = [
        { id: 1, name: 'Alice', email: '[email protected]' },
        { id: 2, name: 'Bob', email: '[email protected]' }
    ];
    res.json({ users: users, requestId: req.id });
}));

app.delete('/api/users/:id', authorize('admin'), asyncHandler(function(req, res) {
    // Delete user logic here
    res.json({ deleted: req.params.id, requestId: req.id });
}));

// =============================================================================
// LAYER 7: Error Handling
// =============================================================================

// 404 handler -- must come after all routes
app.use(function(req, res) {
    res.status(404).json({
        error: 'NOT_FOUND',
        message: 'The requested resource does not exist',
        path: req.originalUrl,
        requestId: req.id
    });
});

// Global error handler -- must be the very last app.use()
app.use(function(err, req, res, next) {
    var status = err.status || err.statusCode || 500;

    // Log the full error server-side
    console.error(JSON.stringify({
        timestamp: new Date().toISOString(),
        requestId: req.id,
        error: err.message,
        stack: err.stack,
        method: req.method,
        url: req.originalUrl
    }));

    // Send sanitized error to client
    res.status(status).json({
        error: status === 500 ? 'INTERNAL_ERROR' : err.code || 'ERROR',
        message: status === 500 ? 'An unexpected error occurred' : err.message,
        requestId: req.id
    });
});

// =============================================================================
// START SERVER
// =============================================================================

var PORT = process.env.PORT || 8080;
app.listen(PORT, function() {
    console.log('Server running on port ' + PORT);
    console.log('Environment: ' + (process.env.NODE_ENV || 'development'));
});

module.exports = app;

Run it:

npm install express helmet cors compression cookie-parser jsonwebtoken
node app.js

Test it:

# Health check (public)
curl http://localhost:8080/health

# Response:
# {"status":"ok","uptime":1.234}

# Protected route without token
curl http://localhost:8080/api/users

# Response:
# {"error":"MISSING_TOKEN","message":"Bearer token is required","requestId":"abc-123"}

# 404
curl http://localhost:8080/api/nonexistent

# Response:
# {"error":"NOT_FOUND","message":"The requested resource does not exist","path":"/api/nonexistent","requestId":"def-456"}

Server logs:

{"timestamp":"2026-02-08T14:30:00.000Z","requestId":"abc-123","method":"GET","url":"/health","status":200,"duration":"0.45ms","ip":"::1"}
{"timestamp":"2026-02-08T14:30:01.000Z","requestId":"def-456","method":"GET","url":"/api/users","status":401,"duration":"0.12ms","ip":"::1"}

Common Issues and Troubleshooting

1. "Can't set headers after they are sent to the client"

Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
    at ServerResponse.setHeader (_http_outgoing.js:561:11)

Cause: You called next() after already sending a response, or two middleware both try to send a response.

Fix: Always return when sending a response to prevent further execution.

// BROKEN
app.use(function(req, res, next) {
    if (!req.headers.authorization) {
        res.status(401).json({ error: 'Unauthorized' });
        // Missing return! next() runs on the next line
    }
    next();
});

// FIXED
app.use(function(req, res, next) {
    if (!req.headers.authorization) {
        return res.status(401).json({ error: 'Unauthorized' });
    }
    next();
});

2. Error handler not being called

UnhandledPromiseRejectionWarning: Error: Database connection failed

Cause: Your error-handling middleware has three parameters instead of four, or it is registered before the route that throws.

Fix: Ensure the error handler has exactly four parameters and is registered last.

// BROKEN -- only 3 params, Express treats this as regular middleware
app.use(function(err, req, res) {
    res.status(500).json({ error: err.message });
});

// FIXED -- exactly 4 params, Express recognizes this as error handler
app.use(function(err, req, res, next) {
    res.status(500).json({ error: err.message });
});

3. req.body is undefined

TypeError: Cannot read properties of undefined (reading 'email')
    at /app/routes/users.js:15:28

Cause: The express.json() middleware is not registered, is registered after the route, or the request is missing the Content-Type: application/json header.

Fix: Register body parsing middleware before any route that reads req.body.

// BROKEN -- route comes before body parser
app.post('/api/users', createUser);
app.use(express.json());

// FIXED -- body parser comes first
app.use(express.json());
app.post('/api/users', createUser);

Also verify the client is sending the correct Content-Type header:

# BROKEN -- no content type
curl -X POST -d '{"email":"[email protected]"}' http://localhost:8080/api/users

# FIXED -- explicit content type
curl -X POST -H "Content-Type: application/json" -d '{"email":"[email protected]"}' http://localhost:8080/api/users

4. Middleware runs for wrong routes

# You expect middleware to only run for /api/* but it runs for everything

Cause: You used app.use(middleware) without a path prefix, or you used app.use('/', middleware) which matches everything.

Fix: Always specify the path prefix when you want scoped middleware.

// BROKEN -- runs for ALL requests including static files
app.use(authenticate);

// FIXED -- only runs for /api routes
app.use('/api', authenticate);

5. Async errors crash the process

node:internal/process/promises:289
            triggerUncaughtException(err, true /* fromPromise */);
UnhandledPromiseRejection: This error originated either by throwing inside of an async function...

Cause: An async route handler or middleware rejects a Promise, and Express 4 does not catch it.

Fix: Wrap all async handlers with the asyncHandler wrapper shown earlier, or use express-async-errors.

npm install express-async-errors
require('express-async-errors'); // Must be required before Express routes are defined

Best Practices

  • Register error-handling middleware last. Express processes middleware in registration order. Error handlers with four parameters must sit at the bottom of the stack to catch errors from all routes above them.

  • Always return after sending a response. Use return res.status(xxx).json(...) to prevent accidental double responses. This is the single most common middleware bug.

  • Scope middleware as narrowly as possible. Do not run body parsing, authentication, or database middleware on static file routes. Use path prefixes and router-level middleware to limit scope.

  • Use middleware factories for configurable behavior. If you are copy-pasting middleware with slight variations, refactor it into a factory function that accepts options. This applies to rate limiting, validation, authorization, and caching.

  • Keep middleware focused on a single concern. A middleware function should do one thing: parse a body, check a token, log a request, attach a header. If your middleware is doing three things, split it into three middleware functions.

  • Wrap async middleware in Express 4. Until you migrate to Express 5, always use an asyncHandler wrapper for async/await route handlers and middleware. Unhandled rejections will crash your process or leave requests hanging.

  • Order middleware for performance. Put cheap checks (rate limiting, auth token validation) before expensive operations (body parsing, database queries). Rejected requests should exit the pipeline as early as possible.

  • Document the request object extensions. When middleware attaches properties to req (like req.user, req.id, req.db), document what each middleware adds and in what order. Future developers -- including future you -- will thank you.

  • Test middleware in isolation. Middleware functions are just functions. You can unit test them by passing mock req, res, and next objects without spinning up a full Express server.


References

Powered by Contentful