Skip to content

Lambda Web Functions After a Hono Lambdalith: What Changes

Lambda Web Functions run a Hono app as a plain HTTP server. What moves off API Gateway, what stays in the app, and when a Hono Lambdalith is still the better fit.

Ayhan Sipahi Ayhan Sipahi

Lambda Web Functions, announced on 12 October 2026, run a Node.js app that listens on a port as a Lambda resource. Each one gets its own HTTPS endpoint, serves many concurrent requests per execution environment, and scales to zero. A Hono app ports to them with few router changes, but the hosting contract around it does not. The @hono/aws-lambda adapter, every read of c.env.event, the gateway’s per-route authorizers and throttles, and per-function knobs such as provisioned concurrency have no direct equivalent. For a Hono Lambdalith that already runs well behind API Gateway, the recommendation is to keep it there and make the app host-agnostic now. Move a bounded context to a Web Function only when a named trigger applies: long requests, streaming, I/O-heavy handlers, server-side rendering, or multi-Region latency.

The starting point is the orders API from Single-Purpose Lambdas to a Hono Lambdalith, Route by Route. The feature is one day old, and no proof of concept has run against it yet. Every Web Functions fact below comes from AWS documentation published at launch, and the code follows the shape AWS documents.

Web Functions in the Terms of a Lambdalith#

AWS now describes three Lambda form factors: Event Functions (the classic event-driven model), Web Functions, and MicroVMs. A Web Function contains immutable revisions (code plus configuration) and up to ten endpoints. An endpoint owns an HTTPS domain, an auth type, scaling and throttle settings, and revision weights that route traffic to one or two active revisions. Endpoint types are HomeRegion, MultiRegion (one global domain with best-effort latency routing), and PerRegion (one domain per Region).

The constraints that decide whether a Lambdalith can move are narrow and concrete, according to the runtimes (opens in new tab), core concepts (opens in new tab), and HTTP (opens in new tab) pages:

  • Runtime nodejs24.x only, Arm64 only, a fixed 2 vCPUs and 2 GB of memory per environment, a default V8 heap of 1,024 MB, and 512 MB of shared /tmp.
  • The server must listen on the host and port in AWS_LAMBDA_HTTP_ENDPOINT within 10 seconds. The entry file is index.js at the package root unless AWS_LAMBDA_NODEJS_ENTRYPOINT says otherwise; package.json main is not read.
  • maxConcurrencyPerEnvironment ranges from 1 to 128 with a default of 64. It is an upper bound on a limit Lambda manages dynamically, so sustained concurrency per environment is often lower.
  • The request timeout ranges from 3 to 900 seconds, with a default of 30.
  • HTTP/1.1 only, a 128 KiB limit on the request head, no WebSocket upgrades, and a half-duplex request path: the app reads the whole request body before it writes the response.
  • Code deploys from a .zip in a versioned S3 bucket, up to 512 MB unzipped. A new revision does not inherit settings from the previous one.
  • Launch Regions are US East (N. Virginia), US East (Ohio), US West (Oregon), Asia Pacific (Tokyo), and Europe (Ireland), per the launch post (opens in new tab).

Billing follows a different shape from Event Functions: requests, active CPU time, memory over the environment’s lifetime, and data transfer. The pricing page (opens in new tab) has the current rates.

The Same Hono App Behind a Different Entry File#

The developer guide (opens in new tab) says frameworks such as Hono run “with no Lambda-specific adapters or wrappers”. That holds for the router and does not hold for the edges. In the previous post, src/orders/app.ts, the sub-apps, the Zod validators, and the app.request() tests are portable. The handler file, the Env bindings, the scope middleware, and the observability middleware are not.

AWS’s own Hono sample on the samples page (opens in new tab) uses @hono/node-server. The Lambdalith’s handler.ts becomes a server entry that binds the address Lambda provides:

// src/orders/server.ts (bundled to index.js at the package root)
import { serve } from '@hono/node-server'
import { app } from './app'

// Lambda sets AWS_LAMBDA_HTTP_ENDPOINT as "host:port". The fallback is for local runs only.
const [host, port] = (process.env.AWS_LAMBDA_HTTP_ENDPOINT ?? 'localhost:3000').split(':')

serve({ fetch: app.fetch, hostname: host, port: Number(port) })

process.on('SIGTERM', () => {
  // Lambda allows at most 1 second here; flush anything buffered, then exit.
  process.exit(0)
})

The Env type loses its Lambda bindings. { event, lambdaContext } does not exist under @hono/node-server, so anything that read c.env.event or c.env.lambdaContext needs a new source. Request correlation comes from the lambda-web-request-id header that Lambda adds to each request. Values the app produces itself, such as the verified token payload, go into Hono’s context variables.

// src/orders/env.ts
export type Env = {
  Variables: { jwtPayload?: Record<string, unknown> }
}

JWT validation moves into the app when no gateway authorizer sits in front. Hono’s built-in JWK middleware (opens in new tab) verifies the bearer token against the issuer’s key set; its alg option is required and lists the accepted algorithms. The scope check from the previous post keeps its core insight (read the token’s scope or scp claim) and only changes where it reads the claims from.

// src/orders/auth.ts
import { createMiddleware } from 'hono/factory'
import { jwk } from 'hono/jwk'
import type { Env } from './env'

const issuer = process.env.JWT_ISSUER ?? ''

// Verifies signature, exp, nbf, iat, and iss; the payload lands in c.get('jwtPayload').
export const verifyJwt = jwk({
  jwks_uri: `${issuer}/.well-known/jwks.json`,
  alg: ['RS256'],
  verification: { iss: issuer },
})

export const requireScope = (scope: string) =>
  createMiddleware<Env>(async (c, next) => {
    const claims = c.get('jwtPayload') ?? {}
    const raw = claims.scope ?? claims.scp ?? ''
    const granted = Array.isArray(raw) ? raw : String(raw).split(' ')
    if (!granted.includes(scope)) {
      return c.json({ message: 'Forbidden' }, 403)
    }
    await next()
  })

CORS moves fully into the app as well, because Lambda adds no CORS headers. hono/cors becomes the only CORS layer, and the previous post’s pitfall about gateway CORS conflicting with integration CORS disappears.

Per-route metrics stay necessary, and they need a rewrite. The EMF page (opens in new tab) states that EMF objects must go to standard output with process.stdout.write, because Lambda wraps console output in its own JSON log event and CloudWatch does not extract metrics from the wrapped object. Built-in metrics also carry no revision dimension, so a canary needs the version in the app’s own metrics. Building the object inside the request avoids a buffer shared between concurrent requests:

// src/orders/observe.ts
import { createMiddleware } from 'hono/factory'
import { routePath } from 'hono/route'
import type { Env } from './env'

const version = process.env.APP_VERSION ?? 'unknown'

export const observe = createMiddleware<Env>(async (c, next) => {
  const start = performance.now()
  await next()
  const route = `${c.req.method} ${routePath(c, -1)}`
  // One object per request: nothing is shared between requests in flight.
  process.stdout.write(
    JSON.stringify({
      _aws: {
        Timestamp: Date.now(),
        CloudWatchMetrics: [
          {
            Namespace: 'orders',
            Dimensions: [['route', 'version']],
            Metrics: [
              { Name: 'requests', Unit: 'Count' },
              { Name: 'faults', Unit: 'Count' },
              { Name: 'latency', Unit: 'Milliseconds' },
            ],
          },
        ],
      },
      route,
      version,
      requests: 1,
      faults: c.res.status >= 500 ? 1 : 0,
      latency: performance.now() - start,
      status: c.res.status,
      requestId: c.req.header('lambda-web-request-id'),
    }) + '\n',
  )
})

The request ID stays a plain field so it is searchable in Logs Insights without creating a metric per request. The Powertools Logger and Metrics from the previous post are not mentioned in the Web Functions docs. Before reusing them, check two things: whether Metrics writes to stdout directly or through console, and whether their module-level instances behave correctly with concurrent requests in one process.

Static assets can be served from the function with serveStatic, as AWS’s sample does. When CloudFront is the front door, serving them from S3 through CloudFront keeps that traffic off the function.

The Hosting Contract, Row by Row#

The table below maps each concern from the previous post to its Web Function counterpart. The API Gateway column is the HTTP API setup from that post.

ConcernHono Lambdalith behind API GatewayHono on a Web Function
Router, routes, Zod validation, app.request() testsHonoUnchanged
Adapterhandle() from @hono/aws-lambdaserve() from @hono/node-server on AWS_LAMBDA_HTTP_ENDPOINT
Requests per environmentOne at a timeUp to maxConcurrencyPerEnvironment (default 64, max 128); the dynamic limit may be lower
JWT validation and scopesGateway JWT authorizer per routeIn the app, or on a front door you keep
Per-route throttlingRoute settings or usage plansNone per route; endpoint rateLimit and maxEnvironments, per Region
Request validationHono (or REST validators)Hono only
Request timeout29 s default on HttpLambdaIntegration3 to 900 s, default 30 s
CanaryWeighted alias; no canary on HTTP APIRevision weights on the endpoint, two revisions
Warm capacityReserved and provisioned concurrencyNone; always scales from zero
Memory, CPU, architecture128 to 10,240 MB, x86 or ArmFixed 2 GB and 2 vCPU, Arm64 only
RuntimesEvery Lambda runtimenodejs24.x only
Custom domain and WAFAPI Gateway custom domain, WAF on RESTOnly through a front door (CloudFront or API Gateway)
Private ingressREST private APIsNot available; the endpoint is internet-reachable
StreamingFunction URLs with streamHandleBuilt in (SSE, chunked); no WebSocket
Built-in metricsPer function, plus per route on the gatewayPer endpoint for requests, per function for environments; no route or revision dimension
BillingDuration per invocationRequests, active CPU, memory over environment lifetime, data transfer

Most rows move a responsibility from configuration into code. Scope checks, CORS, and validation become middleware you test with app.request(). Throttling becomes an endpoint-wide setting, so a route that relied on its own throttle loses that isolation. The timeout row widens the window from under 30 seconds to up to 15 minutes, which is the main reason long-running routes are candidates. The capacity rows remove options: there is no provisioned floor, no memory dial, and no x86.

The concurrency row matters most. Everything in Code Architecture by Init Amortization about module-scope clients still holds. A database pool or SDK client created once per environment is still the right pattern. Module-scope mutable per-request state is the new hazard. On an Event Function a module-level let currentUser was wrong in style but safe in practice, because one environment served one request. On a Web Function the same variable is shared by every request in flight on that event loop, up to 64 by default. Per-request logger context appended to a singleton, caches mutated across an await, and metric buffers flushed at the end of a request all leak between requests in the same way. Hono’s c.set and c.get give each request its own scope.

CPU-bound work has the opposite problem. A long synchronous loop on an Event Function delayed one request; on a Web Function it delays every request in that environment. Move such work to worker_threads or lower maxConcurrencyPerEnvironment for the endpoint.

Choosing the Front Door#

The networking (opens in new tab) and endpoint access control (opens in new tab) pages describe the options. An endpoint uses one of two auth types: IamAuth (callers sign with SigV4 or SigV4a) or ApplicationManaged (the app decides). Either way, the endpoint domain stays reachable from the internet.

Direct endpoint with ApplicationManaged is the simplest shape. The app handles authentication, CORS, and abuse protection, and the endpoint domain is the public URL. There is no custom domain. It fits internal tools, preview environments, and machine-to-machine calls that carry app-verified tokens.

CloudFront with Origin Access Control in front of an IamAuth endpoint is the production shape AWS documents. The OAC uses origin type lambda and signing behavior always-amz-auth. A resource-based policy grants cloudfront.amazonaws.com the lambda:InvokeWebFunctionEndpoint action, scoped by aws:SourceArn. CloudFront signs each origin request. Lambda strips only Authorization values that are AWS signatures, so the viewer’s own bearer token can still reach the app for the JWK middleware. CloudFront drops Authorization on GET and HEAD unless the cache policy includes it, so add it to the cache policy (opens in new tab) on every behavior that uses this origin. Lambda@Edge origin-request and origin-response triggers are not supported on those behaviors. This shape adds a custom domain, WAF, caching, and HTTP/2 and HTTP/3 at the edge. The trade-off is a second resource to configure, plus one rule: CloudFront must not forward the viewer’s Host header. If it does, Lambda answers 404 Platform.InvalidEndpoint. For CloudFront as a front door in general, see Edge Computing on AWS.

API Gateway kept in front preserves gateway JWT authorizers, route throttles, and usage plans. AWS’s best-practices guidance (opens in new tab) lists it as a valid front door, but only CloudFront OAC is documented as signing for IamAuth. Behind any other front door the endpoint must be ApplicationManaged. Because the endpoint stays directly reachable, AWS’s guidance is to verify a shared secret or token in the application. The costs are a second hop, a secret to rotate, and the gateway’s integration timeout, which still caps request duration. No AWS page shows this exact pairing yet. Treat it as a migration bridge, and verify it (especially how the Host header reaches Lambda) before relying on it.

Moving a Bounded Context Without a Big-Bang Cutover#

The previous post’s discipline carries over: one small, reversible step at a time, with parity checked before each cutover. The unit of cutover changes from a function to a path on the front door. The decision for each route runs as follows, with staying on the Lambdalith as the root.

yes

no

no

yes

keep gateway features

custom domain, WAF

Route in the Hono Lambdalith

Needs provisioned concurrency, more than 2 GB, x86, non-Node, private ingress, usage plans, or a non-launch Region?

Stays on the Lambdalith

Has a trigger: long requests, streaming, I/O-bound concurrency, SSR, multi-Region?

Make app host-agnostic, audit concurrency

Front door

Retarget route to HTTP integration, shared secret

CloudFront OAC to IamAuth endpoint

  1. Gate on the constraints first. Any route that needs reserved or provisioned concurrency, more than 2 GB, x86 native modules, a non-Node runtime, private ingress, usage-plan throttles, or a Region outside the launch list stays where it is. This is the counterpart of “Routes That Stay Single-Purpose” in the previous post.
  2. Make the app host-agnostic. Remove reads of c.env.event and c.env.lambdaContext, take the request ID from headers, and add the JWK middleware while the gateway authorizer still validates. The checks are redundant during this phase, and the Lambdalith keeps working with every one of these changes. They therefore ship before any infrastructure change, and they are worth doing even if the move never happens.
  3. Audit for concurrency. Search for module-scope let, caches mutated across await, per-request context appended to singletons, and metric buffers. Then run concurrent requests against the server locally, as AWS’s testing guidance (opens in new tab) for Web Functions recommends.
  4. Package for Arm64 and the entry contract. Bundle to index.js at the archive root, add "type": "module" to the packaged package.json for ESM output, and build native dependencies for linux/arm64. Upload to a versioned bucket and record the object version.
  5. Stand up the Web Function next to the Lambdalith. CloudFormation has AWS::Lambda::WebFunction, AWS::Lambda::WebFunctionRevision, and AWS::Lambda::WebFunctionEndpoint, and the CLI has aws lambda-web deploy. There is no CDK L2 construct, so CDK users write them with the L1 classes CfnWebFunction, CfnWebFunctionRevision, and CfnWebFunctionEndpoint. Validate the revision on a test endpoint without production traffic, and run the previous post’s parity fixtures against it over HTTP.
  6. Cut over per path at the front door. With API Gateway kept in front, retarget an explicit route’s integration from the Lambda integration to an HTTP integration that points at the endpoint. This is the same retarget-then-delete mechanic as before, with the same one-line revert. With CloudFront as the target, path-based cache behaviors can send /orders/* to the Web Function origin while everything else keeps its current origin. In both cases, keep the Lambdalith deployed through a soak period.
  7. Use revision weights for later deploys. For an endpoint created with the CLI, switch it to autoDeploymentMode: Disabled. Template-created endpoints are driven by RevisionWeights instead. Then canary with two revisions, and attribute errors by APP_VERSION in the app’s own metrics. Keep the rollback revision referenced by an endpoint, because automatic cleanup deletes the oldest unreferenced revisions past 50.

The local run for step 3 uses the same command AWS documents, with a load tool to push concurrent requests through one process:

npx esbuild src/orders/server.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/index.js
echo '{"type":"module"}' > dist/package.json
cd dist && AWS_LAMBDA_HTTP_ENDPOINT=127.0.0.1:3000 node index.js &
sleep 2
npx autocannon -c 64 -d 10 http://127.0.0.1:3000/orders/123

A 64-connection run mirrors the default per-environment ceiling. A request-scoped bug shows up as responses carrying another request’s data, so assert on response bodies in a scripted test as well; a throughput number alone hides it.

The cost of this path is a phase where two hosting models run side by side, with two sets of dashboards and two deploy pipelines. What it buys is a per-path rollback that never depends on the new platform behaving well.

Endpoint Settings as the Remaining Throttle#

Without per-route throttles, three endpoint settings carry the protection role. The scaling page (opens in new tab) works through an example: maxEnvironments of 10 times maxConcurrencyPerEnvironment of 64 allows about 640 requests in flight. At 200 ms per request that is about 3,200 requests per second, bound by concurrency. At 10 ms per request, a rateLimit of 8,000 binds first.

rateLimit (0 to 10,000 RPS, in fixed steps: 0, 100 to 1,000 by 100, then 2,000 to 10,000 by 1,000) is the closest analogue to a gateway throttle and suits protecting a downstream database. maxEnvironments (2 to 10,000) caps how far the endpoint scales, and therefore spend. Both are enforced per Region, so a MultiRegion endpoint gets the limit in each Region it serves. When either limit fires, the 429 response carries a lambda-web-error-type header naming the cause. Setting rateLimit to 0 returns 503 Platform.EndpointDisabled, AWS documents that a value of 0 blocks all traffic, so it works as a kill switch. Per the quotas page (opens in new tab), account defaults per Region are 10,000 RPS per endpoint, 100,000 RPS per account, and 2,000 Arm64 vCPUs. New accounts start with lower Web Functions quotas that AWS raises automatically. At 2 vCPUs per environment, that is 1,000 environments.

The trade-off is granularity. One noisy route can consume the whole endpoint’s rateLimit, and per-route isolation exists only if routes live on separate endpoints or behind a front door that throttles per path.

When the Override Wins#

The default leans toward staying, because the gateway features the previous post preserved have no Web Function equivalent and the feature is new: one runtime, five Regions, no CDK L2. Three situations change the answer.

Greenfield Node.js HTTP services in a launch Region, with none of the blocking constraints, can start on a Web Function behind CloudFront with OAC and an IamAuth endpoint, using the same Hono app shape. There is no adapter to remove later and no gateway configuration to migrate. The cost is owning auth, CORS, and per-route policy in code from day one, plus accepting a cold start after idle periods, because there is no warm-capacity floor.

Streaming-heavy or long-request APIs should move first. Server-sent events, chunked responses, and requests longer than the gateway’s integration window fit Web Functions directly, while the Lambdalith needs Function URLs or a separate path for them. Handlers that spend most of their time waiting on I/O also fit the active-CPU billing model. The EnvironmentCPUTime to EnvironmentActiveTime ratio from the metrics page (opens in new tab) shows that share once a function runs.

Teams that should stay put include anyone on Python or Java, anyone who needs private REST APIs, per-route usage plans or API keys, or strict warm-capacity guarantees, and anyone whose traffic arrives from queues or streams. WebSockets are a fit for neither option; API Gateway WebSocket APIs remain the AWS answer there. For the earlier question of how many functions to have at all, see Single-Purpose Functions vs Lambdalith. The third form factor, MicroVMs, appears in Where Should Claude Code Run? as a sandbox option.

Mistakes the Documentation Already Warns About#

Each item below is a failure mode described in the launch documentation, with the fix AWS points to.

  1. Listening on localhost or a fixed port. The result is Application.TimeoutListenError or a 502 on the first request. Parse AWS_LAMBDA_HTTP_ENDPOINT and bind exactly that host and port.
  2. Trusting “Active”. An active revision or a successful endpoint update does not mean the server started; Lambda does not check that it listens. Smoke-test every deploy and read the logs.
  3. Partial configuration on a new revision. A new revision copies nothing from the previous one. Omitted environment variables disappear and the timeout returns to 30 seconds. Generate the full revision config from one source.
  4. Per-request state in module scope. It is shared by every request in flight on that environment. Use c.set and c.get, and guard read-modify-write sequences that cross an await.
  5. EMF through console. Lambda wraps the output and CloudWatch extracts nothing. Write with process.stdout.write.
  6. Forwarding the viewer Host header from CloudFront. Lambda routes on Host and answers 404 Platform.InvalidEndpoint. Use the AllViewerExceptHostHeader origin request policy or an explicit allow list. If the app reads bearer tokens, also include Authorization in the cache policy, or GET requests arrive without it.
  7. Treating a front door as privacy. An ApplicationManaged endpoint behind any front door stays reachable at its own domain, bypassing CloudFront and WAF. Use IamAuth with OAC, or verify an origin secret in the app.
  8. Writing the response before reading the body. The request path is half-duplex, so large uploads stall. Consume the body first.
  9. x86 native modules. Build dependencies inside a linux/arm64 container with npm ci --omit=dev.
  10. Trusting forwarded headers. Lambda forwards X-Forwarded-Host without validation and appends only the last X-Forwarded-For hop. Build absolute URLs from a domain you control.
  11. Losing the rollback revision. Automatic cleanup removes the oldest unreferenced revisions past 50, and deleting the S3 object version moves a revision to Failed. Keep the rollback target referenced and its object version retained.

Where the Default Holds#

Keeping a working Hono Lambdalith on API Gateway holds while its routes depend on per-route scopes, throttles, usage plans, private APIs, or provisioned concurrency. It also holds for runtimes and Regions outside the launch list, and while none of the triggers apply. The override applies to greenfield Node.js services and to bounded contexts with long requests, streaming, I/O-heavy handlers, server-side rendering, or multi-Region latency needs. Those move path by path behind a front door that keeps a one-line revert. The next step that pays off either way is removing c.env.event and c.env.lambdaContext from the app.

References#

Related posts