Single-Purpose Lambdas to a Hono Lambdalith, Route by Route
Move single-purpose Lambdas behind API Gateway into a Hono Lambdalith one route at a time, with a per-route rule for auth scopes, validation, throttling, and metrics.
Folding a set of single-purpose Lambda functions into one Hono Lambdalith behind an API Gateway you already run is a routing problem before it is a code problem. Authorizer scopes, request validation, throttling, and per-route metrics are configured on the gateway route. Reserved concurrency, the IAM role, and per-function metrics are configured on each function. One deploy that deletes fifteen routes and adds a {proxy+} catch-all changes every one of those settings at once and leaves no per-route way back. An explicit route always wins over the catch-all on both HTTP APIs and REST APIs. The safe sequence follows from that rule: land the Hono function on the catch-all route first, retarget each explicit route’s integration to it, and delete a route only when nothing on it still needs the gateway. In CDK for TypeScript teams, the sequence has six parts: the inventory that drives it, the catch-all and retarget code for HTTP API, the differences on REST, the gateway features you rebuild in Hono, the parity test that gates each cutover, and the routes that stay single-purpose.
Whether to merge at all is a separate decision, settled in AWS Lambda: Single-Purpose Functions vs Lambdalith: single-purpose is the default, and a single-domain Lambdalith is an exception earned by meeting five criteria. Everything below assumes that decision is made for one bounded context, here an orders API. The end state it works toward is a hybrid.
Route Inventory Before the First Deploy#
Every later step is a decision about one route. The first artifact is therefore a table: one row per route, one column per property that could be lost in the move. The columns are: authorizer and authorization scopes, request validator or model, throttle overrides, whether a dashboard or alarm keys on that route’s gateway metrics, reserved or provisioned concurrency, IAM statements no sibling route needs, and the payload format version the integration uses today.
The columns follow from where API Gateway and Lambda store each property. On an HTTP API, JWT authorizers and scopes attach to the route; AWS states that “If you configure scopes for a route, the token must include at least one of the route’s scopes.” Route-level throttling lives on the stage, keyed by route key, and per-route gateway metrics exist only when detailed metrics are enabled on the stage. A REST API adds request validators, usage plans with per-method throttles, API keys, and caching. All of those are configured per method or per stage and method, and none of them exist on HTTP APIs. By contrast, reserved and provisioned concurrency, the execution role, and Lambda’s own per-function metrics are properties of the function. They stop describing a route the moment its traffic flows through a different function.
The AWS CLI pulls most of the table. For an HTTP API:
API_ID=abc123
aws apigatewayv2 get-routes --api-id "$API_ID" \
--query 'Items[].[RouteKey,AuthorizationType,AuthorizationScopes,Target]' --output table
aws apigatewayv2 get-stage --api-id "$API_ID" --stage-name '$default' \
--query '{detailed:DefaultRouteSettings.DetailedMetricsEnabled,routes:RouteSettings}'
For a REST API:
API_ID=abc123
aws apigateway get-resources --rest-api-id "$API_ID" --embed methods \
--query 'items[].{path:path,methods:resourceMethods}'
aws apigateway get-stage --rest-api-id "$API_ID" --stage-name prod --query methodSettings
aws apigateway get-usage-plans --query 'items[].apiStages'
And per function:
aws lambda get-function-concurrency --function-name get-order
aws lambda get-provisioned-concurrency-config --function-name get-order --qualifier live
The matrix below is the rule every later step applies. Retargeted means the explicit route stays and points at the Lambdalith. Deleted means the route is gone and the catch-all serves the path.
| Property | Lives on | Kept after retarget | Kept after delete |
|---|---|---|---|
| JWT authorizer and scopes (HTTP API) | route | yes | only if the catch-all’s authorizer and scopes match, or the check is rebuilt in Hono |
| Request validator and model (REST) | method | yes | no, rebuild with a Hono validator |
| Route throttle (HTTP API) or usage-plan method throttle (REST) | stage route settings or usage plan | yes | no Hono equivalent |
| Per-route gateway metrics | stage and route | yes | expected to collapse into the catch-all’s series |
| Reserved or provisioned concurrency | function | no | no |
| IAM role scope | function | no, the role becomes a union | no |
| Per-function Lambda metrics | function | no, rebuild as EMF dimensions | no, same |
| Function-level scaling rate | function | shared | shared |
Function count itself is often the pressure that started the migration; Breaking Through CloudFormation’s 500 Resource Barrier covers that side. The inventory tells you which rows can reach the deleted column, which stop at retargeted, and which never move.
One Hono App for the Bounded Context#
Hono is a router built on Web Standards. A handler receives a context c, reads the request through c.req, and returns a standard Response. app.route(prefix, subApp) mounts a sub-app under a path prefix, app.use(path, middleware) runs middleware before the matching handler, and c.env carries whatever the runtime adapter binds. On Lambda, that is the raw event and the Lambda context. The adapter lives in @hono/aws-lambda: Hono v4.13.10 moved the runtime adapters into separate packages, and the old hono/aws-lambda subpath still works in v4 but is deprecated ahead of v5. That is why older tutorials, including earlier posts on this site, import from a different path.
The move that keeps parity checkable is to extract each legacy handler’s core into a plain function before any Hono code exists. The legacy handler and the Hono route then call the same function, so parity holds by construction and the diff for each route stays small. Convert callback-style handlers to async at the same time: the Node.js 24 runtime drops the callback handler signature along with context.done, context.succeed, and context.fail. The conversion is due whether or not the route moves.
// src/legacy/get-order.ts (after extraction, before the route moves)
import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2, Context } from 'aws-lambda'
import { getOrderById } from '../orders/core/get-order-by-id'
const json = { 'content-type': 'application/json' }
export const handler = async (
event: APIGatewayProxyEventV2,
_context: Context,
): Promise<APIGatewayProxyStructuredResultV2> => {
const order = await getOrderById(event.pathParameters?.id ?? '')
return order
? { statusCode: 200, headers: json, body: JSON.stringify(order) }
: { statusCode: 404, headers: json, body: JSON.stringify({ message: 'Not Found' }) }
}
The Hono side is one Env type for the bindings, one sub-app per former function, one root app, and one handler file.
// src/orders/env.ts
import type { LambdaEvent } from '@hono/aws-lambda'
import type { Context } from 'aws-lambda'
// The binding uses Lambda's own Context type because Powertools' Logger expects it.
// Hono's LambdaContext omits the deprecated done/fail/succeed members that type still carries.
export type Env = {
Bindings: { event: LambdaEvent; lambdaContext: Context }
}
// src/orders/routes/get-order.ts
import { Hono } from 'hono'
import { getOrderById } from '../core/get-order-by-id'
import type { Env } from '../env'
export const getOrder = new Hono<Env>().get('/:id', async (c) => {
const order = await getOrderById(c.req.param('id'))
return order ? c.json(order) : c.json({ message: 'Not Found' }, 404)
})
// src/orders/app.ts
import { Hono } from 'hono'
import type { Env } from './env'
import { observe } from './observe'
import { createOrder } from './routes/create-order'
import { getOrder } from './routes/get-order'
export const app = new Hono<Env>()
app.use('*', observe)
app.route('/orders', getOrder)
app.route('/orders', createOrder)
app.notFound((c) => c.json({ message: 'Not Found' }, 404))
// src/orders/handler.ts
import { handle } from '@hono/aws-lambda'
import { app } from './app'
export const handler = handle(app)
handle inspects the event shape and accepts API Gateway REST (payload format 1.0), HTTP API and Function URL (payload format 2.0), ALB, and VPC Lattice events. For 1.0 it builds the request URL from event.path; for 2.0 it uses event.rawPath. Neither path is modified, which matters for stage prefixes later. Set-Cookie headers go out as multiValueHeaders on 1.0 and as the cookies array on 2.0. Response bodies are base64-encoded with isBase64Encoded: true unless the content type is one of five text/* types (plain, html, css, javascript, csv) or a JSON or XML type, so text/markdown and text/event-stream are encoded too; handle(app, { isContentTypeBinary }) overrides that check. Shared clients and configuration belong at module scope so one cold start pays for every route, the pattern in Code Architecture by Init Amortization. The handler habits in AWS Lambda Cold Start Optimization in TypeScript matter more once one bundle carries every route.
The Catch-All Route on HTTP API#
API Gateway picks the most specific route: a full match on method and path first, then a match with a greedy {proxy+} variable, then $default. AWS puts the last step directly: “Routes with greedy path variables have higher priority than the $default route.” Therefore a $default route receives only requests that matched nothing else. Before it exists, those requests get the gateway’s own {"message":"Not Found"}; afterwards they reach Hono’s notFound handler. Landing the catch-all is the first deploy, and it moves no existing traffic.
// lib/orders-stack.ts (additions inside the existing stack)
import { Duration } from 'aws-cdk-lib'
import { HttpRoute, HttpRouteKey } from 'aws-cdk-lib/aws-apigatewayv2'
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'
import { Runtime } from 'aws-cdk-lib/aws-lambda'
import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs'
// `api` (HttpApi) and `jwt` (HttpJwtAuthorizer) already exist in this stack.
const ordersLith = new NodejsFunction(this, 'OrdersLith', {
entry: 'src/orders/handler.ts',
runtime: Runtime.NODEJS_24_X,
timeout: Duration.seconds(10),
bundling: { format: OutputFormat.ESM, minify: true, sourceMap: true },
environment: {
NODE_OPTIONS: '--enable-source-maps',
POWERTOOLS_SERVICE_NAME: 'orders',
POWERTOOLS_METRICS_NAMESPACE: 'orders',
},
})
// One integration object, reused by every route that targets the Lambdalith.
// scopePermissionToRoute: false keeps a single invoke permission on the function.
const lith = new HttpLambdaIntegration('OrdersLith', ordersLith, {
scopePermissionToRoute: false,
})
// The catch-all. Every explicit route still wins over it.
new HttpRoute(this, 'OrdersDefaultRoute', {
httpApi: api,
routeKey: HttpRouteKey.DEFAULT,
integration: lith,
authorizer: jwt,
})
HttpLambdaIntegration defaults to payload format 2.0 and a 29-second integration timeout. With scopePermissionToRoute at its default, CDK adds one invoke permission per route; the CDK docs say of false: “This is useful for reducing the AWS Lambda policy size for cases where the same AWS Lambda function is reused for many integrations.” HttpApi also offers a defaultIntegration prop that creates the $default route, but on an existing API an explicit HttpRoute is the narrower tool. Attaching the authorizer there keeps it on one route, whereas a defaultAuthorizer set at the API level applies to every route added without its own authorizer. Keep the $default stage CDK creates by default, because with a named stage, expect rawPath to start with the stage name. Prefer $default to ANY /{proxy+} as the catch-all. It covers every method and the root path, and it sits below greedy routes in precedence, so a {proxy+} route you add later for CORS preflight still wins over it. For the bundler settings on NodejsFunction, see CDK TypeScript Lambda: Choosing a Bundler; for where the construct lives in the CDK app, see AWS CDK Project Structure.
The trade-off of the catch-all is small but not zero. The first deploy carries no risk to existing routes, but authenticated traffic that used to stop at the gateway (typos, retired clients, calls to removed routes) now invokes Lambda and bills as invocations. Unauthenticated requests still stop at the JWT authorizer with a 401. The observe middleware below labels that traffic as /* so it stays visible as cost, separate from demand. The single unscoped invoke permission is the intended Lambdalith behavior, and it is also a wider grant than per-route permissions: any route on that API can invoke the function.
Retarget First, Delete Later#
Each route passes through three states. In the single-purpose state it has an explicit route and its own function. In the retargeted state it keeps the explicit route, its authorizer, scopes, validator, and throttle, and only the integration now points at the Lambdalith; the gateway still enforces everything that lives on the route. In the deleted state the explicit route is gone, the catch-all serves the path, and only the per-route features you rebuilt in Hono remain.
import { HttpMethod } from 'aws-cdk-lib/aws-apigatewayv2'
// Retarget: the same addRoutes call, with the integration swapped.
// Authorizer, scopes, and any route-level throttle stay exactly where they were.
api.addRoutes({
path: '/orders/{id}',
methods: [HttpMethod.GET],
integration: lith, // was: getOrderIntegration
authorizer: jwt,
authorizationScopes: ['orders/read'],
})
CDK derives the route’s construct id from the method and path, so swapping integration is an update to the existing AWS::ApiGatewayV2::Route. Run cdk diff before the first retarget and confirm it shows an update to the route’s target and no resource replacement; a replacement would mean a short window with no route at all. The update holds only while the construct id is unchanged: a route first created with new HttpRoute(this, 'SomeId', ...) has a different id than one added through addRoutes, so retarget it through the same construct rather than re-declaring it.
After the retarget, the legacy function receives no traffic, but it stays in the stack. Reverting is the one-line change back to the old integration, and that only works while the old function exists. Leave it through a soak period, then remove it in a later deploy. Moving the traffic and deleting the code are separate changes, and each is reversible on its own.
Deleting the route is the removal of the addRoutes call. Do it only when every column in that route’s inventory row is empty or rebuilt. Some rows never qualify: a REST usage-plan throttle, or a scope that differs from what the catch-all’s authorizer checks. Those routes stay retargeted permanently, and that is a fine outcome; the function count still drops and the explicit route costs nothing extra. Track the count of routes in each state through the migration. A long-lived mix of states is the main way this approach fails, and an end state agreed per route before the first retarget prevents it.
The cost of this sequence is deploy count: one per route, plus the cleanup deploys that remove legacy functions, plus the inventory and the recorded fixtures as upkeep. What that buys is a change scope of one route per deploy and a rollback that never touches more than one integration.
The Same Sequence on a REST API#
The sequence is the same on a REST API; five details differ. The catch-all is a proxy resource on the root, created with RestApi.root.addProxy(). Do not reach for LambdaRestApi with proxy: true: its constructor replaces addResource, addMethod, and addProxy on the root with functions that throw and tell you to set proxy to false. A strangler needs explicit resources and the proxy side by side.
import { LambdaIntegration } from 'aws-cdk-lib/aws-apigateway'
// `api` (RestApi) already has explicit resources for each single-purpose function,
// for example: const orders = api.root.addResource('orders')
// `cognitoAuthorizer`, `bodyValidator`, and `newOrderModel` also exist in the stack.
const lithRest = new LambdaIntegration(ordersLith, { scopePermissionToMethod: false })
api.root.addProxy({ defaultIntegration: lithRest, anyMethod: true })
// Retarget one method: the same addMethod call, with the integration swapped.
// The request validator and authorizer stay on the method.
orders.addMethod('POST', lithRest, { // was: createOrderIntegration
authorizer: cognitoAuthorizer,
requestValidator: bodyValidator,
requestModels: { 'application/json': newOrderModel },
})
Second, /{proxy+} does not cover the parent path. CDK’s ProxyResource adds each method to the root as well when the proxy is mounted at /, unless the root already has that method, so that an empty path is proxied too. AWS documents the failure this avoids: a request to the root with no root method returns 403 Forbidden with Missing Authentication Token. Test GET / explicitly after the catch-all lands.
Third, REST matches the resource before the method, and it excludes explicit siblings from the proxy. AWS states that “a method request against a specific resource takes precedence over a method request against a generic resource at the same level of the resource hierarchy.” The consequence for the strangler: a resource that keeps any method is expected to shadow the root proxy for every method on its path. If /orders keeps GET and you delete POST /orders, expect POST /orders to return 403 Missing Authentication Token without ever reaching Hono. So on REST, retarget each method on a resource to lithRest, and delete the resource only when all of its methods have moved. This is what makes retarget-then-delete mandatory on REST, where on HTTP API it is merely the safer order.
Fourth, the path in the event. On the default execute-api URL, event.path carries no stage name. When a custom domain uses a base path mapping, expect the mapping prefix (for example /v1/orders) to appear in event.path; AWS’s HTTP API documentation points at format 1.0 and path as the way to see an API mapping value. Mount the app with app.basePath('/v1') in that case, and verify the prefix with one logged event before relying on it.
Fifth, binary responses. Hono base64-encodes non-text bodies, and a REST API only decodes them for clients when binaryMediaTypes is configured on the RestApi, for example with */*. Set it and test one binary route. Request validators can move into Hono, as the next section shows. Usage-plan method throttles and API keys cannot, so routes that depend on them stay retargeted.
Gateway Features Rebuilt in Hono#
Authorization scopes are the first thing to rebuild. With the JWT authorizer on $default, token validation still happens at the gateway and the claims arrive in the event. Only the per-route scope check moves into code.
// src/orders/require-scope.ts
import type { APIGatewayProxyEventV2WithJWTAuthorizer } from 'aws-lambda'
import { createMiddleware } from 'hono/factory'
import type { Env } from './env'
// The cast names the JWT-authorized event shape, which Hono's LambdaEvent union does not narrow to.
export const requireScope = (scope: string) =>
createMiddleware<Env>(async (c, next) => {
const { jwt } = (c.env.event as unknown as APIGatewayProxyEventV2WithJWTAuthorizer).requestContext.authorizer
// Read the token's claim, not jwt.scopes: the gateway fills jwt.scopes only on routes with
// authorizationScopes, and the `$default` route has none, so it arrives as null there.
const raw = jwt.claims.scope ?? jwt.claims.scp ?? ''
const granted = Array.isArray(raw) ? raw : String(raw).split(' ')
if (!granted.includes(scope)) {
return c.json({ message: 'Forbidden' }, 403)
}
await next()
})
Add requireScope('orders/read') in front of every handler whose route had scopes. While the route is retargeted the check is redundant with the gateway’s; once the route is deleted it is the only check. The middleware reads the token’s scope or scp claim instead of requestContext.authorizer.jwt.scopes. The gateway fills that array only on routes that configure authorizationScopes, and the $default route configures none, so it arrives as null there. A check built on jwt.scopes passes while the route is retargeted and returns 403 to every caller the moment the explicit route is deleted. Confirm the claim name in one logged $default event, and record at least one parity fixture from a request that arrived through $default rather than through an explicit route.
Request validation replaces a REST validator and model with a Hono validator. @hono/zod-validator validates json, query, param, header, form, and cookie targets, and c.req.valid('json') returns the typed body. The same shape works for an HTTP API, which never had request validation to lose. For the in-handler validation patterns this replaces, see Middy and Zod: Type-Safe AWS Lambda Middleware Validation.
// src/orders/routes/create-order.ts
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import { z } from 'zod'
import { createOrder as createOrderCore } from '../core/create-order'
import type { Env } from '../env'
import { requireScope } from '../require-scope'
// Replaces the REST request validator and model for this method.
const newOrder = z.object({
sku: z.string().min(1),
quantity: z.number().int().positive(),
})
export const createOrder = new Hono<Env>().post(
'/',
requireScope('orders/write'),
zValidator('json', newOrder),
async (c) => {
const order = await createOrderCore(c.req.valid('json'))
return c.json(order, 201)
},
)
Throttling has no Hono equivalent that protects the function, because by the time Hono runs, the function has already been invoked and billed. A route whose protection was a route-level throttle or a usage-plan method throttle keeps its explicit route.
Per-route observability is the last rebuild, and it goes in on day one, before the first cutover. The gateway’s per-route metrics collapse into the catch-all’s series for deleted routes, and Lambda’s per-function metrics describe the Lambdalith as a whole. One middleware restores the per-route view by emitting the matched route template as an EMF dimension and a structured-log field.
// src/orders/observe.ts
import { Logger } from '@aws-lambda-powertools/logger'
import { Metrics, MetricUnit } from '@aws-lambda-powertools/metrics'
import { createMiddleware } from 'hono/factory'
import { routePath } from 'hono/route'
import type { Env } from './env'
const logger = new Logger()
const metrics = new Metrics()
export const observe = createMiddleware<Env>(async (c, next) => {
logger.addContext(c.env.lambdaContext)
const start = performance.now()
await next()
// routePath(c, -1) is the last matched pattern, such as "/orders/:id", never the raw path.
// A request that fell through to notFound matched only this middleware and reports "/*",
// which keeps catch-all-only traffic visible as its own series.
const route = `${c.req.method} ${routePath(c, -1)}`
metrics.addDimension('route', route)
metrics.addMetric('requests', MetricUnit.Count, 1)
metrics.addMetric('faults', MetricUnit.Count, c.res.status >= 500 ? 1 : 0)
metrics.addMetric('latency', MetricUnit.Milliseconds, performance.now() - start)
logger.info('request', { route, status: c.res.status })
metrics.publishStoredMetrics()
})
routePath() from hono/route replaces c.req.routePath, which Hono deprecated in v4.8.0. Powertools Metrics flushes with publishStoredMetrics() without any Middy wrapper, and it caps a metric at 29 dimensions, which is one more reason the dimension must be the low-cardinality route template and never the raw path. For how per-route views are assembled from access logs when custom metrics are not an option, see Observability, and Where Service Meshes Are Heading.
Rebuilding observability has its own price: ownership. Per-route latency percentiles and fault rates come back, but custom metrics are billed per unique metric and dimension combination, and the middleware is code you keep correct. A typo in the dimension name splits a series, and an exception inside the middleware itself, before publishStoredMetrics(), drops the request from the count. Handler errors do not: Hono’s error handler turns them into a 500 response, and the middleware counts them as faults.
Parity Tests Without a Deployment#
Two test layers run in CI with nothing deployed. The first is Hono’s own app.request(path, init, bindings), which runs the app in-process against a standard Request and returns a Response; because observe reads c.env.lambdaContext, pass a stub context as the third argument. The second is the parity test: one recorded API Gateway event, sent to both the legacy handler and handle(app), with status and body compared. The parity test is the gate for each retarget.
// test/get-order.parity.test.ts
import { readFileSync } from 'node:fs'
import { handle, type LambdaEvent } from '@hono/aws-lambda'
import type { APIGatewayProxyEventV2, Context } from 'aws-lambda'
import { describe, expect, it } from 'vitest'
import { handler as legacyGetOrder } from '../src/legacy/get-order'
import { app } from '../src/orders/app'
// Recorded from the legacy function and redacted. Hand-written fixtures miss the
// stage, cookie, and base64 details that a live gateway adds.
const event = JSON.parse(
readFileSync(new URL('./fixtures/get-order.v2.json', import.meta.url), 'utf8'),
) as APIGatewayProxyEventV2
const context = { awsRequestId: 'parity', functionName: 'get-order' } as Context
const lith = handle(app)
describe('GET /orders/{id} parity', () => {
it('returns what the single-purpose handler returned', async () => {
const before = await legacyGetOrder(event, context)
// Same JSON on both sides. The cast bridges aws-lambda's types and Hono's own event interfaces.
const after = await lith(event as unknown as LambdaEvent, context)
expect(after.statusCode).toBe(before.statusCode)
expect(JSON.parse(after.body)).toEqual(JSON.parse(before.body ?? '{}'))
})
})
Both sides call the same getOrderById, so stub it once with vi.mock or point it at a test table. The parity test checks the HTTP translation layer, which is the only thing that changed. Record the fixtures by logging a redacted copy of the event in the legacy function for a day, one fixture per payload format version in use; stage, cookie, and base64 quirks appear only in recorded events.
Rollback Paths by API Type#
Reverting a retarget is the integration swap in reverse, and it stays a one-line change as long as the legacy function exists. That is the primary rollback on both API types, and it is why function removal trails route deletion.
The canary most Lambda teams reach for does not apply here. A weighted alias splits traffic between two published versions of the same function, so it protects Lambdalith deploys after the migration. It cannot split traffic between the legacy function and the Lambdalith, because those are two functions. On a REST API, stage canary release deployments split a percentage of total stage traffic to a new deployment; if a deployment contains only one retargeted method, the canary in effect covers that route. The CDK Stage L2 does not expose canary settings, so check the CfnStage L1 escape hatch before planning on it. On an HTTP API there is no canary release feature at all, per AWS’s own comparison. The safety net there is the parity test, one route per deploy, and the fast revert.
Routes That Stay Single-Purpose#
Any route that needs a per-function property stays out of the Lambdalith, and the triage below is the decision in one view. Reserved and provisioned concurrency are configured per function; a route that relies on either keeps its function. The scaling rate is also per function: AWS states that each function scales independently, at 1,000 execution environment instances every 10 seconds. A route with a spiky profile would therefore consume the rate the other routes share. An IAM statement no sibling route needs widens the Lambdalith’s role into a union; the granularity post makes that argument, and it applies per route here. Finally, a route that returns payloads near the 6 MB synchronous limit, or that needs response streaming, stays separate: Hono’s streamHandle is documented only for Function URLs with response streaming; API Gateway is outside that scope.
The end state is a hybrid: one Lambdalith on the catch-all, a few explicit routes that still point at it, and a few isolated functions. The cost is two deploy shapes to maintain and explain. The benefit is isolation exactly where a per-function property demands it, and nowhere else.
When Another Adapter Fits Better#
Hono is one of several routers that run inside a Lambda function, and two of the others are AWS-maintained. Hono fits this migration because event translation happens in-process with no extra server, the same app also runs behind a Function URL, an ALB, or outside Lambda entirely, and app.request() lets route tests run without API Gateway event fixtures. Each alternative wins in a case worth naming.
Powertools for AWS Lambda ships an HTTP Event Handler: an AWS-maintained router that resolves REST, HTTP API, ALB, and Function URL events, validates with Standard Schema, supports middleware, and streams responses on REST APIs and Function URLs. Its changelog lists metrics and tracer middleware for the router. It wins when the team already standardizes on Powertools for logging, metrics, and tracing and wants no additional framework; the observe middleware above is then already written for you. Hono wins for the reader who values portability, the larger validator and OpenAPI ecosystem, and the Request-based testing model.
The AWS Lambda Web Adapter runs as a Lambda extension that starts your web server, waits for it to become ready, and forwards each event as an HTTP request, to port 8080 by default; zip deployments add the adapter layer and AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrap. It wins when the bounded context already exists as an Express, Fastify, or Next.js server, or when the same container must also run on ECS or App Runner. It does not fit a team that has handlers and no server: that team would write a router either way, while paying an extension’s initialization on every cold start.
serverless-http is the in-process wrapper for Express, Koa, and similar Node frameworks, and Fastify’s official in-process path is @fastify/aws-lambda, at 6.4.1 or later, since 6.4.0 is deprecated for a critical event-spoofing advisory. Either wins when the team already depends on that framework’s middleware and knows it well. Hono’s case against them is a smaller surface: it has no dependencies, speaks Web Standards Request and Response directly, and needs no second translation layer between the Lambda event and the framework’s own request object.
Mistakes That Deploy Without Errors#
Each of the following passes cdk deploy and fails only under traffic.
- A named HTTP API stage. With a stage called
prod, expectrawPathto begin with/prod, and Hono’s 2.0 processor usesrawPathas is, so every route returns 404. Keep the$defaultstage, or callapp.basePath('/prod')as a last resort. - CORS with an authorizer on
$default. AWS documents that the$defaultroute catches requests for all methods and routes you have not defined, includingOPTIONS, and recommends anOPTIONS /{proxy+}route without authorization for preflight. It also states that when CORS is configured on the API, CORS headers from the integration are ignored. Pick gateway CORS orhono/cors, never both, and add the unauthenticatedOPTIONSroute. - A cached REST Lambda authorizer behind the proxy. A cached policy whose
Resourcenames the first request’s method ARN denies later requests to other paths for the length of the TTL. Return a policy that covers every method of the API when caching is on, or disable the cache. - Raw paths as metric dimensions.
/orders/8f2c...creates one series per order id and hits the dimension cardinality you pay for. Use the route template fromroutePath(), and labelnotFoundtraffic as its own value. - Payload format drift. Legacy HTTP API integrations may use format 1.0 while
HttpLambdaIntegrationdefaults to 2.0. Hono accepts both, but any code that readsc.env.eventdirectly (claims, cookies,requestContext) sees a different shape. PinpayloadFormatVersionexplicitly and keep one recorded fixture per version. - Resource policy growth. Per-route scoped permissions accumulate on the one function and push its resource-based policy toward the 20 KB quota. Use the unscoped permission options shown above, and check with
aws lambda get-policy --function-name <generated-name> --query Policy --output text | wc -c, where the name is the one CDK generated for theNodejsFunctionunless you setfunctionName. - Callback-style legacy handlers on Node.js 24. They fail at runtime, in Hono or out of it. Convert them to
asyncwhile extracting the core function. - Changing
defaultAuthorizeron an existingHttpApi. It applies to every route added without its own authorizer. Attach the authorizer on the explicit$defaultHttpRouteinstead.
Boundary of the Route-by-Route Default#
The route-by-route sequence holds when one bounded context sits behind one API Gateway you control, the routes share an authorizer, and the team wants a rollback at every step instead of one cutover. Reach for a different tool when the context already exists as a server application (the Web Adapter), or when the team is already Powertools-native and wants the router that comes with it (the Event Handler). A route that needs its own concurrency, IAM scope, or streaming stays single-purpose, and the hybrid is the intended result. Start with the inventory table; none of the later decisions can be made without it.
References#
- AWS Lambda - Hono (opens in new tab) - Official guide to Hono on Lambda:
handle,streamHandlefor Function URL streaming, theeventandlambdaContextbindings, and binary response handling. - Hono v4.13.10 release notes (opens in new tab) - The release that moved runtime adapters into packages such as
@hono/aws-lambdaand deprecated thehono/<adapter>imports ahead of v5. - Route Helper - Hono (opens in new tab) -
routePath()andmatchedRoutes(), which replace the deprecatedc.req.routePathand supply the route template used as a metric dimension. - Create routes for HTTP APIs in API Gateway (opens in new tab) - Route selection order on HTTP APIs: full match, then greedy
{proxy+}, then$default. - Set up a method request in API Gateway (opens in new tab) - REST proxy resources and the rule that a specific sibling resource takes precedence over
{proxy+}at the same level. - Create AWS Lambda proxy integrations for HTTP APIs in API Gateway (opens in new tab) - Payload format 1.0 versus 2.0, including
rawPath, thecookiesfield, and the API-mapping note. - Choose between REST APIs and HTTP APIs (opens in new tab) - Feature matrix showing which per-route features (request validation, usage plans, canary releases, JWT authorizers) each API type supports.
- Control access to HTTP APIs with JWT authorizers (opens in new tab) - Per-route authorizers and scopes, and the JWT claims and scopes passed to the integration.
- Configure CORS for HTTP APIs in API Gateway (opens in new tab) - Why a
$defaultroute with an authorizer catchesOPTIONSpreflight and how anOPTIONS /{proxy+}route fixes it. - HttpLambdaIntegrationProps (AWS CDK) (opens in new tab) - Default payload version 2.0, the 29-second timeout, and
scopePermissionToRoutefor a shared function’s resource policy. - aws-cdk-lib.aws_apigateway module (AWS CDK) (opens in new tab) -
LambdaRestApiproxy behavior,addProxy(),binaryMediaTypes, and method-level throttling on REST stages and usage plans. - HTTP integrations for REST APIs in API Gateway (opens in new tab) - Why a root request returns
403 Missing Authentication Tokenwhen only/{proxy+}has a method. - Lambda quotas (opens in new tab) - The 20 KB resource-based policy quota and the 6 MB synchronous payload limit.
- Understanding Lambda function scaling (opens in new tab) - Reserved and provisioned concurrency per function, and the function-level scaling rate a Lambdalith shares across routes.
- Fix authorization HTTP 403 errors from API Gateway Lambda authorizers (opens in new tab) - Why a cached authorizer policy denies other paths behind a proxy resource.
- Metrics - Powertools for AWS Lambda (TypeScript) (opens in new tab) - EMF metrics with dimensions, manual
publishStoredMetrics(), and the 29-dimension cap. - HTTP Event Handler - Powertools for AWS Lambda (TypeScript) (opens in new tab) - The AWS-maintained router for REST, HTTP API, ALB, and Function URL events, the main alternative to Hono.
- AWS Lambda Web Adapter (opens in new tab) - The extension that runs an unchanged web server inside Lambda, with its port and wrapper configuration.
- Node.js 24 runtime now available in AWS Lambda (opens in new tab) - Removal of the callback-based handler signature and the related
contextmethods.
Related posts
Set up a production-grade link shortener with AWS CDK, DynamoDB, and Lambda: architecture decisions, project layout, and the schema choices that hold up at scale.
aws-cdk · lambda · dynamodb +5
Before building an internal service layer, decide whether you need one: what it costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.
aws · aws-cdk · lambda +4
A private REST API structurally cannot carry gRPC, and every AWS surface that speaks gRPC excludes Lambda targets. What to keep from gRPC, and what to drop.
aws · aws-cdk · lambda +4
The private REST API, the resource policy that switches it on, per-route AWS_IAM grants, the two CDK stacks, and signing the call from a Node 22 Lambda.
aws · aws-cdk · lambda +4
SigV4 proves which service is calling and nothing about which user it is for. How to propagate a verified subject, and what the transport actually encrypts.
aws · aws-cdk · lambda +4