AWS Lambda Web Functions: Deploy a Node.js HTTP Server, Lock It Behind CloudFront, and Avoid the First-Day 502s
AWS Lambda Web Functions: Deploy a Node.js HTTP Server, Lock It Behind CloudFront, and Avoid the First-Day 502s
On October 12, 2026, AWS launched Lambda Web Functions: a second kind of Lambda function where your code is an ordinary HTTP server that listens on a port. No handler signature, no event object, no Lambda Web Adapter layer. The same day, CloudFront added Web Functions as an origin type with origin access control. The functions you already have are now called Lambda Event Functions; that is a rename only and needs no code change. This guide walks through a first deployment with the new aws lambda-web CLI, the one environment variable your server has to honour, how to put the endpoint behind CloudFront without leaving a public back door, and the errors you are most likely to hit on day one.
What we tested and what we did not. The sample server in Step 1 was run locally on Node.js with AWS_LAMBDA_HTTP_ENDPOINT set by hand, and every aws lambda-web flag used below was checked against the help output of AWS CLI 2.37.13. We did not deploy a Web Function into an AWS account for this article, so the deploy output, the CloudFront setup, the status codes and the error types are taken from the AWS documentation published at launch, not from our own runs. Each section says which is which.
What actually changed
A classic Lambda function (now an Event Function) gets one event per execution environment at a time and is billed for wall-clock duration. A Web Function is different in four ways that matter:
| Event Function | Web Function | |
|---|---|---|
| Programming model | handler(event, context) | Your HTTP server listens on a host and port |
| Concurrency per environment | 1 request | Up to 128 concurrent requests (default cap 64) |
| Compute billing | Wall-clock duration x memory | Active CPU time, plus memory for the life of the environment |
| HTTPS endpoint | Function URL, API Gateway or ALB | Built in, one or more named endpoints per function |
The launch limits, from the announcement and developer guide:
- Runtime: Node.js only (
nodejs24.x), on Arm64. Python, Java and .NET are not supported yet. - Regions: US East (N. Virginia), US East (Ohio), US West (Oregon), Asia Pacific (Tokyo) and Europe (Ireland).
- Environment size: each execution environment has 2 vCPUs and a 2 GB memory limit. There is no memory slider.
- Protocol: HTTP/1.1 only. HTTP/1.0 gets a 505. The
Upgradeheader is stripped before the request reaches your code, so plan on response streaming rather than WebSockets. - Request timeout: 30 seconds by default, configurable from 3 to 900 seconds.
- Packaging: a .zip file in a versioned S3 bucket. Container images are not mentioned in the launch documentation.
Three resource types sit behind every deployment. A Web Function is the container. A revision is an immutable snapshot of code plus configuration. An endpoint is an HTTPS hostname that routes to one or two revisions, with weights if you want a canary.
Step 1: Write a server that listens where Lambda tells it to
Lambda passes the listen address in one environment variable, AWS_LAMBDA_HTTP_ENDPOINT, in host:port form. Your server must start listening on exactly that host and port within 10 seconds of process start. This is the whole contract.
index.js (this is the file we ran locally):
1import http from 'node:http';
2
3// Lambda injects host:port. Fall back to a local default for development.
4const endpoint = process.env.AWS_LAMBDA_HTTP_ENDPOINT ?? '127.0.0.1:3000';
5const sep = endpoint.lastIndexOf(':');
6const host = endpoint.slice(0, sep);
7const port = Number(endpoint.slice(sep + 1));
8
9const server = http.createServer(async (req, res) => {
10 if (req.url === '/stream') {
11 res.writeHead(200, { 'content-type': 'text/plain' });
12 for (let i = 1; i <= 3; i++) {
13 res.write(`chunk ${i}\n`);
14 await new Promise((r) => setTimeout(r, 300));
15 }
16 return res.end('done\n');
17 }
18 res.writeHead(200, { 'content-type': 'application/json' });
19 res.end(JSON.stringify({
20 message: 'Hello from a Web Function',
21 path: req.url,
22 requestId: req.headers['lambda-web-request-id'] ?? null,
23 }));
24});
25
26server.listen(port, host, () => console.log(`listening on ${host}:${port}`));
27
28process.on('SIGTERM', () => {
29 // Lambda gives the process up to 1 second after SIGTERM.
30 server.close(() => process.exit(0));
31 setTimeout(() => process.exit(0), 800).unref();
32});
package.json:
1{"name": "my-web-app", "version": "1.0.0", "type": "module", "private": true}
Without "type": "module" the import line fails with "Cannot use import statement outside a module".
Run it locally the way Lambda would:
1AWS_LAMBDA_HTTP_ENDPOINT=127.0.0.1:8791 node index.js &
2curl -s http://127.0.0.1:8791/hello -H 'lambda-web-request-id: test-123'
Our output:
1listening on 127.0.0.1:8791
2{"message":"Hello from a Web Function","path":"/hello","requestId":"test-123"}
curl -N http://127.0.0.1:8791/stream printed chunk 1 to chunk 3 roughly 300 ms apart, then done, and a kill -TERM exited the process cleanly.
If you use Express, Hono or Fastify, the only change is the same one: split AWS_LAMBDA_HTTP_ENDPOINT and pass both parts to app.listen(port, host). Do not hard-code localhost or port 3000 in the deployed path. A server that binds to a loopback address or the wrong port is the documented cause of Application.TimeoutListenError.
Step 2: Deploy with aws lambda-web deploy
Web Functions have their own CLI namespace, lambda-web, not lambda. Check that your CLI has it:
1aws lambda-web help
If you get invalid choice: lambda-web, update the AWS CLI. Version 2.37.13 has it.
Then deploy. The command zips the directory, uploads it to S3, and creates the Web Function, its first revision and an endpoint named dev:
1aws lambda-web deploy \
2 --name my-web-app \
3 --code ./my-web-app \
4 --region us-east-1
When the command has to create a function or endpoint it asks you to press Enter. In CI, add --create to skip the prompt.
According to the documentation, when it finishes it prints an endpoint URL in this shape:
1https://bcdfghjklmnpqrst.b7t.lambda-web.us-east-1.on.aws
On first run, and unless you pass --bucket-name and --execution-role-arn, the command also creates two things in your account that you should know about:
- an S3 bucket named
awscli-lambdaweb-<account-id>-<region>-an, with versioning turned on - an IAM execution role named
awscli-lambdaweb-<function-name>with CloudWatch Logs permissions
So the identity running the first deploy needs iam:CreateRole, iam:PutRolePolicy, iam:GetRole, s3:CreateBucket, s3:PutBucketPolicy and s3:PutBucketVersioning on top of the Lambda permissions and iam:PassRole. In an account where developers cannot create roles, pre-create both and pass them in.
A redeploy is the same command. It creates a new revision, and because the dev endpoint is in LatestRevision mode, traffic moves to it once the revision is Active. The URL does not change.
Useful flags, all present in the CLI help:
1aws lambda-web deploy \
2 --name my-web-app \
3 --code ./my-web-app \
4 --env DATABASE_URL=postgres://db.internal/app \
5 --timeout-seconds 60 \
6 --max-concurrency-per-environment 32 \
7 --auth-type IamAuth
One warning from the documentation that is easy to miss: Lambda does not verify that your server starts when it creates a revision. The revision and the endpoint can both show Active while every request fails. Active means "packaged", not "healthy". Always send a real request after a deploy.
Step 3: Decide who can reach the endpoint
The default is the part to be careful with. aws lambda-web deploy creates the endpoint with auth type ApplicationManaged, which means Lambda authenticates nothing and anyone who has the hostname can call your code. There are two auth types:
| Auth type | Who can call | Use it for |
|---|---|---|
ApplicationManaged (CLI default) | Anyone on the internet. Lambda does not evaluate the resource policy for invokes. | Public sites, or apps that do their own login |
IamAuth | Callers that sign with SigV4 and are allowed lambda:InvokeWebFunctionEndpoint | Service-to-service calls, and anything behind CloudFront OAC |
To switch an existing endpoint:
1aws lambda-web update-web-function-endpoint \
2 --function-name my-web-app \
3 --endpoint-name dev \
4 --auth-type IamAuth
If you call an IamAuth endpoint directly, sign with service name lambda and always send the X-Amz-Content-SHA256 header, even on a GET with no body. Requests without it are rejected with a 403. For streamed bodies the documented value is the literal UNSIGNED-PAYLOAD.
If you run an AWS Organization and want to stop public Web Function endpoints entirely, AWS documents a condition key for it. This SCP denies creating or switching to ApplicationManaged:
1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Effect": "Deny",
6 "Action": [
7 "lambda:CreateWebFunction",
8 "lambda:CreateWebFunctionEndpoint",
9 "lambda:UpdateWebFunctionEndpoint"
10 ],
11 "Resource": "*",
12 "Condition": {
13 "StringEquals": {
14 "lambda:Request/WebFunctionAuthType": "ApplicationManaged"
15 }
16 }
17 }
18 ]
19}
With that in place a plain aws lambda-web deploy fails with access denied until the developer adds --auth-type IamAuth. Existing SCPs written for lambda:FunctionUrlAuthType do not cover Web Functions; they use different actions and a different key.
Step 4: Put CloudFront in front, with origin access control
Web Function endpoints have no custom domain setting and no WAF attachment of their own. AWS's answer for both is CloudFront. We have not built this distribution ourselves; the settings below are from the CloudFront Developer Guide page published with the launch.
1. Set the endpoint to IamAuth (Step 3). This is required. With ApplicationManaged the endpoint stays publicly reachable on its own hostname even when CloudFront is in front of it.
2. Create the origin access control. Origin type is lambda and the signing behavior must be always-amz-auth. CloudFront rejects the other signing behaviors for Web Function origins.
1aws cloudfront create-origin-access-control \
2 --origin-access-control-config '{
3 "Name": "my-web-app-oac",
4 "Description": "OAC for Lambda Web Function origin",
5 "SigningProtocol": "sigv4",
6 "SigningBehavior": "always-amz-auth",
7 "OriginAccessControlOriginType": "lambda"
8 }'
Use "SigningProtocol": "sigv4a" instead if the origin is a multi-Region endpoint (hostname ending in .lambda-web.global.on.aws).
3. Create the distribution with the endpoint hostname as the origin domain, attach the OAC to the origin, and choose these two policies on the cache behavior:
- Origin request policy:
AllViewerExceptHostHeader, notAllViewer. The endpoint routes on theHostheader and expects its own hostname there. If CloudFront forwards your custom domain asHost, Lambda answers 404 withlambda-web-error-type: Platform.InvalidEndpoint. - Cache policy: if your app reads the viewer's
Authorizationheader (JWTs, for example), add that header to the cache policy. CloudFront drops it on GET and HEAD otherwise.
4. Allow the distribution to invoke the endpoint. Save this as policy.json, with your account ID, function name, endpoint name and distribution ID:
1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Sid": "AllowCloudFrontOACAccess",
6 "Effect": "Allow",
7 "Principal": { "Service": "cloudfront.amazonaws.com" },
8 "Action": "lambda:InvokeWebFunctionEndpoint",
9 "Resource": "arn:aws:lambda:us-east-1:111122223333:web-function/my-web-app/endpoint/dev",
10 "Condition": {
11 "StringEquals": {
12 "aws:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE"
13 }
14 }
15 }
16 ]
17}
Attach it to the function ARN, not the endpoint ARN:
1aws lambda-web put-resource-policy \
2 --resource-arn "arn:aws:lambda:us-east-1:111122223333:web-function/my-web-app" \
3 --policy file://policy.json \
4 --region us-east-1
put-resource-policy replaces the whole policy. If the function already has one, fetch it with get-resource-policy, append the statement, and pass the returned revisionId with --revision-id so you do not overwrite someone else's change.
Two documented limits to plan around: Lambda@Edge origin-request and origin-response triggers cannot be used on a behavior that has a Web Function origin with OAC, and OAC does not work for a multi-Region endpoint that includes an opt-in Region.
Step 5: Set scaling caps before real traffic arrives
By default nothing limits how far an endpoint scales except account quotas: 10,000 requests per second per endpoint, 100,000 per Region across the account, and 2,000 vCPUs, which at 2 vCPUs each is 1,000 environments. Three settings let you cap it lower:
| Setting | Lives on | Range | Default |
|---|---|---|---|
maxConcurrencyPerEnvironment | Revision | 1 to 128 | 64 |
maxEnvironments | Endpoint | 2 to 10,000 | not set |
rateLimit (requests per second) | Endpoint | 0 to 10,000 | not set |
The endpoint settings change in place, with no new revision:
1aws lambda-web update-web-function-endpoint \
2 --function-name my-web-app \
3 --endpoint-name dev \
4 --scaling-config '{"maxEnvironments": 10}' \
5 --throttle-config '{"rateLimit": 500}'
rateLimit values up to 1,000 must be a multiple of 100, and above 1,000 a multiple of 1,000. A value of 0 turns the endpoint off: every request gets a 503 with Platform.EndpointDisabled. That is a handy kill switch and an easy way to cause an outage by typo.
Requests over a cap get an HTTP 429 and are not queued. There is no provisioned concurrency or minimum-environments setting. An idle endpoint scales to zero and the next request pays a cold start, although Lambda does keep spare capacity ahead of demand while traffic is flowing.
What it costs
Prices for US East (N. Virginia), from the Lambda pricing page:
| Dimension | Price |
|---|---|
| Requests | $0.30 per million |
| Active CPU | $0.0000276944 per vCPU-second |
| Memory | $0.0000036667 per GB-second, for as long as the environment exists |
AWS's own example: 15 million requests a month at 40 ms of active CPU each comes to $16.62 CPU + $4.66 memory + $4.50 requests = $25.78. The CPU and request figures check out by hand (15M x 0.04 s x $0.0000276944 = $16.62, and 15 x $0.30 = $4.50).
The pricing page lists no free tier for Web Functions, unlike Event Functions.
The line to watch is memory. It accrues while an environment exists, including while it sits idle between requests. By our arithmetic, one 2 GB environment that stays up for a full 30-day month costs 2 x 2,592,000 s x $0.0000036667, about $19 in memory alone. A steady trickle of traffic that never lets the endpoint scale to zero will cost roughly that per warm environment, regardless of CPU use. I/O-heavy apps come out ahead of Event Functions; a low-traffic app that is pinged every minute by an uptime checker may not.
Best practices
- Smoke-test every revision.
Activedoes not mean the server came up. Curl the endpoint in the pipeline and fail the deploy on a non-200. - Use
IamAuthplus CloudFront OAC for anything customer-facing. It is the only way to get a custom domain, WAF, and an origin that cannot be reached directly by anonymous callers. - Canary with revision weights. Set the endpoint's auto-deployment mode to
Disabledand split traffic, for example 90/10, with--revision-weightsonupdate-web-function-endpoint. - Write code that is safe for concurrent requests. Up to 128 requests share one process. Per-request data in a module-level variable will leak between users.
- Size connection pools for the environment, not the request. One pool per environment, bounded, and remember
maxEnvironmentsx pool size is what your database sees. - Start listening first, then warm up. You have 10 seconds to bind the port. Load big files and open connections after
listen(), and have handlers await that work.
Common mistakes to avoid
- Leaving the default
ApplicationManagedendpoint in place after adding CloudFront. WAF rules on the distribution mean nothing if the.on.awshostname is still open. - Using the
AllViewerorigin request policy. It forwards the viewer'sHostheader and every request 404s. - Shipping without
node_modules. The package must contain production dependencies. Runnpm ci --omit=devbefore deploying, and check withunzip -l. - Passing PascalCase JSON to the API-level commands.
create-web-functionwantsbuildConfig,codeConfig,serviceConfig.BuildConfigfails with "Unknown parameter". - Responding before the upload finishes. The request path is half-duplex: Lambda forwards the full request body before it delivers your response. Read the body first, or large uploads hang.
- Assuming graceful shutdown. After
SIGTERMyou get 1 second. Flush logs, nothing more.
Troubleshooting
All of these are from the AWS troubleshooting page. Start with curl --include and read the lambda-web-error-type response header; it tells you whether the platform, the runtime or your code failed.
| Symptom | Cause | Fix |
|---|---|---|
502 on first request, Application.TimeoutListenError | Server bound to localhost, or a port other than the one in AWS_LAMBDA_HTTP_ENDPOINT, or took over 10 s to listen | Parse the variable and listen on that exact host and port |
Cannot find module '/var/task/index.js' | Entry file is not at the zip root | Put index.js at the root, or set AWS_LAMBDA_NODEJS_ENTRYPOINT (for example dist/index.js) with --env |
404 with Platform.InvalidEndpoint behind CloudFront | Viewer Host header forwarded to the origin | Switch to AllViewerExceptHostHeader |
403 on an IamAuth endpoint | Unsigned request, missing X-Amz-Content-SHA256, or no lambda:InvokeWebFunctionEndpoint permission | Add the header, check the signature and both policies |
| 429 | rateLimit, maxEnvironments, or an account quota was hit | The error type names which one: Platform.EndpointRequestThrottle, Platform.EndpointMaxEnvThrottle, Platform.AccountActiveVCPUArm64Throttle |
504 with Runtime.RequestTimeout | Request ran past timeoutSeconds (default 30) | Raise --timeout-seconds, up to 900 |
JavaScript heap out of memory | V8's default heap limit is below the 2 GB environment limit | Fix the leak, lower concurrency, or set --max-old-space-size through NODE_OPTIONS |
| VPC resources unreachable | The serving revision has no egress network connector | Create a revision that sets egressNetworkConnectorArn |
The response also carries lambda-web-request-id and lambda-web-logstream, which point you at the exact CloudWatch log stream for that request.
FAQ
Do I have to migrate my existing Lambda functions? No. They are now called Lambda Event Functions and work exactly as before. Event-driven work such as SQS consumers and S3 triggers should stay there; Web Functions only accept HTTP requests.
How is this different from a Lambda function URL?
A function URL still invokes a handler with one event at a time and bills wall-clock duration. A Web Function runs your own HTTP server, shares one environment across up to 128 concurrent requests, and bills active CPU. The IAM actions differ too: lambda:InvokeWebFunctionEndpoint instead of lambda:InvokeFunctionUrl.
Can I run Python or Java? Not at launch. Only Node.js is supported. For other languages AWS points to Event Functions.
Can I keep an environment warm? There is no setting for it. Lambda keeps headroom while traffic flows and removes environments when it stops.
Does it support WebSockets?
The documentation says the endpoint speaks HTTP/1.1 and removes the Upgrade header before forwarding, so a WebSocket handshake cannot complete. Use streamed responses or server-sent events.
Key takeaways
| Topic | What to remember |
|---|---|
| The contract | Listen on the host and port in AWS_LAMBDA_HTTP_ENDPOINT within 10 seconds |
| CLI | aws lambda-web deploy --name <name> --code <dir>; needs a recent AWS CLI v2 |
| Default exposure | The CLI creates a public ApplicationManaged endpoint unless you pass --auth-type IamAuth |
| CloudFront | OAC type lambda, signing always-amz-auth, origin request policy AllViewerExceptHostHeader |
| Limits | Node.js 24 on Arm64, 5 Regions, 2 vCPU and 2 GB per environment, HTTP/1.1 only |
| Scaling | Default 64 concurrent requests per environment; cap with maxEnvironments and rateLimit |
| Billing | Active CPU plus memory for the life of the environment; an always-warm environment is about $19 a month in memory |
| Debugging | Read the lambda-web-error-type header first |