Skip to content
Ayhan Sipahi Ayhan Sipahi

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, a cold-start budget the chain will not fit inside, 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; the rest ships. This is the failure mode conventions do not fix, 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.

1. 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 the chain after the first execution is the other half of that: a handler cannot quietly add middleware at request time, which is what makes the ordering guarantee in the next section worth anything.

2. 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.

3. 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 REPORT line in CloudWatch Logs carries Init Duration for 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 and the per-request configuration and enforcement arguments have to carry the decision by themselves.

Code Comparison

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. The difference is ownership: every line in the second stack is code the team writes, tests, and patches.

Two Patterns That Need State Between Invocations

Both of the following need state that outlives a single invocation, which is where a chain you control earns its complexity.

1. 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. That is enough to stop a single hot container from hammering a failing dependency. It is not a fleet-wide breaker, and it is no substitute for a timeout on the downstream call.

2. 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.

Migration Strategy - From Middy to Custom

A migration that flips every handler at once has no rollback story. Four phases keep the blast radius small:

Phase 1: Hybrid Approach

// 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

Phase 2: Feature Parity

// 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()
  }
}

Phase 3: Specialization

Once every Middy middleware has an equivalent, the specialization starts: dropping hooks nothing calls, inlining the ones that only wrap a single function call, and re-running the four measurements from earlier against the same handler. If the second set of numbers looks like the first, the honest move is to stop here and keep Middy.

Phase 4: Defaults and Documentation

The last phase 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.

When to Choose Custom vs Middy

The decision comes down to which constraint you can actually measure:

Choose Middy When:

  • Team is new to middleware patterns
  • Standard use cases (HTTP APIs, basic validation)
  • Fast development is the priority
  • Bundle size < 1MB is acceptable
  • Cold start < 1s is acceptable
  • Limited development resources for custom solutions

Choose Custom Framework When:

  • Performance is critical (< 500ms cold start required)
  • Complex business rules requiring dynamic behavior
  • Team has middleware expertise
  • Specific compliance/security requirements
  • Large-scale applications (50+ functions)
  • Need for team standardization and enforcement

Hybrid Approach When:

  • Migration phase between solutions
  • Different performance requirements per function
  • Learning custom patterns while maintaining productivity

Trade-offs to Weigh

1. Performance vs Developer Experience

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 the latency is a stated requirement and not worth taking when it is a preference.

2. Adoption Decides the Outcome

A framework the team routes around is worse than the library it replaced, because now there are two conventions in the codebase. Defaults and documentation are part of the technical work, not a follow-up task.

3. Maintenance Overhead is Permanent

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.

4. Incremental Migration Keeps Each Step Reversible

Porting one handler at a time is slower than a big-bang rewrite and leaves every step with a working rollback. That matters more than the total elapsed time.

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')
  })
})

Production Checklist

Before a custom middleware framework carries production traffic:

  • Before-and-after Init Duration recorded 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

The Bottom Line

Middy stays the default. Its overhead is real and small, and for most fleets it disappears next to the first network call a handler makes. Replace it when a measurement forces the issue: configuration that has to resolve per request, a cold-start budget the chain will not fit inside, or a convention problem that survives every review cycle. Below those thresholds a custom engine buys milliseconds and costs a maintenance commitment that outlives whoever wrote it.

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

AWS Lambda Middleware Mastery

From Middy basics to building custom middleware frameworks for production-scale Lambda applications

Progress 2/2 posts completed

All Posts in This Series

Part 2: Building Custom Middleware Frameworks for Production

Related posts