AWS launched Lambda Web Functions on 12 October. You hand Lambda an ordinary Node.js HTTP server, Express, Fastify, Hono or plain node:http, and it gives you an HTTPS endpoint, optionally served from several regions, with multi-concurrent execution environments and billing on active CPU rather than wall-clock time. No handler, no adapter, no API Gateway in between.
The CLI story is excellent: aws lambda-web deploy --name my-app --code . and you have a URL. The CDK story, on day one, was L1s. CfnWebFunction, CfnWebFunctionRevision, CfnWebFunctionEndpoint, plus a code bucket, a BucketDeployment and log groups you wire up yourself. That's fine for a demo and painful for anything you intend to keep.
So I wrote the L2. It's on npm as @ashishpandeyone/cdk-lambda-web-function and the source is on GitHub, Apache-2.0.
The short version
npm install @ashishpandeyone/cdk-lambda-web-function
It needs aws-cdk-lib 2.273.0+, constructs 10.5.1+ and Node.js 22+.
import * as lambda from 'aws-cdk-lib/aws-lambda';
import {
WebFunction,
WebFunctionAuthType,
WebFunctionEndpointType,
} from '@ashishpandeyone/cdk-lambda-web-function';
const fn = new WebFunction(this, 'Api', {
runtime: lambda.Runtime.NODEJS_24_X,
code: lambda.Code.fromAsset('dist'),
authType: WebFunctionAuthType.AWS_IAM,
endpoint: {
endpointType: WebFunctionEndpointType.MULTI_REGION,
regions: ['us-east-1', 'eu-west-1'],
},
});
new CfnOutput(this, 'Url', { value: fn.endpoint.url });
// https://<id>.lambda-web.global.on.aws
The server is just a server. The only contract is to listen on whatever AWS_LAMBDA_HTTP_ENDPOINT says, within 10 seconds:
const express = require('express');
const app = express();
app.get('/', (req, res) => res.json({ hello: 'world', region: process.env.AWS_REGION }));
const [host, port] = process.env.AWS_LAMBDA_HTTP_ENDPOINT.split(':');
app.listen(Number(port), host);
PORT isn't set, the runtime has no AWS SDK (bundle the clients you import), and code runs on arm64. Those three trip people up more than anything else.
If you'd rather write TypeScript and not think about bundling, NodejsWebFunction does for Web Functions what NodejsFunction does for regular ones: esbuild, ESM by default with a require shim so CommonJS dependencies like Express still work, source maps on with NODE_OPTIONS=--enable-source-maps set for you, and the entry point matched to the bundle.
new NodejsWebFunction(this, 'Api', {
entry: 'src/server.ts',
authType: WebFunctionAuthType.AWS_IAM,
});
Why an L2 is worth it here
The API follows aws-cdk-lib/aws-lambda wherever the two services overlap, so grantInvoke, addEnvironment, addPermission, metric*() and property injection all behave the way you'd expect. The interesting part is what it absorbs underneath, because Web Functions have a few sharp edges that the L1s leave entirely to you.
The code bucket. Web Functions pull code as the lambda.amazonaws.com service principal, from a versioned bucket. The CDK bootstrap bucket is neither readable by that principal nor versioned. The construct gives each stack a small versioned code bucket, copies assets into it S3-to-S3 on deploy, pins revisions to the object version, and empties and deletes the bucket with the stack. No BucketDeployment, which means no 1 GB Python Lambda with an AWS CLI layer.
Log groups in every region. Logs are two-tier. Each serving region writes to <group>.local, and everything is copied to <group> in the home region within seconds. If you don't create those groups, the service creates them for you, with no retention, and never deletes them. The construct owns all of them, in every region any endpoint serves from, with the same retention and removal policy. The default execution role can write to exactly those groups and nothing else, including no logs:CreateLogGroup, which matters more than it looks: environments still draining after a delete can otherwise re-create the group you just removed.
The resource policy. A Web Function has one resource policy and no AddPermission API. If two stacks both need a statement in it (the function's stack, and a stack with a CloudFront distribution, say), a naive AWS::Lambda::ResourcePolicy from either one replaces the whole document and silently drops the other's statements. The construct uses a custom resource per stack that reads the policy, swaps only the statements that stack owns, and writes it back with optimistic retries. It also refuses public policies at synth time and warns when you give a service principal without a sourceArn or sourceAccount condition.
Revisions. Every code or config change is a new revision. The construct keeps the newest 20 by default (revisionRetention, up to 50), never deletes one an endpoint routes to or one the previous deployment used, and a failed deployment rolls the endpoint back. You can pin an endpoint to an older revision or split traffic between two:
const previous = WebFunctionRevision.fromRevisionAttributes(this, 'Previous', {
webFunction: fn,
revisionId: 'rev-a1b2c',
});
fn.endpoint.routeTraffic(previous, [{ revision: fn.currentRevision, weight: 0.1 }]);
Regions and endpoints
Every function gets a default endpoint, and you can add up to nine more, each with its own URL, auth type, regions and limits.
endpointType |
URL | Routing |
|---|---|---|
HOME_REGION (default) |
https://<id>.<cell>.lambda-web.<home>.on.aws |
home region only |
MULTI_REGION |
one global https://<id>.lambda-web.global.on.aws
|
nearest region |
PER_REGION |
one URL per region via fn.endpoint.regionalUrl(region)
|
the region in the hostname |
A public endpoint next to an IAM-only global one, for service-to-service traffic:
const api = new WebFunction(this, 'Api', {
runtime: lambda.Runtime.NODEJS_24_X,
code: lambda.Code.fromAsset('dist'),
authType: WebFunctionAuthType.NONE,
endpoint: { rateLimit: 1000 },
});
const internal = api.addEndpoint('Internal', {
authType: WebFunctionAuthType.AWS_IAM,
endpointType: WebFunctionEndpointType.MULTI_REGION,
regions: ['us-east-1', 'eu-west-1'],
maxEnvironments: 50,
});
internal.grantInvoke(consumerRole);
Two things worth internalising before you go multi-region. rateLimit and maxEnvironments apply separately in each region, so a three-region endpoint at rateLimit: 1000 accepts up to 3,000 rps in total. And MULTI_REGION routes by latency, not health. It won't fail over when a region is having a bad day.
Callers signing for a MULTI_REGION endpoint need SigV4a with region *, since they can't know which region will serve the request. The README has a working @smithy/signature-v4a snippet.
CloudFront, again
Back in 2024 I wrote about putting a Lambda Function URL behind CloudFront with CDK. Web Functions get the same treatment, with origin access control from day one:
new cloudfront.Distribution(this, 'Cdn', {
defaultBehavior: {
origin: WebFunctionOrigin.withOriginAccessControl(fn.endpoint),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
},
});
That one call creates the OAC (SigV4 for a regional domain, SigV4a for a global one) and writes a resource-policy statement that lets only this distribution invoke the endpoint. The distribution can live in another stack, or another region if you need us-east-1 for an ACM certificate.
Two gotchas are worth knowing. Forward everything except Host, because the endpoint returns 404 when Host is the distribution's domain, so it's ALL_VIEWER_EXCEPT_HOST_HEADER, not ALL_VIEWER. And if your app authenticates viewers with a JWT, Authorization has to be in the cache policy, or CloudFront strips it from GET and HEAD. A 0/0/1-second TTL policy keeps caching off in practice.
Canary and linear deployments
This is where an L2 earns its keep. Each endpoint can shift traffic in steps and watch CloudWatch alarms while it does:
const api = new WebFunction(this, 'Api', {
runtime: lambda.Runtime.NODEJS_24_X,
code: lambda.Code.fromAsset('dist'),
authType: WebFunctionAuthType.AWS_IAM,
endpoint: {
deployment: WebFunctionDeployment.canary({
percent: 10,
bake: Duration.minutes(10),
rollbackOn: { serverErrorRate: 2 },
}),
},
});
rollbackOn creates error-rate alarms in every region the endpoint serves from, since Web Function metrics stay in the region that served the request and an alarm can only watch its own region. If any alarm fires, the endpoint goes back to the revision it was serving, the custom resource fails, and CloudFormation rolls the stack back. Each step also waits until every region has applied it. Under the hood it's a small Step Functions state machine polling every 15 seconds, created only when you use it.
One honest caveat: Web Function metrics have no revision dimension. At a 10% canary, the new revision's errors are roughly a tenth of what the endpoint reports, so set thresholds accordingly, or alarm on a metric your app publishes with its own version.
The example: Freedoom, rebuilt
Julian Wood from AWS put Doom on a Web Function, and it's one of the best demonstrations of what the new model allows. He compiled the doomgeneric engine to a 2.7 MB WebAssembly module with zig cc, loads it in Node.js 24 through node:wasi, and runs the game loop on the server one 1/35-second tic at a time. Web Functions don't do WebSockets, so frames go out as a single long streamed HTTP response at 17.5 fps, delta-encoded, with key presses arriving as small POSTs. Because consecutive requests can land on different execution environments, each game's state lives in DynamoDB as an input log behind a lease: a new owner replays the log to rebuild the game, in about 30 ms per minute of play. Each tic costs around 0.15 ms of CPU, so one environment hosts many games.
His write-up notes that CDK only had L1 constructs for Web Functions, and his stack is wired up with them. That made it the perfect test: rebuild it with the L2 and see what falls away.
The result is cdk-freedoom-lambda-web-functions. One WebFunction replaces CfnWebFunction, CfnWebFunctionRevision, CfnWebFunctionEndpoint, the code bucket, the BucketDeployment and the log group. -c cloudfront=true replaces the original README's three manual steps (create the OAC, create the distribution, run put-resource-policy by hand) and switches the endpoint to IAM auth. -c regions=us-east-1,eu-west-1 turns it into a MULTI_REGION endpoint backed by a DynamoDB global table, with each region reading its own replica and no server change. The .local log groups that the original leaves behind after cdk destroy are now owned and cleaned up.
It also surfaced the one thing an L2 can't fix. Julian's lease hands a game to a new owner once the heartbeat is 2.5 seconds stale, and that relies on strongly consistent conditional writes. A global table across those regions is eventually consistent. Watching a game from another region works, a second or two further behind than in the original, but taking over a game owned by another region can stall. That needs a server-side change, not an infrastructure one, and the example's README says so.
Things to know before you adopt it
The package is experimental and 0.x, so the API may still move between minor versions. Not yet supported: VPC access (network connectors) and tracing.
cdk deploy --hotswap and cdk watch skip Web Functions silently. They report no changes and the endpoint keeps serving the old revision. Use --hotswap-fallback, which deploys through CloudFormation in about 40 seconds for a home-region endpoint.
Changing an endpoint's type or regions creates a new endpoint with a new URL. Leave endpointName unset and the construct hashes the type and regions into the name, so this just works. Set a fixed name and you have to rename it in the same deployment, or it fails with "already exists". The construct warns at synth time if you set one.
Logs are stored twice (.local plus the home-region copy), so budget for roughly double log ingestion on multi-region endpoints, and subscribe only to fn.logGroup or you'll get every event twice.
There's more in the README: customer-managed KMS keys with multi-Region replicas, importing functions from other stacks, metric helpers, property injection for team defaults, and cdk-nag acknowledgements for the resources the construct creates. The repo also has an end-to-end suite that deploys, verifies and destroys real stacks across three regions.
If you try it, I'd like to hear what breaks. Issues and PRs are open on GitHub.
Originally published at ashishkumarpandey.com.
Top comments (0)