The Production Middleware Patterns That Actually Work
Building custom middleware is easy.
Building middleware that doesn't randomly break on Tuesday at 3am is hard. Here are the patterns that actually work when your app is getting hammered by real traffic.
Understanding the Middleware Chain (Where Everything Goes Wrong)
Express middleware is just a function with access to req
, res
, and next
. The magic (and danger) is in the order. One middleware not calling next()
and your request dies in middleware purgatory.
// This will kill your app silently
app.use((req, res, next) => {
console.log('Request received');
// Forgot next()
- request hangs forever
});
// This actually works
app.use((req, res, next) => {
console.log('Request received');
next(); // ALWAYS call next() unless you're ending the response
});
The request flows through middleware in the exact order you define them. Authentication before route handlers. Error handling last. Fuck up the order and spend hours debugging why your auth isn't running. The Express routing guide explains middleware execution order in detail.
Custom Authentication Middleware (Real Production Pattern)
Here's how to build auth middleware that doesn't suck:
const jwt = require('jsonwebtoken');
const authenticate = (options = {}) => {
return async (req, res, next) => {
try {
// Get token from multiple sources (headers, cookies, query)
let token = req.headers.authorization?.replace('Bearer ', '') ||
req.cookies?.access_token ||
req.query?.token;
if (!token) {
if (options.optional) {
req.user = null;
return next();
}
return res.status(401).json({ error: 'Authentication required' });
}
// Verify and decode token
const decoded = jwt.verify(token, process.env.
JWT_SECRET);
// Optional: Check if user still exists in database
if (options.checkUserExists) {
const user = await User.findById(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
} else {
req.user = decoded;
}
next();
} catch (error) {
// JWT expired or invalid
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}
// Log unexpected errors but don't expose them
console.error('Auth middleware error:', error);
res.status(500).json({ error: 'Authentication error' });
}
};
};
// Usage
- flexible for different requirements
app.use('/api/public', authenticate({ optional: true }));
app.use('/api/protected', authenticate({ checkUserExists: true }));
Key lessons:
Handle multiple token sources, make it configurable, don't leak error details, and always have fallbacks. For more JWT security best practices, check the OWASP JWT Cheat Sheet and Auth0's JWT guide.
Request Logging Middleware (Debug Your Prod Issues)
Logging middleware seems simple until you need to debug production issues.
Here's what actually helps:
const morgan = require('morgan');
const create
RequestLogger = (env = 'development') => {
if (env === 'production') {
// Structured logging for production
- parseable by log aggregators
return morgan('combined', {
stream: {
write: (message) => {
const log = {
timestamp: new Date().to
ISOString(),
level: 'info',
type: 'request',
message: message.trim()
};
console.log(JSON.stringify(log));
}
}
});
} else {
// Readable format for development
return morgan('dev');
}
};
// Custom request context middleware
- adds request ID
const add
RequestContext = (req, res, next) => {
req.requestId = Math.random().toString(36).substring(2);
req.startTime = Date.now();
// Add request ID to response headers for debugging
res.set('X-Request-ID', req.requestId);
next();
};
// Performance monitoring middleware
const trackPerformance = (req, res, next) => {
const originalSend = res.send;
res.send = function(data) {
const responseTime = Date.now()
- req.startTime;
// Log slow requests (>1s) for investigation
if (responseTime > 1000) {
console.warn(`Slow request detected: ${req.method} ${req.path}
- ${response
Time}ms`);
}
// Add performance headers
res.set('X-Response-Time', `${responseTime}ms`);
originalSend.call(this, data);
};
next();
};
app.use(addRequestContext);
app.use(createRequestLogger(process.env.
NODE_ENV));
app.use(trackPerformance);
This setup gives you request tracing, performance monitoring, and structured logs that actually help during outages. For production logging strategies, see Winston's best practices and The 12-Factor App logging guidelines.
Error Handling Middleware (The Final Safety Net)
Error middleware is your last chance to prevent crashes.
Express 5 catches promise rejections automatically, but you still need proper error boundaries:
// Async error wrapper
- catches promise rejections
const async
Handler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Validation error middleware
- handles specific error types
const handleValidationError = (err, req, res, next) => {
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(e => e.message);
return res.status(400).json({
error: 'Validation failed',
details: errors,
requestId: req.request
Id
});
}
next(err);
};
// Database error middleware
- handles DB connection issues
const handleDatabaseError = (err, req, res, next) => {
if (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT') {
console.error('Database connection error:', err);
return res.status(503).json({
error: 'Service temporarily unavailable',
requestId: req.request
Id
});
}
next(err);
};
// Generic error handler
- catches everything else
const genericErrorHandler = (err, req, res, next) => {
// Log all errors with context
console.error('Unhandled error:', {
error: err.message,
stack: err.stack,
requestId: req.request
Id,
url: req.url,
method: req.method,
userAgent: req.get('User-Agent'),
timestamp: new Date().toISOString()
});
// Don't leak internal details in production
if (process.env.
NODE_ENV === 'production') {
res.status(500).json({
error: 'Internal server error',
requestId: req.requestId
});
} else {
res.status(500).json({
error: err.message,
stack: err.stack,
requestId: req.request
Id
});
}
};
// Order matters
- specific handlers first, generic handler last
app.use(handleValidationError);
app.use(handleDatabaseError);
app.use(genericErrorHandler);
The key is layered error handling
- catch specific cases first, then have a generic fallback that doesn't crash your app.
Rate Limiting Middleware (Prevent Your API From Getting Hammered)
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('redis');
// Redis-based rate limiting for multi-server deployments
const redisClient = Redis.createClient({
host: process.env.
REDIS_HOST || 'localhost'
});
const createRateLimiter = (options) => {
return rateLimit({
store: new RedisStore({
client: redis
Client,
prefix: 'rate_limit:'
}),
windowMs: options.window
Ms || 15 * 60 * 1000, // 15 minutes
max: options.max || 100, // requests per window
message: {
error: 'Too many requests',
retryAfter:
Math.ceil(options.window
Ms / 1000)
},
standardHeaders: true,
legacyHeaders: false,
// Skip rate limiting for whitelisted IPs
skip: (req) => {
const whitelist = process.env.
RATE_LIMIT_WHITELIST?.split(',') || [];
return whitelist.includes(req.ip);
}
});
};
// Different limits for different endpoints
app.use('/api/auth', createRateLimiter({ max: 5, windowMs: 15 * 60 * 1000 })); // Stricter for auth
app.use('/api/', createRateLimiter({ max: 100 })); // General API limit
Use Redis for rate limiting if you have multiple servers, otherwise in-memory is fine for single instances.
For advanced rate limiting patterns, check the express-rate-limit documentation and Redis rate limiting patterns.
Middleware Patterns That'll Save Your Ass
**Pattern 1:
Conditional Middleware**
// Only apply middleware based on conditions
const conditional
Middleware = (condition, middleware) => {
return (req, res, next) => {
if (condition(req)) {
middleware(req, res, next);
} else {
next();
}
};
};
// Usage: Only log requests to API endpoints
app.use(conditional
Middleware(
req => req.path.startsWith('/api'),
morgan('combined')
));
**Pattern 2:
Middleware Factories**
// Reusable middleware with configuration
const create
Validator = (schema) => {
return (req, res, next) => {
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({
error: 'Validation failed',
details: error.details.map(d => d.message)
});
}
next();
};
};
// Usage
const userSchema = Joi.object({
email:
Joi.string().email().required(),
password: Joi.string().min(8).required()
});
app.post('/users', create
Validator(userSchema), createUser);
**Pattern 3:
Middleware Composition**
// Combine multiple middleware into one
const compose
Middleware = (...middlewares) => {
return (req, res, next) => {
let index = 0;
const dispatch = (i) => {
if (i >= middlewares.length) return next();
const middleware = middlewares[i];
middleware(req, res, () => dispatch(i + 1));
};
dispatch(0);
};
};
// Usage: Create authentication + authorization combo
const protectedRoute = composeMiddleware(
authenticate({ checkUserExists: true }),
authorize(['admin', 'moderator']),
rateLimit({ max: 10 })
);
app.use('/admin', protected
Route);
These patterns let you build middleware that's reusable, testable, and doesn't break when requirements change.
For more middleware patterns, check Express middleware examples, [Node.js design patterns](https://github.com/Packt
Publishing/Node.js-Design-Patterns-Third-Edition), Express best practices, Middleware testing strategies, Error handling patterns, and Production middleware security.