Middleware Composition Patterns in Hono
Deep dive into composing middleware chains in Hono for API gateways, covering ordering strategies, conditional execution, and reusable middleware factories.
Middleware is the backbone of any API gateway. Every request that passes through Dispatch traverses a carefully ordered chain of middleware functions that handle authentication, rate limiting, logging, request transformation, and more. Getting this chain right -- its ordering, its conditional logic, its error propagation -- is the difference between a gateway that hums along at scale and one that collapses under its own complexity. This article examines the middleware composition patterns we use in Dispatch, built on Hono's elegant middleware system.
Understanding Hono's Middleware Model
Hono implements the "onion" middleware model, similar to Koa. Each middleware function receives a context object and a next function. Calling next() passes control to the next middleware in the chain, and when the chain completes, execution returns back through each middleware in reverse order. This bidirectional flow is what makes the model so powerful for gateways.
import { Hono } from 'hono'
import type { MiddlewareHandler } from 'hono'
const app = new Hono()
const timing: MiddlewareHandler = async (c, next) => {
const start = performance.now()
// --- INBOUND: request is heading downstream ---
await next()
// --- OUTBOUND: response is heading back to client ---
const duration = performance.now() - start
c.header('X-Response-Time', `${duration.toFixed(2)}ms`)
}
const requestId: MiddlewareHandler = async (c, next) => {
const id = c.req.header('X-Request-ID') || crypto.randomUUID()
c.set('requestId', id)
c.header('X-Request-ID', id)
await next()
}
const logger: MiddlewareHandler = async (c, next) => {
console.log(`--> ${c.req.method} ${c.req.path}`)
await next()
console.log(`<-- ${c.req.method} ${c.req.path} ${c.res.status}`)
}
// Order matters: timing wraps everything, then requestId, then logger
app.use('*', timing)
app.use('*', requestId)
app.use('*', logger)The order of app.use() calls defines the execution order. In the example above, timing is the outermost layer, meaning it measures the total time including requestId assignment and logger execution. If you reversed the order, timing would only measure the route handler, missing the middleware overhead entirely.
In Dispatch, we are meticulous about middleware ordering. Our standard chain follows this sequence: timing, request ID, CORS, authentication, rate limiting, request validation, and finally the route handler. Each layer depends on data set by previous layers -- rate limiting needs the authenticated user identity, logging needs the request ID, and so on.
Middleware Factories and Configuration
Static middleware functions are useful for simple cross-cutting concerns, but a production gateway needs middleware that adapts to its context. Middleware factories -- functions that return middleware functions -- are the pattern that makes this possible.
import type { MiddlewareHandler } from 'hono'
// Factory: creates a rate limiter configured for a specific tier
function rateLimiter(config: {
windowMs: number
maxRequests: number
keyExtractor: (c: Context) => string
}): MiddlewareHandler {
return async (c, next) => {
const key = config.keyExtractor(c)
const windowKey = `${key}:${Math.floor(Date.now() / config.windowMs)}`
const current = await getRequestCount(windowKey)
if (current >= config.maxRequests) {
return c.json(
{
error: {
code: 429,
message: 'Rate limit exceeded',
retryAfter: Math.ceil(config.windowMs / 1000),
},
},
429
)
}
await incrementRequestCount(windowKey)
c.header('X-RateLimit-Limit', String(config.maxRequests))
c.header('X-RateLimit-Remaining', String(config.maxRequests - current - 1))
await next()
}
}
// Usage: different rate limits for different service tiers
app.use(
'/api/v1/public/*',
rateLimiter({
windowMs: 60_000,
maxRequests: 100,
keyExtractor: (c) => c.get('clientIp') || 'unknown',
})
)
app.use(
'/api/v1/premium/*',
rateLimiter({
windowMs: 60_000,
maxRequests: 10_000,
keyExtractor: (c) => c.get('authenticatedUser')?.id || 'unknown',
})
)This factory pattern is the foundation of Dispatch's configurability. Every middleware in our gateway is a factory that accepts a configuration object. When a new service is onboarded to Dispatch, its middleware stack is assembled from these factories based on a declarative configuration file:
interface MiddlewareConfig {
name: string
options: Record<string, unknown>
}
interface RouteConfig {
path: string
upstream: string
middleware: MiddlewareConfig[]
}
const routeConfig: RouteConfig = {
path: '/api/v1/payments/*',
upstream: 'https://payments-service.internal',
middleware: [
{ name: 'auth', options: { required: true, scopes: ['payments:read'] } },
{ name: 'rateLimit', options: { windowMs: 60000, maxRequests: 500 } },
{ name: 'validateBody', options: { schema: 'PaymentRequest' } },
{ name: 'timeout', options: { ms: 5000 } },
],
}Conditional Middleware Execution
Not every request should pass through every middleware. Public health check endpoints should skip authentication. Internal service-to-service calls may bypass rate limiting. Hono supports conditional middleware through path-based registration, but for more granular control, Dispatch uses a conditional wrapper pattern:
import type { MiddlewareHandler } from 'hono'
function when(
predicate: (c: Context) => boolean,
middleware: MiddlewareHandler
): MiddlewareHandler {
return async (c, next) => {
if (predicate(c)) {
await middleware(c, next)
} else {
await next()
}
}
}
// Skip auth for health checks and public endpoints
app.use(
'*',
when(
(c) => !c.req.path.startsWith('/health') && !c.req.path.startsWith('/public'),
authMiddleware({ secret: process.env.AUTH_SECRET })
)
)
// Only validate request bodies for write operations
app.use(
'/api/*',
when(
(c) => ['POST', 'PUT', 'PATCH'].includes(c.req.method),
bodyValidator()
)
)We also use a unless variant for exclusion-based logic, and a compose function that bundles multiple middleware into a single unit:
function compose(...middlewares: MiddlewareHandler[]): MiddlewareHandler {
return async (c, next) => {
let index = -1
async function dispatch(i: number): Promise<void> {
if (i <= index) {
throw new Error('next() called multiple times')
}
index = i
if (i < middlewares.length) {
await middlewares[i](c, () => dispatch(i + 1))
} else {
await next()
}
}
await dispatch(0)
}
}
// Bundle related middleware into a reusable unit
const securityMiddleware = compose(
corsMiddleware({ origin: 'https://app.klivvr.com' }),
helmetMiddleware(),
csrfProtection({ origin: 'https://app.klivvr.com' })
)
const observabilityMiddleware = compose(
requestIdMiddleware(),
timingMiddleware(),
loggingMiddleware({ level: 'info' })
)
app.use('*', observabilityMiddleware)
app.use('/api/*', securityMiddleware)The compose function reduces visual noise in your route definitions and makes it easy to test middleware bundles in isolation.
Error Handling in Middleware Chains
Errors in middleware require careful handling. An exception thrown in one middleware should not crash the entire gateway, and it should produce a meaningful error response. Hono propagates errors thrown during next() back up the middleware chain, allowing outer middleware to catch and handle them.
Dispatch uses an error boundary middleware pattern at the outermost layer:
const errorBoundary: MiddlewareHandler = async (c, next) => {
try {
await next()
} catch (err) {
const requestId = c.get('requestId') || 'unknown'
if (err instanceof HTTPException) {
// Known HTTP errors: pass through with standard envelope
return c.json(
{
error: {
code: err.status,
message: err.message,
requestId,
},
},
err.status
)
}
if (err instanceof TimeoutError) {
return c.json(
{
error: {
code: 504,
message: 'Upstream service timed out',
requestId,
},
},
504
)
}
// Unknown errors: log and return 500
console.error(`[${requestId}] Unhandled middleware error:`, err)
return c.json(
{
error: {
code: 500,
message: 'Internal gateway error',
requestId,
},
},
500
)
}
}
// Must be the FIRST middleware registered
app.use('*', errorBoundary)A common pitfall is forgetting that await next() can throw. If your middleware does work after calling next() -- like recording response metrics -- that code will be skipped if a downstream middleware throws and you do not have a try/catch. In Dispatch, every middleware that performs post-response work wraps its await next() in a try/finally block:
const metricsMiddleware: MiddlewareHandler = async (c, next) => {
const start = performance.now()
let status = 500 // Default to 500 in case of unhandled error
try {
await next()
status = c.res.status
} finally {
const duration = performance.now() - start
recordMetric('gateway.request.duration', duration, {
path: c.req.routePath,
method: c.req.method,
status: String(status),
})
}
}Middleware Testing Strategies
Testing middleware in isolation is critical for a gateway, where a subtle middleware bug can affect every request. Hono makes this straightforward because each middleware is a pure function that operates on a context object.
We test Dispatch middleware using Hono's built-in test client:
import { Hono } from 'hono'
import { testClient } from 'hono/testing'
import { describe, it, expect } from 'vitest'
describe('rateLimiter middleware', () => {
it('allows requests under the limit', async () => {
const app = new Hono()
app.use(
'*',
rateLimiter({ windowMs: 60000, maxRequests: 2, keyExtractor: () => 'test' })
)
app.get('/test', (c) => c.json({ ok: true }))
const client = testClient(app)
const res1 = await client.test.$get()
expect(res1.status).toBe(200)
const res2 = await client.test.$get()
expect(res2.status).toBe(200)
const res3 = await client.test.$get()
expect(res3.status).toBe(429)
})
it('isolates rate limits by key', async () => {
const app = new Hono()
app.use(
'*',
rateLimiter({
windowMs: 60000,
maxRequests: 1,
keyExtractor: (c) => c.req.header('X-API-Key') || 'default',
})
)
app.get('/test', (c) => c.json({ ok: true }))
const res1 = await app.request('/test', {
headers: { 'X-API-Key': 'key-a' },
})
expect(res1.status).toBe(200)
const res2 = await app.request('/test', {
headers: { 'X-API-Key': 'key-b' },
})
expect(res2.status).toBe(200) // Different key, separate limit
})
})For integration testing of the full middleware chain, we construct a complete gateway instance with all middleware layers and run requests through it. This catches ordering bugs and interaction effects that unit tests miss.
Conclusion
Middleware composition is where an API gateway's architecture is defined. Hono's onion model, combined with TypeScript's type system, provides a foundation that is both expressive and safe. The patterns described here -- factories for configurability, conditional wrappers for flexibility, error boundaries for resilience, and compose for organization -- are the building blocks that make Dispatch reliable under production load. The key insight is that middleware should be small, focused, and composable. When each piece does one thing well, the overall chain becomes easier to reason about, test, and evolve as your gateway's requirements grow.
Related Articles
API Monitoring and Alerting Best Practices
A comprehensive guide to monitoring API gateways in production, covering the four golden signals, structured logging, distributed tracing, and actionable alerting strategies.
Edge Computing for Fintech: Latency and Compliance Benefits
How edge computing addresses the unique challenges of fintech platforms, including latency-sensitive transactions, data residency requirements, and regulatory compliance across jurisdictions.
API Performance Optimization: From 200ms to 20ms
A practical guide to optimizing API gateway performance, covering the specific techniques that took Dispatch's p95 latency from 200ms to under 20ms.