Middleware sounds simple until production happens. You write a nice clean function, it works fine on your laptop, then at 2am it's eating all your server memory or hanging requests forever. I've been there. We've all been there.
The problem is that most middleware tutorials show you the happy path - they never mention that your authentication middleware will block everything when Redis goes down, or that your logging middleware will consume 2GB of memory if someone sends a 100MB request body.
The Reality Check: What Actually Gets Downloaded (September 2025)
Here's what's actually getting downloaded (checked npm-stat last week):
- Express.js: Still crushing it with something like 45M+ weekly downloads - the 800-pound gorilla isn't going anywhere
- Koa.js: Around 5M weekly downloads - solid but not exciting
- Fastify: Roughly 3M weekly downloads - the performance darling
Express isn't going anywhere despite what HackerNews tells you. It's boring, predictable, and has middleware for literally everything. Fastify is faster and has better architecture, but good luck finding middleware for that obscure OAuth provider you need to integrate with.
The Node.js ecosystem has evolved around Express patterns. When you look at production deployment guides, security best practices, and monitoring solutions, they all assume Express middleware patterns. This ecosystem momentum is why major companies still build on Express despite faster alternatives.
The real difference isn't those synthetic benchmarks (hello world apps don't represent real workloads) - it's whether your middleware will work when things go wrong. Express middleware fails loudly and obviously. Fastify's plugin system is elegant until you need to debug why your authentication isn't loading in the right order.
Express.js: Where Dreams Go to Die (And Sometimes Live)
Express middleware is just a function that gets req
, res
, and next
. Sounds simple, right? It is, until you forget to call `next()` and wonder why half your routes stopped working. Or when you call next()
twice and get that cryptic "Cannot set headers after they are sent" error.
The Production Nightmare Stories
Every Express app is middleware stacked on middleware stacked on more middleware. One bad middleware kills everything downstream. Here's the fun part - you won't know which one until 3am on a Friday.
I once spent 4 hours debugging random API timeouts. Turns out some genius added body parsing middleware that tried to shove 50MB file uploads into memory. No error, no log, just... silence. Like the server gave up on life. Another time, our rate limiting middleware started rejecting every request because the Redis connection pool was exhausted, but the middleware didn't handle connection failures. It just hung forever waiting for a response that would never come.
Production middleware failures follow predictable patterns. Memory leaks from unbounded arrays, unhandled promise rejections that crash the process, connection pool exhaustion, and race conditions in shared state. The Node.js debugging guide helps, but middleware bugs are context-dependent - they only surface under specific request patterns or loads.
Middleware That Actually Works
Here's logging middleware that won't kill your server:
// This took me way too long to get right, and it still breaks sometimes
function requestLogger(options = {}) {
const { includeBody = false, maxBodySize = 1024 } = options;
// TODO: make this configurable instead of hardcoded
const sensitiveFields = ['password', 'token', 'authorization', 'cookie'];
return (req, res, next) => {
const start = process.hrtime.bigint();
// Don't log health checks - they spam the shit out of the logs
if (req.url === '/health' || req.url === '/ping') {
return next();
}
const requestData = {
method: req.method,
url: req.url,
ip: req.ip || 'unknown', // Sometimes this is undefined, Node.js is weird
userAgent: req.get('User-Agent') || 'none'
};
// Only log body if it's small - learned this the hard way after a 50MB upload killed our logs
if (includeBody && req.body) {
try {
// This JSON.stringify can throw if there's circular refs, ask me how I know
const bodyStr = JSON.stringify(req.body);
if (bodyStr.length < maxBodySize) {
requestData.body = sanitizeObject(req.body, sensitiveFields);
}
} catch (err) {
requestData.body = '[CIRCULAR_OR_FAILED_TO_STRINGIFY]'; // Happened more than I'd like to admit
}
}
res.on('finish', () => {
const duration = Number(process.hrtime.bigint() - start) / 1000000;
// Only log slow requests to reduce noise - trust me, you don't want every request logged
if (duration > 100) {
console.log(JSON.stringify({
...requestData,
statusCode: res.statusCode,
duration: `${duration.toFixed(2)}ms`,
warning: duration > 1000 ? 'SLOW_REQUEST' : undefined
}));
}
});
next();
};
}
function sanitizeObject(obj, sensitiveFields) {
if (!obj || typeof obj !== 'object') return obj;
const sanitized = { ...obj };
sensitiveFields.forEach(field => {
// Handle nested fields like \"auth.password\"
if (sanitized[field]) {
sanitized[field] = '[REDACTED]';
}
});
return sanitized;
}
Authentication: The Place Where Everything Goes Wrong
JWT authentication seems easy until you realize there are like 50 different ways it can fail. Missing tokens, expired tokens, malformed tokens, wrong algorithms, clock drift, and my personal favorite: when someone puts the JWT secret in the wrong environment variable and it takes down prod for 2 hours.
The JWT specification is deceptively simple, but production implementations need to handle algorithm confusion attacks, timing attacks on HMAC verification, and key rotation scenarios. The OWASP JWT Security Cheat Sheet covers the security pitfalls, while Auth0's JWT handbook explains the cryptographic details.
const jwt = require('jsonwebtoken');
function createAuthMiddleware(options = {}) {
const {
secret = process.env.JWT_SECRET,
skipPaths = ['/health', '/ping', '/metrics'],
optional = false
} = options;
// Fail fast during startup, not during request handling - learned this after 2am debugging session in December 2023
if (!secret && !optional) {
throw new Error('JWT_SECRET missing - seriously, set the damn environment variable');
}
// TODO: add support for rotating secrets, but that's a problem for future me
return async (req, res, next) => {
// Skip auth for health checks and public paths
if (skipPaths.includes(req.path) || req.path.startsWith('/public/')) {
return next();
}
const authHeader = req.headers.authorization;
// Handle both \"Bearer TOKEN\" and just \"TOKEN\" because clients are inconsistent
const token = authHeader?.startsWith('Bearer ')
? authHeader.slice(7)
: authHeader;
if (!token) {
if (optional) {
req.user = null;
return next();
}
return res.status(401).json({
error: 'Missing authorization header',
code: 'NO_TOKEN',
hint: 'Add Authorization: Bearer <your-token>'
});
}
try {
// Clock tolerance for servers with slightly different times
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
clockTolerance: 30 // 30 seconds wiggle room
});
// Extra paranoia checks
if (!decoded.sub) {
return res.status(401).json({
error: 'Token missing user ID',
code: 'INVALID_CLAIMS'
});
}
req.user = decoded;
next();
} catch (error) {
// Be specific about what went wrong
let errorResponse = {
error: 'Token verification failed',
code: 'AUTH_ERROR'
};
if (error.name === 'TokenExpiredError') {
errorResponse = {
error: 'Token expired',
code: 'TOKEN_EXPIRED',
hint: 'Get a new token from /auth/refresh'
};
} else if (error.name === 'JsonWebTokenError') {
errorResponse = {
error: 'Invalid token format',
code: 'MALFORMED_TOKEN'
};
}
// Log for debugging but don't expose internals - spent 3 hours once because I wasn't logging enough
console.warn('Auth failed:', {
error: error.message,
token: token.substring(0, 20) + '...',
ip: req.ip,
userAgent: req.get('User-Agent')
// TODO: add request ID for better tracing, but the logging system sucks
});
res.status(401).json(errorResponse);
}
};
}
The point is: auth middleware breaks in production in ways that make absolutely no sense at 3am. Plan for every failure mode or spend your weekends debugging why your API suddenly locked everyone out. Ask me how I know. (Spoiler: Node 18 changed crypto defaults and broke our JWT verification for exactly 3 hours on a Saturday.)
Fastify: When Express Isn't Fast Enough (But Do You Really Need It?)
Fastify's plugin system is genuinely elegant. Instead of stacking middleware like Express, you register plugins that have their own isolated scope. It's like microservices for your middleware - which sounds great until you need to debug why Plugin A can't see the configuration from Plugin B.
The Fastify Reality Check
Fastify plugins solve real problems:
- Encapsulation: Your auth plugin can't accidentally break your logging plugin
- Performance: Schema validation is compiled once at startup, not on every request
- Dependency injection: Plugins declare what they need and load in the right order
But they also create new problems:
- Debugging hell: Error stack traces through plugin loading are... not fun
- Learning curve: Express middleware is just functions. Fastify plugins have lifecycle hooks, decorators, and registration patterns
- Ecosystem: Still smaller than Express, though growing fast
Here's Fastify auth that actually works (most of the time):
const fp = require('fastify-plugin');
async function authPlugin(fastify, options) {
const {
secret = process.env.JWT_SECRET,
skipRoutes = ['/health', '/ping'],
optional = false
} = options;
if (!secret && !optional) {
throw new Error('JWT_SECRET missing - this will break everything');
}
// Register the JWT plugin - this can fail in weird ways
try {
await fastify.register(require('@fastify/jwt'), {
secret: secret
});
} catch (error) {
// If JWT plugin fails to register, the whole app won't start
fastify.log.error('JWT plugin registration failed:', error);
throw error;
}
// This decorator gets added to every request
fastify.decorate('authenticate', async function(request, reply) {
if (skipRoutes.includes(request.routerPath) || skipRoutes.includes(request.url)) {
return;
}
try {
await request.jwtVerify();
// At this point request.user is populated (hopefully)
} catch (err) {
if (optional) {
request.user = null;
return;
}
// Fastify JWT error codes are... inconsistent
let errorMsg = 'Token verification failed';
if (err.code === 'FST_JWT_NO_AUTHORIZATION_IN_HEADER') {
errorMsg = 'Missing Authorization header';
} else if (err.code === 'FST_JWT_AUTHORIZATION_TOKEN_EXPIRED') {
errorMsg = 'Token expired - get a new one';
} else if (err.code === 'FST_JWT_AUTHORIZATION_TOKEN_INVALID') {
errorMsg = 'Token is malformed or invalid';
}
// Log for debugging because Fastify errors can be cryptic - this saved my ass when debugging plugin load order in Fastify 4.2
fastify.log.warn('Auth failure:', {
error: err.message,
code: err.code,
url: request.url,
ip: request.ip
});
return reply.code(401).send({
error: errorMsg,
code: 'AUTH_FAILED'
});
}
});
// Role checking that actually checks roles
fastify.decorate('requireRole', function(allowedRoles = []) {
return async function(request, reply) {
if (!request.user) {
return reply.code(401).send({ error: 'Not authenticated' });
}
const userRoles = request.user.roles || [];
const hasPermission = allowedRoles.some(role => userRoles.includes(role));
if (!hasPermission) {
fastify.log.warn('Role check failed:', {
user: request.user.id,
userRoles,
required: allowedRoles
});
return reply.code(403).send({
error: 'Insufficient permissions',
required: allowedRoles,
current: userRoles
});
}
};
});
}
// The fp wrapper is important - without it, plugin scope gets weird
module.exports = fp(authPlugin, {
name: 'auth-plugin',
version: '1.0.0',
dependencies: ['@fastify/jwt'] // This plugin won't load until @fastify/jwt is ready
});
Schema Validation: Fastify's Secret Weapon
One of Fastify's biggest advantages is built-in JSON Schema validation. This isn't just input validation - it's performance optimization. Fastify compiles schemas at startup and uses them for both validation and response serialization.
// Fastify route with comprehensive schema validation
const userRouteSchema = {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: {
type: 'string',
format: 'email',
maxLength: 255
},
password: {
type: 'string',
minLength: 8,
maxLength: 128,
pattern: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]'
},
name: {
type: 'string',
minLength: 1,
maxLength: 100
}
},
additionalProperties: false
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
name: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' }
}
},
400: {
type: 'object',
properties: {
error: { type: 'string' },
code: { type: 'string' },
validation: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
message: { type: 'string' }
}
}
}
}
}
}
};
fastify.post('/users', {
schema: userRouteSchema,
preHandler: [fastify.authenticate]
}, async (request, reply) => {
const { email, password, name } = request.body;
try {
const user = await createUser({ email, password, name });
reply.code(201).send({
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt
});
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
return reply.code(409).send({
error: 'Email already exists',
code: 'DUPLICATE_EMAIL'
});
}
fastify.log.error('User creation failed:', error);
reply.code(500).send({
error: 'Internal server error',
code: 'CREATE_USER_FAILED'
});
}
});
The schema validation above happens at startup compilation, not runtime, making it significantly faster than manual validation. More importantly, the response schema ensures consistent API contracts and enables automatic OpenAPI documentation generation.
Performance Considerations: The Hidden Costs
Middleware performance isn't just about execution time - it's about memory usage, error handling, and scalability patterns that only become apparent under load.
Memory Leaks in Middleware
The most common middleware performance killer is memory leaks caused by closures that hold references to large objects. Here's a pattern that will slowly consume all available memory:
// DON'T DO THIS - Memory leak waiting to happen
function badLoggingMiddleware() {
const requestHistory = []; // This array grows forever
return (req, res, next) => {
requestHistory.push({
url: req.url,
body: req.body, // Keeps entire request body in memory
timestamp: Date.now()
});
console.log(`Total requests: ${requestHistory.length}`);
next();
};
}
The correct approach uses bounded collections or external storage:
// Better: Bounded logging with LRU cache
const LRU = require('lru-cache');
function smartLoggingMiddleware(options = {}) {
const { maxEntries = 1000, maxAge = 60000 } = options;
const requestCache = new LRU({
max: maxEntries,
ttl: maxAge
});
return (req, res, next) => {
const requestId = `${req.method}:${req.url}:${Date.now()}`;
requestCache.set(requestId, {
method: req.method,
url: req.url,
userAgent: req.get('User-Agent'),
timestamp: Date.now()
});
// Log statistics without keeping full history
console.log(`Active requests in cache: ${requestCache.size}`);
next();
};
}
The difference is dramatic: the bad middleware will consume 100MB+ of memory after handling 10,000 requests, while the LRU-based version maintains constant memory usage.
This foundation sets the stage for understanding how modern Node.js middleware and plugins work in practice. The next sections will dive into specific implementation patterns, testing strategies, and production deployment considerations that separate amateur middleware from enterprise-grade components.