Middy Alternatives: Building a Custom AWS Lambda Middleware Framework
When a Lambda fleet outgrows Middy's static middleware model, how a project-specific engine handles per-request config, and what owning one costs
Middy covers the typical middleware needs of a small Lambda fleet, but the tradeoffs of its generic middleware-chain model become measurable once a service hits about 50 functions sharing a common middleware stack: per-invocation overhead, cold-start cost of the middleware chain, and the coupling that a shared wrapper creates between otherwise unrelated functions. At that scale the question becomes whether to continue layering on top of Middy’s abstractions, replace them with AWS Lambda Powertools, or build a project-specific middleware framework that only pays for the hooks the fleet actually uses.
The default answer is to stay on Middy. It is maintained, documented, and its per-invocation cost disappears next to the first network call a handler makes. Writing your own engine earns its keep only against a constraint you can measure: configuration that has to resolve per request, init time the chain adds to every cold start, or handler conventions that no amount of code review makes stick.
Where Middy’s Model Runs Out#
Per-Request Configuration#
Multi-tenant validation is the clearest case. Each tenant carries its own rules: one needs UK postcode checks, another German VAT numbers, a third a set of rules that exists nowhere else.
Middy resolves middleware options when the handler module loads:
import middy from '@middy/core'
import validator from '@middy/validator'
import { transpileSchema } from '@middy/validator/transpile'
// transpileSchema compiles the schema once, at module load
const schema = transpileSchema(getSchemaForTenant(process.env.TENANT_ID))
export const handler = middy(businessLogic)
.use(validator({ eventSchema: schema })) // one schema for every tenant
Schema selection has to happen per request, but the compiled validator is fixed for the lifetime of the module. The usual workaround is conditional logic back inside the handler, which gives up the separation the middleware was supposed to buy.
The cost lands as a second validation layer maintained alongside the middleware that already owns validation.
Bundle Size and Cold Start#
Every Middy package added to the stack lands in the deployment artifact, and the artifact has to be downloaded and initialized before the first invocation runs. The chain itself also costs init work: each .use() registers hooks that the engine composes when the module loads.
Neither cost is dramatic on its own. They matter when a function is latency-sensitive and rarely warm, because both are paid on every cold start and neither shows up in the warm-path numbers most teams watch. A synchronous API behind API Gateway feels this. An SQS consumer with steady traffic does not.
Inconsistent Chains Across a Team#
Across multiple developers working on different services, middleware usage patterns become inconsistent:
// Developer A's approach
export const handler = middy(businessLogic)
.use(httpJsonBodyParser())
.use(validator())
.use(httpErrorHandler())
// Developer B's approach (order is different!)
export const handler = middy(businessLogic)
.use(httpErrorHandler()) // Error handling first?
.use(httpJsonBodyParser())
.use(validator())
// Developer C's approach
export const handler = middy(businessLogic)
.use(customAuth()) // Team-specific middleware
.use(httpJsonBodyParser())
// No validator at all!
All three compile. All three behave differently on the error path, and nothing in the type system objects; reviews catch some of it, and the rest ships because a convention has no way to fail a build.
Designing a Custom Middleware Framework#
A replacement engine only has to solve those three problems. Everything else Middy does can stay unimplemented until something asks for it.
A Chain Compiled Once#
The engine keeps one context object per invocation and composes the chain on first execution:
interface LightweightContext {
event: any
context: any
response?: any
metadata: Map<string, any> // Memory efficient storage
startTime: number
}
type MiddlewareHandler = (
ctx: LightweightContext,
next: () => Promise<void>
) => Promise<void>
class CustomMiddlewareEngine {
private middlewares: MiddlewareHandler[] = []
private isCompiled = false
private compiledChain?: (ctx: LightweightContext) => Promise<void>
private errorHandler?: (error: unknown, ctx: LightweightContext) => any
use(middleware: MiddlewareHandler): this {
if (this.isCompiled) {
throw new Error('Cannot add middleware after compilation')
}
this.middlewares.push(middleware)
return this
}
onError(handler: (error: unknown, ctx: LightweightContext) => any): this {
this.errorHandler = handler
return this
}
// Pre-compile middleware chain for performance
private compile(): void {
const chain = this.middlewares.reduceRight(
(next, middleware) => (ctx: LightweightContext) =>
middleware(ctx, () => next(ctx)),
() => Promise.resolve()
)
this.compiledChain = chain
this.isCompiled = true
}
async execute(event: any, context: any): Promise<any> {
if (!this.isCompiled) this.compile()
const ctx: LightweightContext = {
event,
context,
metadata: new Map(),
startTime: Date.now()
}
try {
if (!this.compiledChain) {
throw new Error('Middleware chain not compiled')
}
await this.compiledChain(ctx)
return ctx.response
} catch (error) {
if (!this.errorHandler) throw error
return this.errorHandler(error, ctx)
}
}
}
The chain is composed once and reused for every warm invocation, so the reduceRight cost is paid on the first request of a container’s life instead of on all of them. Freezing it after the first execution stops a handler from quietly adding middleware at request time.
Configuration That Resolves Per Request#
For the multi-tenant validation problem, the middleware resolves its own configuration at runtime:
interface DynamicValidationOptions {
getSchema: (ctx: LightweightContext) => Promise<any>
cacheKey?: (ctx: LightweightContext) => string
}
const dynamicValidator = (options: DynamicValidationOptions): MiddlewareHandler => {
const schemaCache = new Map<string, any>()
return async (ctx, next) => {
let schema: any
if (options.cacheKey) {
const key = options.cacheKey(ctx)
schema = schemaCache.get(key)
if (!schema) {
schema = await options.getSchema(ctx)
schemaCache.set(key, schema)
}
} else {
schema = await options.getSchema(ctx)
}
const isValid = validateAgainstSchema(ctx.event, schema)
if (!isValid) {
throw new ValidationError('Invalid request data')
}
await next()
}
}
// Usage with multi-tenant support
const handler = new CustomMiddlewareEngine()
.use(dynamicValidator({
getSchema: async (ctx) => {
const tenantId = ctx.event.pathParameters?.tenantId
return await getTenantSchema(tenantId)
},
cacheKey: (ctx) => `tenant:${ctx.event.pathParameters?.tenantId}`
}))
The schema resolves per request, and the cache keeps that resolution off the hot path after the first call for a given tenant. The cache lives in the execution environment, so it survives warm invocations and disappears with the container. Give it a bound if the tenant count is open-ended; an unbounded Map in a long-lived container is a slow memory leak.
Standards Enforced at Load Time#
The factory is the only place every handler passes through, so it is where enforcement belongs:
interface TeamStandards {
required: string[]
order: string[]
}
const standards: TeamStandards = {
required: ['auth', 'validation', 'errorHandler'],
order: ['auth', 'validation', 'businessLogic', 'errorHandler']
}
// Named middleware, so the factory can check the chain it just built
const registry: Record<string, () => MiddlewareHandler> = {
auth: authMiddleware,
validation: validationMiddleware,
errorHandler: errorHandlerMiddleware
}
const createStandardHandler = (businessLogic: MiddlewareHandler) => {
const missing = standards.required.filter((name) => !standards.order.includes(name))
if (missing.length > 0) {
throw new Error(`Required middleware missing: ${missing.join(', ')}`)
}
const engine = new CustomMiddlewareEngine()
for (const name of standards.order) {
engine.use(name === 'businessLogic' ? businessLogic : registry[name]())
}
return engine
}
The check runs when the handler module loads, so a chain missing auth fails on deploy instead of on the first request that needed it. A handler that bypasses the factory entirely is still possible, but it is now a single grep away rather than a matter of noticing an unusual .use() order during review.
What to Measure Before Switching#
A rewrite is only justified by numbers from your own fleet, and those numbers have to exist before any engine code does. Four measurements decide it:
- Init duration. The
REPORTline in CloudWatch Logs carriesInit Durationfor every cold start. Deploy the same handler twice, once with the full Middy stack and once with the stack removed, and the difference is what the chain costs at startup. - Deployed artifact size. Measure the bundle after tree-shaking, not
node_modules. Bundlers drop a large share of what a dependency listing suggests, and the artifact is what Lambda downloads. - Warm invocation overhead. Time the chain and emit the delta as a custom metric. If it is small next to the first DynamoDB or HTTP call in the handler, the chain is not what makes the endpoint slow.
- Distinct hooks in use. Count the Middy middlewares the fleet actually calls. A custom engine is cheap to own when that count is three and expensive when it is twelve.
If the measurements say the chain is a rounding error next to downstream I/O, the performance argument is gone. Per-request configuration and enforcement then have to carry the decision alone.
The Same Chain in Both Engines#
Middy Approach:
export const handler = middy(businessLogic)
.use(httpJsonBodyParser())
.use(httpCors({ origin: 'https://app.example.com' }))
.use(validator({ eventSchema: transpileSchema(schema) }))
.use(httpErrorHandler())
.use(httpSecurityHeaders())
Custom Framework:
const handler = new CustomMiddlewareEngine()
.use(jsonParser())
.use(corsHandler({ origin: 'https://app.example.com' }))
.use(requestValidator(schema))
.use(businessLogicWrapper(businessLogic))
.use(errorHandler())
.use(securityHeaders())
The surfaces look alike, but the difference is ownership: every line in the second stack is code the team writes, tests, and patches.
State That Outlives One Invocation#
A breaker and a response cache both keep state in the execution environment between invocations.
Circuit Breaker#
interface CircuitBreakerOptions {
failureThreshold: number
recoveryTimeout: number
monitor?: (state: 'open' | 'closed' | 'half-open') => void
}
const circuitBreaker = (options: CircuitBreakerOptions): MiddlewareHandler => {
let failures = 0
let lastFailure = 0
let state: 'open' | 'closed' | 'half-open' = 'closed'
return async (ctx, next) => {
const now = Date.now()
// Check if we should attempt recovery
if (state === 'open' && now - lastFailure > options.recoveryTimeout) {
state = 'half-open'
options.monitor?.(state)
}
// Block requests if circuit is open
if (state === 'open') {
throw new Error('Circuit breaker is open - service temporarily unavailable')
}
try {
await next()
// Success - reset failures
if (failures > 0) {
failures = 0
state = 'closed'
options.monitor?.(state)
}
} catch (error) {
failures++
lastFailure = now
if (failures >= options.failureThreshold) {
state = 'open'
options.monitor?.(state)
}
throw error
}
}
}
One caveat that applies to any breaker inside Lambda: the counter lives in the execution environment, so each warm container keeps its own. Twenty concurrent containers means twenty independent breakers, each needing its own failures before it opens, which stops a single hot container from hammering a failing dependency without acting as a fleet-wide breaker or substituting for a timeout on the downstream call.
Response Caching Inside the Chain#
interface CacheOptions {
ttl: number
keyGenerator: (ctx: LightweightContext) => string
shouldCache: (ctx: LightweightContext) => boolean
invalidateOn?: string[]
}
const smartCache = (options: CacheOptions): MiddlewareHandler => {
const cache = new Map<string, { data: any, expires: number }>()
return async (ctx, next) => {
const cacheKey = options.keyGenerator(ctx)
const now = Date.now()
// Check cache hit
if (options.shouldCache(ctx)) {
const cached = cache.get(cacheKey)
if (cached && cached.expires > now) {
ctx.response = cached.data
ctx.metadata.set('cache', 'hit')
return // Skip remaining middleware
}
}
await next()
// Cache the response
if (ctx.response && options.shouldCache(ctx)) {
cache.set(cacheKey, {
data: ctx.response,
expires: now + options.ttl
})
ctx.metadata.set('cache', 'miss')
}
}
}
// Usage with intelligent caching
const handler = new CustomMiddlewareEngine()
.use(smartCache({
ttl: 5 * 60 * 1000, // 5 minutes
keyGenerator: (ctx) => `user:${ctx.event.pathParameters?.userId}`,
shouldCache: (ctx) => ctx.event.httpMethod === 'GET'
}))
.use(businessLogicWrapper(getUserProfile))
Returning before next() skips the rest of the chain. Middy has the same escape hatch, through request.earlyResponse in a before middleware, so short-circuiting is not an argument for a custom engine on its own. What the custom chain buys is that the key generator, the TTL, and the invalidation rules sit in one module you own, instead of being split between a middleware option object and the handler.
Porting Handlers Off Middy#
A migration that flips every handler at once has no rollback story. The order below keeps the blast radius small.
Mixing Custom Middleware Into a Middy Chain#
// Mix custom middleware with existing Middy
export const handler = middy(businessLogic)
.use(customPerformanceMiddleware()) // Our custom
.use(httpJsonBodyParser()) // Middy
.use(customValidation()) // Our custom
.use(httpErrorHandler()) // Middy
Building the Missing Equivalents#
// Build custom equivalents for all Middy middleware
const customJsonParser = (): MiddlewareHandler => {
return async (ctx, next) => {
if (ctx.event.body && typeof ctx.event.body === 'string') {
try {
ctx.event.body = JSON.parse(ctx.event.body)
} catch (error) {
throw new Error('Invalid JSON body')
}
}
await next()
}
}
Trimming and Standardizing#
Once every Middy middleware has an equivalent, the trimming starts: dropping hooks nothing calls and inlining the ones that only wrap a single function call. Re-run the four measurements from earlier against the same handler at this point.
What remains is making the standard chain the path of least resistance: a factory function that produces it, a lint rule or review check that flags handlers built any other way, and a short document explaining which hook runs where.
What Owning the Engine Costs#
A custom chain can shave real time off cold starts, and it costs development time that would otherwise go to the product. That trade is worth taking when latency is a stated requirement.
Maintenance is the part that does not end. Custom code means custom maintenance, including the Node.js upgrade nobody scheduled. Middy’s maintainers absorb that work today; after a rewrite, the team does.
Adoption decides whether any of it pays off. A framework the team routes around is worse than the library it replaced, because now there are two conventions in the codebase, and defaults and documentation are part of that technical work.
Testing the Chain#
Chain order is the part most likely to regress silently, so it deserves an explicit test:
describe('Custom Middleware Framework', () => {
test('should execute middleware chain in order', async () => {
const executionOrder: string[] = []
const middleware1 = async (ctx: any, next: Function) => {
executionOrder.push('before-1')
await next()
executionOrder.push('after-1')
}
const middleware2 = async (ctx: any, next: Function) => {
executionOrder.push('before-2')
await next()
executionOrder.push('after-2')
}
const engine = new CustomMiddlewareEngine()
.use(middleware1)
.use(middleware2)
await engine.execute({}, {})
expect(executionOrder).toEqual([
'before-1', 'before-2', 'after-2', 'after-1'
])
})
test('should handle circuit breaker correctly', async () => {
const failingMiddleware = async () => {
throw new Error('Service unavailable')
}
const engine = new CustomMiddlewareEngine()
.use(circuitBreaker({ failureThreshold: 2, recoveryTimeout: 1000 }))
.use(failingMiddleware)
// First failure
await expect(engine.execute({}, {})).rejects.toThrow('Service unavailable')
// Second failure - should open circuit
await expect(engine.execute({}, {})).rejects.toThrow('Service unavailable')
// Third request - should be blocked by circuit breaker
await expect(engine.execute({}, {})).rejects.toThrow('Circuit breaker is open')
})
})
Checks Before Cutover#
Before a custom middleware framework carries production traffic:
- Before-and-after
Init Durationrecorded for the same handler - Error path covered for every middleware, including the ones that throw before
next() - Chain order asserted in a test that fails on reordering
- Alarms updated for the new metric names
- Rollback tested by pointing the alias back at the Middy version
- Hook order documented where the handlers live
Where That Leaves Middy#
Middy stays the default, and the case for replacing it has to come from a measurement you can point at.
If the numbers do point that way, port one high-traffic handler first, keep the Middy version deployable behind an alias, and compare Init Duration on the same workload before touching the rest of the fleet.
References#
- Middy.js - Official Documentation (opens in new tab) - Complete guide to the Middy middleware engine for AWS Lambda, including lifecycle hooks and official middleware packages
- Middy - Getting Started (opens in new tab) - Step-by-step introduction to wrapping Lambda handlers with Middy
- Middy - Handling Errors (opens in new tab) - How onError lifecycle hooks and http-error-handler middleware manage exceptions
- Middy - Early Interrupt (opens in new tab) - How a middleware stops the rest of the chain and returns a response directly, with a caching example
- Middy - Validator Middleware (opens in new tab) - Why eventSchema takes a compiled ajv validator and where transpileSchema does the compilation
- Lambda execution environment lifecycle - AWS Lambda (opens in new tab) - The init, invoke, and shutdown phases that determine what a cold start actually pays for
- Building Lambda functions with Node.js - AWS Lambda (opens in new tab) - Official AWS documentation covering the Node.js runtime, handler conventions, and deployment
- Powertools for AWS Lambda (TypeScript) (opens in new tab) - AWS-maintained developer toolkit for structured logging, tracing, and validation in Lambda functions
AWS Lambda Middleware Mastery
From Middy basics to building custom middleware frameworks for production-scale Lambda applications
All posts in this series
Related posts
Discover how Middy transforms Lambda development with middleware patterns, moving from repetitive boilerplate to clean, maintainable serverless functions
lambda · middleware · serverless +5
Build maintainable, type-safe Lambda middleware with Middy's builder pattern, Zod validation, feature flags, and secrets management for serverless apps.
lambda · middleware · typescript +7
When a Node.js to Go move on AWS Lambda pays for itself and when it does not: the decision framework, the serverless Go patterns, and the cost math behind the call.
go · nodejs · serverless +5
Match architecture weight to each runtime's init-amortization: lean handlers on single-purpose Lambda, more on a Lambdalith, full OOP/DI only on long-lived runtimes.
architecture · lambda · serverless +3
How to slice AWS Lambda functions: default to single-purpose, treat the single-domain Lambdalith as an earned exception, and the platform forces that decide it.
lambda · serverless · architecture +2