
Getting Started on AWS Lambda Web Functions
Deploy your first Lambda Web Function, run it locally, define it in SAM, stream responses and ship safely with canaries and IAM-secured endpoints.
Series: Serverless (2 articles)
- 2Getting Started on AWS Lambda Web Functions This article
Getting started with some coding
This is Part 2, the hands-on half. If you want the background first, such as how we’ve been running HTTP on Lambda, how Web Functions differ from Event Functions and what they cost, start with Part 1 .
You'll need AWS CLI v2 recent enough to include the
aws lambda-web commands (aws lambda-web help will tell you, and aws update upgrades it if you used the official installer). You'll also need credentials pointed at one of the launch Regions, and permission to create the function plus the S3 bucket and IAM role the CLI sets up for you. Node.js 24 locally is optional but handy (prerequisites ).The quickest way to see one working is the starter:
1
aws lambda-web deploy --name my-web-app --hello-worldThat command writes a small TypeScript server into
./my-web-app. It then creates (or reuses) a versioned S3 bucket and an execution role, creates the function and its first revision, and puts a HomeRegion endpoint called dev in front of it. When it finishes, you get a URL:1
2
curl https://bcdfghjklmnpqrst.b7t.lambda-web.us-east-1.on.aws
# {"message":"Hello from Lambda Web!","path":"/"}Be aware that this endpoint is public. The default auth type is
ApplicationManaged, which means Lambda doesn't authenticate anything. Add --auth-type IamAuth if you only want signed callers.Bringing your own app
The only Lambda-specific thing your code has to do is listen on the host and port that Lambda passes in
AWS_LAMBDA_HTTP_ENDPOINT (runtimes ). If it listens anywhere else, requests never reach it. Here's a small Express API:1
2
3
4
mkdir orders-api && cd orders-api
npm init -y
npm pkg set type=module
npm install express1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// index.js (Node.js 24, ES modules)
import express from 'express';
const app = express();
app.use(express.json({ limit: '1mb' }));
// Lambda sets this variable. The fallback only applies when you run locally.
const [host, port] = (process.env.AWS_LAMBDA_HTTP_ENDPOINT || '127.0.0.1:3000').split(':');
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.get('/orders/:id', async (req, res) => {
const requestId = req.get('lambda-web-request-id') ?? 'local';
console.log(JSON.stringify({ requestId, route: '/orders/:id', orderId: req.params.id }));
res.json({ orderId: req.params.id, region: process.env.AWS_REGION ?? 'local' });
});
app.listen(Number(port), host, () => console.log(`Listening on ${host}:${port}`));
// Lambda sends SIGTERM before it removes an environment, and allows up to 1 second
process.on('SIGTERM', () => {
// flush any buffered logs or metrics here
process.exit(0);
});To run it locally, you just run it:
1
2
AWS_LAMBDA_HTTP_ENDPOINT=127.0.0.1:3000 node index.js
curl http://127.0.0.1:3000/orders/42There's no emulator and there are no event fixtures. Supertest, Playwright, k6 or whatever you already use to test HTTP services works as is.
Deploying is the same command, pointed at your directory:
1
2
npm ci --omit=dev
aws lambda-web deploy --name orders-api --code .Run it again after a change and you get a new revision. Because
dev is in LatestRevision mode, traffic moves to the new revision as soon as it's active, and the URL doesn't change. In CI, add --create to skip the confirmation prompt that deploy shows when it creates a function without --hello-world, or adds an endpoint.A few things are worth knowing before your first real deploy. The runtime loads
index.js from the root of the package unless you set AWS_LAMBDA_NODEJS_ENTRYPOINT. Your server has 10 seconds to start listening, so push heavy initialisation until after listen. Lambda doesn't check that your server actually starts, which means a revision and its endpoint can both show Active while every request fails. Smoke test after every deploy.Native modules need to be built for Linux Arm64. The testing guide suggests installing dependencies inside an Arm64 container:
docker run --rm --platform linux/arm64 -v "$PWD":/app -w /app node:24 npm ci --omit=dev.And frameworks that read their own listen variables, like Next.js in standalone mode, need a small
index.js that maps AWS_LAMBDA_HTTP_ENDPOINT onto those variables first.When you're finished,
aws lambda-web delete-web-function --function-name orders-api removes the function along with its revisions and endpoints. It leaves the IAM role, the objects in the S3 bucket and the CloudWatch log groups behind, so clean those up yourself.Defining it in SAM
The CLI is fine for kicking the tyres, but anything real belongs in infrastructure as code. SAM has a new
AWS::Serverless::WebFunction resource that expands into three CloudFormation resources: AWS::Lambda::WebFunction, AWS::Lambda::WebFunctionRevision and AWS::Lambda::WebFunctionEndpoint. This template is trimmed down from the official Serverless Land pattern (the full version is on GitHub ):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Parameters:
CodeBucket:
Type: String # versioned bucket; its policy grants lambda.amazonaws.com s3:GetObject and s3:GetObjectVersion
CodeKey:
Type: String
Default: orders-api/function.zip
Resources:
WebFunction:
Type: AWS::Serverless::WebFunction
Properties:
Runtime: nodejs24.x
CodeUri:
Bucket: !Ref CodeBucket
Key: !Ref CodeKey
Timeout: 30 # 3 to 900 seconds
AuthType: ApplicationManaged # public; IamAuth requires SigV4 (SigV4a for MultiRegion)
EndpointType: HomeRegion # MultiRegion/PerRegion need Disabled + RevisionWeights; fixed after creation
AutoDeploymentMode: LatestRevision # Disabled + RevisionWeights for canaries
LoggingConfig:
LogGroup: !Sub /aws/lambda/web/${AWS::StackName}
Outputs:
EndpointDomainName:
Value: !GetAtt WebFunctionEndpoint.DomainNameThen package, upload and deploy:
1
2
3
zip -r function.zip index.js package.json package-lock.json node_modules
aws s3 cp function.zip s3://my-code-bucket/orders-api/function.zip
sam deploy --guided --parameter-overrides CodeBucket=my-code-bucketThe pattern's notes call out one trap. CloudFormation only creates a new revision when the revision's properties change, so if you re-upload to the same S3 key and redeploy, it reports no changes. Upload every build under a new key (the commit SHA works well) and pass it in as
CodeKey.AWS also lists the CDK, the SDKs and the console as supported ways to create Web Functions. I've left CDK out of this post because I couldn't confirm the final construct names at the time of writing.
Streaming responses
Streaming needs no special setup on a Web Function. You write to the response and the bytes go to the client. On Event Functions, streaming typically means wrapping your handler for streaming and putting a Function URL in
RESPONSE_STREAM mode, so this is a nice simplification. Here's server-sent events, taken from the official samples :1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.get('/events', (request, response) => {
response.setHeader('Content-Type', 'text/event-stream');
response.setHeader('Cache-Control', 'no-cache');
response.flushHeaders();
const interval = setInterval(() => {
response.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
}, 1000);
// Stop doing work once the client has gone
response.on('close', () => {
clearInterval(interval);
});
});If you plan to stream LLM tokens this way, read the networking page first. The revision's
timeoutSeconds covers the whole stream, up to 900 seconds, and when it fires the client gets a truncated body.The request path is half-duplex, so read the full request body before you start writing. Cancel any upstream call when the client disconnects, or you'll keep paying for CPU on a stream nobody is reading. WebSockets aren't supported at all; Lambda strips the upgrade headers, so SSE is the option for server push.
The billing model suits this kind of workload well. In the pricing page's LLM streaming example, each request stays open for 3,000 ms, but vCPU is only charged for the 200 ms of active work (memory is billed separately for as long as the environment exists).
The part that will catch Lambda experts out: concurrency
On a standard Event Function, a module-level variable is effectively request-scoped, because only one request runs in an environment at a time (Lambda Managed Instances are the exception).
Plenty of us have leaned on that, knowingly or not. On a Web Function, up to 128 requests share a single Node.js process, so the same pattern leaks data between users. The lifecycle docs are blunt about it: "Do not store per-request state in global variables."
This is the kind of code that's harmless today and becomes a data leak when you move it across:
1
2
3
4
5
6
7
8
9
10
11
12
let currentUser;
app.use(async (req, res, next) => {
currentUser = await verifyToken(req.get('authorization'));
next();
});
app.get('/me/orders', async (req, res) => {
// another request can overwrite currentUser while this await is pending
const orders = await db.ordersFor(currentUser.id);
res.json(orders);
});The fix is to keep request data on the request:
1
2
3
4
5
6
7
8
9
app.use(async (req, res, next) => {
res.locals.user = await verifyToken(req.get('authorization'));
next();
});
app.get('/me/orders', async (req, res) => {
const orders = await db.ordersFor(res.locals.user.id);
res.json(orders);
});Globals are still the right home for clients, connection pools and caches. Just make sure lazy initialisation happens once, otherwise a burst of concurrent requests on a cold environment will each open their own pool. A shared promise handles that neatly:
1
2
3
4
5
6
7
8
9
10
let poolPromise;
function getPool() {
// the first caller creates the pool; everyone else awaits the same promise
poolPromise ??= createPool({ max: 10 }).catch((err) => {
poolPromise = undefined; // let the next request retry
throw err;
});
return poolPromise;
}(
createPool, verifyToken and db stand in for your own driver and auth code.)A few more things from the runtime and best practice docs are worth taking to heart. Synchronous CPU work blocks the event loop for every in-flight request in that environment, so move heavy work to
worker_threads (you have 2 vCPUs) or lower maxConcurrencyPerEnvironment.Any read-modify-write on shared state that spans an
await can interleave with other requests. The environment has 2 GB of memory and the default V8 heap is 1,024 MB; you can raise it with NODE_OPTIONS=--max-old-space-size=1536, but that value replaces Lambda's default NODE_OPTIONS, so leave room for buffers and concurrent requests.All requests share the same 512 MB
/tmp, so use unique file names. The docs also say to reuse clients and connection pools across requests. My own advice is to size those pools for many concurrent requests per environment, not one.Canary releases and going multi-Region
Traffic shifting lives on the endpoint. You set
revisionWeights across at most two active revisions, and the weights have to add up to 100 (deployment patterns ). The endpoint that deploy creates is in LatestRevision mode, so the first step is to take manual control:1
2
3
4
5
6
aws lambda-web update-web-function-endpoint \
--function-name orders-api --endpoint-name dev \
--auto-deployment-mode Disabled
aws lambda-web wait web-function-endpoint-updated \
--function-name orders-api --endpoint-name devOnce the new revision is
Active (weights that point at anything else are rejected), send it 10% of traffic:1
2
3
4
5
6
7
8
9
aws lambda-web wait web-function-revision-active \
--function-name orders-api --revision-id rev-def45
aws lambda-web update-web-function-endpoint \
--function-name orders-api --endpoint-name dev \
--revision-weights '[{"revisionId":"rev-abc12","weight":90},{"revisionId":"rev-def45","weight":10}]'
aws lambda-web wait web-function-endpoint-updated \
--function-name orders-api --endpoint-name devPromoting it, or rolling back, is the same command with a single revision at weight 100.
Two details in the docs are easy to miss. First, a new revision doesn't inherit anything from the previous one, so you have to send the full
serviceConfig (timeout, environment variables, concurrency) every time or you'll silently fall back to defaults. Second, the Web Function metrics have no revision dimension. If you want to compare canary errors with the baseline, log your own build or revision ID.Going multi-Region is a flag on the same deploy command:
1
2
aws lambda-web deploy --name orders-api --code . \
--regions us-east-1,eu-west-1,ap-northeast-1Lambda replicates the code and configuration and gives you a single global domain (
<domain-id>.lambda-web.global.on.aws) with latency-based routing on a best-effort basis (launch blog ).It's impressive for one flag, but it's still a distributed system and you still own most of the hard parts (endpoints , best practices ). There's no health-based failover; routing is by latency only. Your data, secrets and downstream dependencies have to exist in every Region that can receive traffic.
Updates can partially succeed, so check the status of every entry in
regionalEndpoints before you call a release done. Multi-Region endpoints can't use LatestRevision or VPC egress connectors, and the endpoint type and Region list are fixed once you create it. If you use IamAuth, callers need to sign with SigV4a rather than SigV4.Locking it down
Every Web Function endpoint is reachable from the internet, whatever auth type you choose, and AWS PrivateLink isn't available for endpoint domains (networking ). So the auth type is your main control.
IamAuth only lets through callers that sign with SigV4 (SigV4a for MultiRegion endpoints) and hold lambda:InvokeWebFunctionEndpoint. ApplicationManaged lets anyone through and leaves authentication to your code, which is what you want for a public site or an API that validates its own JWTs.For anything public in production, I'd put CloudFront in front. Endpoints don't support custom domains or a direct AWS WAF association, so you need CloudFront for those anyway. Use an
IamAuth endpoint with origin access control (origin type lambda, signing behaviour always-amz-auth), and CloudFront will sign every origin request while unsigned direct calls get rejected.Your app's own
Authorization header, a JWT for example, still comes through as long as the distribution forwards it, because Lambda only strips values that are AWS signatures (those starting with AWS4-) (access control ).Scope the resource policy to your distribution:
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCloudFrontOACAccess",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "lambda:InvokeWebFunctionEndpoint",
"Resource": "arn:aws:lambda:eu-west-1:111122223333:web-function/orders-api/endpoint/prod",
"Condition": {
"StringEquals": { "aws:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE" }
}
}]
}1
2
3
aws lambda-web put-resource-policy \
--resource-arn arn:aws:lambda:eu-west-1:111122223333:web-function/orders-api \
--policy file://policy.jsonNote that
put-resource-policy replaces the whole policy. To add a statement, read the current one with get-resource-policy and pass its revisionId back. Two CloudFront details will save you some head-scratching. Don't forward the viewer's Host header, because Lambda routes on Host and returns a 404 if it doesn't match (the AllViewerExceptHostHeader origin request policy handles this). And an ApplicationManaged endpoint stays publicly reachable at its own domain even when CloudFront sits in front of it.If you run a platform team, there's a new condition key that lets you block public endpoints across an Organization:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Deny",
"Action": [
"lambda:CreateWebFunction",
"lambda:CreateWebFunctionEndpoint",
"lambda:UpdateWebFunctionEndpoint"
],
"Resource": "*",
"Condition": {
"StringEquals": { "lambda:Request/WebFunctionAuthType": "ApplicationManaged" }
}
}]
}With that SCP in place,
aws lambda-web deploy fails unless the developer passes --auth-type IamAuth.A few things that API Gateway used to handle are now yours. Lambda adds no CORS headers, so return explicit origins and test your
OPTIONS preflights. X-Forwarded-Host is passed through without validation, so don't build redirects, absolute URLs or host-based authorization from it unless a front door you control sets it.Request-size limits and abuse protection on
ApplicationManaged endpoints are on you, along with a sensible rateLimit. And as always, load secrets at runtime instead of baking them into the zip.Series: Serverless (2 articles)
- 2Getting Started on AWS Lambda Web Functions This article
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article