AWS Builder Center
Lambda Web Functions - Because Your Express App Deserves Better Than a Lambda Adapter

Lambda Web Functions - Because Your Express App Deserves Better Than a Lambda Adapter

Lambda Web Functions lets you deploy your port-listening Node.js web apps to Lambda with zero code changes. Multi-concurrent execution, active CPU billing, and global deployment in one command. The adapter era is over.

Developer Advocate | Building communities, Mentoring people for better tomorrow!
It's usual afternoon. You've got a clean Express API. Routes are tight, middleware is organized, tests pass. You run node index.js, hit [IP_ADDRESS]:3000, everything works.
Then someone asks: "Can we put this on Lambda? We want to scale to zero."
Your heart sinks a little.
Not because Lambda is bad. Lambda is great for event-driven stuff. But deploying a web app to Lambda has always meant paying what I call the adapter tax.

The Adapter Tax

You know the ritual:
  1. Install serverless-express or @vendia/serverless-express
  2. Rip out app.listen(), replace it with exports.handler
  3. Wrap your app in a handler adapter
  4. Set up API Gateway in front of it
  5. Debug binary media type issues
  6. Wonder why file uploads keep breaking
  7. Accept that every HTTP request gets its own execution environment
  8. Watch cold starts pile up during traffic bursts
  9. Pay for wall-clock time, including the 150ms your code sat idle while DynamoDB fetched data
You end up with Frankenstein's deployment. An HTTP server pretending to be an event handler, fronted by an API Gateway that converts HTTP to events and back to HTTP. It works. But your app was never meant to run this way.
As of 2026-10-12, this whole dance is over.
Before vs After: Web Apps on Lambda

Lambda Web Functions: Just Deploy Your Server

AWS released Lambda Web Functions. The pitch: your code listens on a port, handles HTTP requests directly, Lambda takes care of the rest.
No adapters. No handler rewrites. No API Gateway. The same Express/Hono/Fastify/Next.js app you run locally.
Three things make this different from the Lambda you know:
Multi-concurrent execution. A single execution environment handles up to 128 simultaneous requests. Traffic spikes? Warm environments absorb the burst instead of spinning up a new environment per request. Cold starts drop hard.
Active CPU billing. You pay for CPU only when your code is actually on the processor. When a request waits on DynamoDB, S3, or any downstream API, the CPU meter stops. Memory is still billed for the life of the environment, but that's the cheap part.
Multi-region in one command. Deploy once, replicate to the regions you pick, every request routes to the nearest one. One URL, global reach.

The Three Flavors of Lambda

Lambda now has three form factors. Same Firecracker microVM isolation underneath, different programming models on top:
Event FunctionsWeb Functions
Execution modelhandler(event, context)Port-listening HTTP server
Concurrency1 request per sandbox1-128 per env (default 64)
BillingWall-clock GB-seconds (CPU + memory combined)Active CPU + memory (no idle CPU cost)
Best forEvent processing, queues, streams, schedulesWeb apps, APIs, SSR, streaming
StatefulnessStateless (stateful via Durable Functions, SnapStart)Stateless (sandbox stays warm ~5 min)
Sandbox lifecycleService-managedService-managed
RuntimesPython, Node.js, .NET, Rust, Go, OCINode.js 24 (Python Q4 2026)
Duration15 min (1 year with Durable)15 min
ScalingAutomatic, per-invocationAutomatic, per-invocation
Side note: existing Lambda functions are now called "Event Functions" in the console. Name change only. Zero code changes for anything you already have deployed.
A real-world app might use all three. Event Functions for processing SQS messages. Web Functions for the API layer and SSR. MicroVMs for running untrusted user code. Same account, same operational model.

Let's Build Something You'd Actually Use

Forget Hello World. We're building a URL Health Checker API. A service that checks whether URLs are alive and measures response time.
Why this example? Because it shows exactly why Web Functions exists:
  • It's I/O heavy. Each health check is ~5ms of CPU work and ~200-500ms of waiting for the target URL to respond. With active CPU billing, you pay for the 5ms, not the 500ms.
  • Batch checks show concurrency. Check 10 URLs at once, and one warm Web Function environment handles all 10 outbound requests at the same time. With Event Functions, you'd spin up 10 separate environments.
  • No external dependencies. Just Express and native fetch(). Works locally, deploys with one command.

The Code

One file. One dependency.
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// index.js
import express from 'express';

const app = express();
app.use(express.json());

// Bind to Lambda's endpoint, or [IP_ADDRESS] for dev
const endpoint = process.env.AWS_LAMBDA_HTTP_ENDPOINT || '[IP_ADDRESS]';
const [host, port] = endpoint.split(':');

// Helper: check a single URL
async function checkUrl(url) {
const start = Date.now();
try {
const response = await fetch(url, {
method: 'HEAD',
signal: AbortSignal.timeout(5000),
redirect: 'follow'
});
const elapsed = Date.now() - start;
return {
url,
status: response.status,
ok: response.ok,
responseTime: `${elapsed}ms`,
checkedAt: new Date().toISOString()
};
} catch (err) {
const elapsed = Date.now() - start;
return {
url,
status: null,
ok: false,
error: err.name === 'TimeoutError' ? 'Timeout (5s)' : err.message,
responseTime: `${elapsed}ms`,
checkedAt: new Date().toISOString()
};
}
}

// GET /health
app.get('/health', (req, res) => {
res.json({
service: 'url-health-checker',
status: 'ok',
runtime: `Node.js ${process.version}`,
region: process.env.AWS_REGION || 'local',
timestamp: new Date().toISOString()
});
});
One thing to notice: AWS_LAMBDA_HTTP_ENDPOINT. Lambda sets this env variable with the host and port your server must bind to. That's the only Lambda-specific detail in the app. Locally, it falls back to [IP_ADDRESS]. Your code never knows the difference.

Run Locally

1
node index.js
1
2
🏥 URL Health Checker running on http://[IP_ADDRESS]
Environment: Local
Standard Node.js. No emulators, no SAM local, no Docker.

Test It

Single URL check:
1
2
3
curl -X POST http://[IP_ADDRESS]:3000/check \
-H "Content-Type: application/json" \
-d '{"url": "https://aws.amazon.com"}' | jq
1
2
3
4
5
6
7
{
"url": "https://aws.amazon.com",
"status": 200,
"ok": true,
"responseTime": "243ms",
"checkedAt": "2026-10-13T18:30:05.000Z"
}
Batch check (this is the one that matters):
1
2
3
4
5
6
7
8
9
10
11
curl -X POST http://[IP_ADDRESS]:3000/check/batch \
-H "Content-Type: application/json" \
-d '{
"urls": [
"https://github.com",
"https://aws.amazon.com",
"https://registry.npmjs.org",
"https://httpbin.org/status/200",
"https://thisdomaindoesnotexist12345.com"
]
}'
| jq
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
29
30
31
32
33
34
35
36
37
38
39
{
"total": 5,
"healthy": 4,
"unhealthy": 1,
"totalTime": "487ms",
"results": [
{
"url": "https://github.com",
"status": 200,
"ok": true,
"responseTime": "312ms"
},
{
"url": "https://aws.amazon.com",
"status": 200,
"ok": true,
"responseTime": "245ms"
},
{
"url": "https://registry.npmjs.org",
"status": 200,
"ok": true,
"responseTime": "189ms"
},
{
"url": "https://httpbin.org/status/200",
"status": 200,
"ok": true,
"responseTime": "456ms"
},
{
"url": "https://thisdomaindoesnotexist12345.com",
"status": null,
"ok": false,
"error": "fetch failed",
"responseTime": "487ms"
}
]
}
Look at totalTime: 487ms. That's roughly the same as the slowest single URL. Not the sum of all five. All five requests fired in parallel with Promise.all(). The wall clock only ticked as long as the slowest response.
On Web Functions, you'd pay CPU for about 8ms of actual JavaScript work across all five checks. The 480ms of I/O wait? Memory cost only.
On Event Functions, you'd need 5 separate environments (4 cold starts), each billed for the full wall-clock time. That's about 1,500ms of billed compute for the same work.

Deploy With One Command

1
aws lambda-web deploy --name url-health-checker --code .
1
2
3
4
5
6
7
8
9
10
Packaging url-health-checker...
Creating web function url-health-checker...
Publishing revision rev-001...
Attaching endpoint...

✅ Deployed successfully!
Function: url-health-checker
Revision: rev-001
Endpoint: https://<id>.<shard>.lambda-web.us-east-1.on.aws
Runtime: nodejs24.x (arm64)
That's it. HTTPS endpoint, TLS included, no API Gateway, no config files. Express app is live.

Hit the Live Endpoint

1
2
3
ENDPOINT="https://<id>.<shard>.lambda-web.us-east-1.on.aws"

curl $ENDPOINT/health | jq
1
2
3
4
5
6
7
{
"service": "url-health-checker",
"status": "ok",
"runtime": "Node.js v24.0.0",
"region": "us-east-1",
"timestamp": "2026-10-13T18:45:00.000Z"
}
Same code. Same routes. region now says us-east-1 instead of local. That's the only difference.

Go Global

1
2
aws lambda-web deploy --name url-health-checker --code . \
--regions us-east-1,eu-west-1,ap-northeast-1
One global URL. User in Tokyo hits ap-northeast-1. London hits eu-west-1. New York hits us-east-1. No Route53 config, no CloudFront to set up.

The Batch Check: Why This Matters

When you hit /check/batch with 5 URLs, the two models handle it very differently.
Multi-Concurrent Execution: Batch Check with 5 URLs
Event Functions: 5 separate execution environments spin up (4 cold starts). Each runs one request. Each billed for the full wall-clock time including I/O wait.
Web Functions: 1 warm environment handles all 5 requests at the same time. While Request 1 waits for github.com to respond, Requests 2-5 can use the CPU. One environment, zero cold starts.

Active CPU Billing: Pay for Work, Not Waiting

Let's break down a single health check request:
  1. Request arrives, Express parses it. 3ms of CPU.
  2. Build fetch request. 2ms of CPU.
  3. Outbound HTTP, waiting for target. 300ms of I/O wait.
  4. Parse response, build JSON. 3ms of CPU.
Total time: ~308ms | CPU work: ~8ms | I/O wait: ~300ms
Active CPU Billing: What You Actually Pay For
Event Functions: billed for all 308ms. Web Functions: billed for ~8ms of CPU. The I/O wait costs only memory.
Now multiply by the batch endpoint. 5 concurrent checks. Event Functions: 5 x 308ms = 1,540ms billed across 5 environments. Web Functions: ~8ms CPU in 1 environment. Do that math at scale and the difference adds up quick.
The billing model has four parts:
  • Requests per request served
  • Active CPU only when your code is on the processor
  • Memory consumed over the life of each execution environment
  • Data transfer standard AWS rates

Canary Deployments Built In

You don't flip all traffic to new code at once. Web Functions has traffic splitting as a first-class concept:
1
2
3
4
5
6
7
8
aws lambda-web update-web-function-endpoint \
--function-name url-health-checker \
--endpoint-name dev \
--auto-deployment-mode Disabled \
--revision-weights '[
{"revisionId": "rev-001", "weight": 90},
{"revisionId": "rev-002", "weight": 10}
]'
90% to the current revision, 10% to the new one. Watch your metrics. If things look good, slide the weight. No extra tooling, no deployment pipeline to build.
A revision is an immutable snapshot of your code and config. An endpoint routes traffic to one or two revisions. You can run multiple endpoints (dev, staging, prod) pointing to different revisions of the same function.

CloudFront Integration

Most web apps serve a mix of static and dynamic content. Web Functions works with CloudFront:
  • Static assets (CSS, JS bundles, images): served from CloudFront cache
  • Dynamic requests (API calls, SSR pages): routed to your Web Function
Set a Web Function endpoint as the origin for your CloudFront distribution. With origin access control (OAC), CloudFront signs requests to the origin, and the endpoint only allows your distribution to call it. Your endpoint stays private, CloudFront handles public traffic.
Same private-origin pattern CloudFront already uses for S3. Now it works for Lambda Web Functions too.

Another Example: AI Streaming Endpoint

An endpoint that calls Amazon Bedrock and streams tokens back as they arrive:
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// stream-endpoint.js
import express from 'express';
import { BedrockRuntimeClient, InvokeModelWithResponseStreamCommand }
from '@aws-sdk/client-bedrock-runtime';

const app = express();
const [host, port] = process.env.AWS_LAMBDA_HTTP_ENDPOINT.split(':');
const bedrock = new BedrockRuntimeClient();

app.use(express.json());

app.post('/api/chat', async (req, res) => {
const { prompt } = req.body;

res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');

const command = new InvokeModelWithResponseStreamCommand({
modelId: 'anthropic.claude-3-sonnet-20240229-v1:0',
contentType: 'application/json',
body: JSON.stringify({
anthropic_version: 'bedrock-2023-05-31',
messages: [{ role: 'user', content: prompt }],
max_tokens: 1024
})
});

const response = await bedrock.send(command);

for await (const event of response.body) {
if (event.chunk) {
const chunk = JSON.parse(
new TextDecoder().decode(event.chunk.bytes)
);
if (chunk.type === 'content_block_delta') {
res.write(`data: ${JSON.stringify({ text: chunk.delta.text })}\n\n`);
}
}
}

res.write('data: [DONE]\n\n');
res.end();
});

app.listen(parseInt(port), host);
Why this fits Web Functions well: streaming is native. Chunked transfer-encoding and SSE work out of the box. You pay CPU only during token generation, not during the gaps between tokens. And the 15-minute timeout gives plenty of room for long generations.

Things That Will Bite You

Node.js only at launch. Runtime is nodejs24.x. Python support is planned for Q4 2026. Java, .NET, Go, Rust? Not yet.
arm64 only. Runs on Graviton. If you have native npm packages (sharp, canvas, bcrypt), make sure they have arm64 builds. Install on an arm64 CI runner or use --platform linux/arm64 in Docker.
No handler exports. If you have serverless-express or any adapter in your code, delete it. The app must call app.listen(). An adapter that never binds a port will fail the readiness check and your environment won't start.
Thread safety matters. One environment serves many concurrent requests in a single Node.js process. Module-level mutable state (like let currentUser = ...) is a data leak bug. Keep per-request data in the request scope. Share only immutable config and SDK clients at module level.
Environments are stateless. Don't write session data to /tmp and expect it on the next request. It might land on a different environment, or a different region on a multi-region endpoint. Use DynamoDB, S3, or ElastiCache for state.
Public by default. An ApplicationManaged endpoint is reachable by anyone with the URL. No AWS-layer auth, no built-in WAF. Your app must do its own authentication. Use IamAuth for internal service-to-service calls, or put CloudFront + WAF in front for public ones.

Service Limits

ResourceLimit
Deployment package512 MB
vCPU per environment2
Memory per environment2 GB
Concurrency per environment1-128 requests (default 64)
Request timeout3-900 seconds (default 30s)
Endpoints per function10
Revisions retained50
/tmp storage512 MB
Init timeout10 seconds
Per-endpoint max rate limit10,000 RPS

IaC Support

For production, Web Functions works with:
  • AWS CloudFormation (AWS::Lambda::WebFunction resource)
  • AWS CDK (L2 constructs available)
  • AWS SAM (SAM resources)
  • Terraform (HCL support)
The CLI is great for getting started. For repeatable infra, use whatever IaC your team already runs.

Available Regions

Lambda Web Functions launched on 2026-10-12 in:
  • US East (N. Virginia), us-east-1
  • US East (Ohio), us-east-2
  • US West (Oregon), us-west-2
  • Asia Pacific (Tokyo), ap-northeast-1
  • Europe (Ireland), eu-west-1
VPC connectivity is supported through egress network connectors for private resources like RDS and ElastiCache.

What Changed

Lambda was built for events in 2014. Great for event-driven architectures. But the web runs on HTTP, and wrapping HTTP in event handlers was always a workaround.
That workaround lasted about a decade. It did the job. But now your Express app can stay an Express app. Your Hono router stays a Hono router. You deploy it, Lambda runs it, traffic routes globally, you pay for actual CPU work.
No more adapters. No more conversion layers. Just your server.

Get Started

1
2
3
4
5
6
# Got code? Point at your directory.
aws lambda-web deploy --name my-api --code ./my-app

# Go global.
aws lambda-web deploy --name my-api --code ./my-app \
--regions us-east-1,eu-west-1,ap-northeast-1
Happy shipping!
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article