Node.js Middleware: Express and Koa Patterns
Middleware functions are the backbone of Node.js web frameworks. They intercept HTTP requests before they reach your route handlers, allowing you to execute code, modify request/response objects, and...
Key Insights
- Express uses linear middleware with callbacks while Koa embraces async/await with an “onion model” that enables elegant upstream/downstream flow control
- Middleware ordering is critical—authentication should precede route handlers, error handlers must come last in Express, and async operations in Koa naturally handle control flow
- Both frameworks excel at different use cases: Express for mature ecosystems and quick setup, Koa for modern async patterns and fine-grained control over request/response cycles
Understanding Middleware Architecture
Middleware functions are the backbone of Node.js web frameworks. They intercept HTTP requests before they reach your route handlers, allowing you to execute code, modify request/response objects, and control the request-response cycle. Think of middleware as a series of filters that each request passes through.
Here’s the conceptual flow:
// Middleware execution flow
Request → MW1 → MW2 → MW3 → Route Handler → MW3 → MW2 → MW1 → Response
// ────────────────→ ←────────────────
// Downstream flow Upstream flow (Koa)
In Express, execution is primarily linear and downstream. In Koa, the onion model allows you to execute code both before and after downstream middleware completes.
Express Middleware Fundamentals
Express middleware follows a straightforward signature: (req, res, next). Each middleware function receives the request object, response object, and a callback to pass control to the next middleware.
const express = require('express');
const app = express();
// Basic logging middleware
app.use((req, res, next) => {
console.log(`${req.method} ${req.path} - ${new Date().toISOString()}`);
next(); // Critical: pass control to next middleware
});
// Authentication middleware
app.use((req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
req.user = verifyToken(token);
next();
} catch (error) {
next(error); // Pass errors to error handler
}
});
// Route-specific middleware
app.get('/admin', requireAdmin, (req, res) => {
res.json({ message: 'Admin area' });
});
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
next();
}
Error handling in Express requires a special four-parameter signature. These middleware functions must be defined after all other middleware and routes:
// Error handling middleware (must have 4 parameters)
app.use((err, req, res, next) => {
console.error(err.stack);
// Handle specific error types
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message });
}
if (err.name === 'UnauthorizedError') {
return res.status(401).json({ error: 'Invalid token' });
}
// Default error response
res.status(500).json({ error: 'Internal server error' });
});
Middleware ordering matters significantly in Express. This won’t work correctly:
// WRONG: Error handler before routes
app.use(errorHandler);
app.get('/users', getUsers); // Errors won't be caught
// CORRECT: Error handler after routes
app.get('/users', getUsers);
app.use(errorHandler);
Koa Middleware Patterns
Koa takes a radically different approach using async/await and a unified context object. Instead of separate req and res objects, Koa provides ctx with ctx.request and ctx.response properties.
const Koa = require('koa');
const app = new Koa();
// Logging middleware with timing
app.use(async (ctx, next) => {
const start = Date.now();
await next(); // Wait for downstream middleware
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
// Authentication middleware
app.use(async (ctx, next) => {
const token = ctx.headers.authorization;
if (!token) {
ctx.status = 401;
ctx.body = { error: 'No token provided' };
return; // Don't call next()
}
try {
ctx.state.user = await verifyTokenAsync(token);
await next();
} catch (error) {
ctx.status = 401;
ctx.body = { error: 'Invalid token' };
}
});
The onion model is Koa’s killer feature. Code before await next() runs on the way down, code after runs on the way back up:
app.use(async (ctx, next) => {
console.log('1: Before downstream');
await next();
console.log('1: After downstream');
});
app.use(async (ctx, next) => {
console.log('2: Before downstream');
await next();
console.log('2: After downstream');
});
app.use(async (ctx) => {
console.log('3: Route handler');
ctx.body = 'Hello';
});
// Output:
// 1: Before downstream
// 2: Before downstream
// 3: Route handler
// 2: After downstream
// 1: After downstream
This pattern enables powerful response manipulation:
// Compression middleware
app.use(async (ctx, next) => {
await next(); // Let route set the body
if (ctx.body && ctx.acceptsEncodings('gzip')) {
ctx.body = await compress(ctx.body);
ctx.set('Content-Encoding', 'gzip');
}
});
Common Middleware Patterns
Let’s implement practical middleware patterns for both frameworks.
JWT Authentication:
// Express version
function jwtAuth(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Missing token' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return res.status(401).json({ error: 'Invalid token' });
}
req.user = decoded;
next();
});
}
// Koa version
async function jwtAuth(ctx, next) {
const token = ctx.headers.authorization?.replace('Bearer ', '');
if (!token) {
ctx.throw(401, 'Missing token');
}
try {
ctx.state.user = jwt.verify(token, process.env.JWT_SECRET);
await next();
} catch (error) {
ctx.throw(401, 'Invalid token');
}
}
Request Validation:
// Express with validation schema
const validateBody = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).json({
error: error.details.map(d => d.message)
});
}
req.body = value; // Use sanitized value
next();
};
app.post('/users', validateBody(userSchema), createUser);
// Koa version
const validateBody = (schema) => async (ctx, next) => {
const { error, value } = schema.validate(ctx.request.body);
if (error) {
ctx.throw(400, error.details.map(d => d.message).join(', '));
}
ctx.request.body = value;
await next();
};
Rate Limiting:
// Simple in-memory rate limiter (use Redis in production)
const rateLimiter = (maxRequests, windowMs) => {
const requests = new Map();
return async (ctx, next) => {
const key = ctx.ip;
const now = Date.now();
const windowStart = now - windowMs;
// Get requests in current window
const userRequests = requests.get(key) || [];
const recentRequests = userRequests.filter(time => time > windowStart);
if (recentRequests.length >= maxRequests) {
ctx.status = 429;
ctx.body = { error: 'Too many requests' };
return;
}
recentRequests.push(now);
requests.set(key, recentRequests);
await next();
};
};
app.use(rateLimiter(100, 60000)); // 100 requests per minute
Performance and Best Practices
Middleware ordering dramatically impacts performance. Place fast, filtering middleware early:
// GOOD: Fast checks first
app.use(rateLimiter);
app.use(cors);
app.use(authenticate);
app.use(bodyParser); // Only parse if request passes filters
app.use(validateRequest);
// BAD: Expensive operations first
app.use(bodyParser); // Parsing bodies for requests that will be rejected
app.use(authenticate);
app.use(rateLimiter);
Avoid blocking operations in middleware:
// BAD: Synchronous file operations
app.use((req, res, next) => {
const data = fs.readFileSync('./config.json'); // Blocks event loop
req.config = JSON.parse(data);
next();
});
// GOOD: Async operations or cache
const config = require('./config.json'); // Load once at startup
app.use((req, res, next) => {
req.config = config;
next();
});
In Koa, always use await next() unless you intentionally want to short-circuit:
// BAD: Forgot await
app.use(async (ctx, next) => {
next(); // Returns a promise that's ignored
console.log('This runs immediately, not after downstream');
});
// GOOD: Properly awaited
app.use(async (ctx, next) => {
await next();
console.log('This runs after downstream completes');
});
Migration Considerations: Express to Koa
When migrating from Express to Koa, the biggest mental shift is embracing async/await and the context object:
// Express pattern
app.use((req, res, next) => {
db.getUser(req.params.id, (err, user) => {
if (err) return next(err);
req.user = user;
next();
});
});
// Koa equivalent
app.use(async (ctx, next) => {
ctx.state.user = await db.getUser(ctx.params.id);
await next();
});
Error handling is cleaner in Koa with try-catch:
// Express: Error handling with callbacks
app.get('/users/:id', (req, res, next) => {
User.findById(req.params.id, (err, user) => {
if (err) return next(err);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
});
});
// Koa: Natural async error handling
app.use(async (ctx) => {
const user = await User.findById(ctx.params.id);
if (!user) ctx.throw(404, 'Not found');
ctx.body = user;
});
Choose Express for its massive ecosystem and when you need maximum compatibility with existing middleware. Choose Koa when you want modern async patterns, cleaner error handling, and don’t mind a smaller (but growing) ecosystem. Both are production-ready; the choice depends on your team’s preferences and project requirements.