Some notes on Lambda Web Functions
AWS launched Lambda Web Functions a few hours ago. You give it a Node.js app that listens on a port and Lambda runs it as-is behind an HTTPS URL, with many concurrent requests per execution environment and billing for the CPU time you actually use rather than wall-clock. There's no handler and no JSON event. I deployed one, poked around inside it, and here are my notes. tl;dr: It feels like taking the best of AWS Lambda, Google Cloud Run and Cloudflare Workers and stuffing them together in a cohesive service.
I got a bit too excited about this one and wrote far more than I normally would,
so I've split it into three posts. This one is the platform: what's in the
execution environment, how it behaves, how it logs, and how it works behind
CloudFront, i.e. nerdy details that probably don't matter to many people.
The second is about running Go on it via execve(),
because "Node.js only" turns out to be a soft limit. The third is about
using a Web Function as an SQS consumer via EventBridge Pipes
and whether that's cheaper than an event function.
History¶
I have a soft spot for this launch because I've been waiting for it since December 2020. That's when I wrote serverlessish, a small Go program that let you run "any web server serverlessly in AWS Lambda" by sitting between the Lambda runtime API and your real server running as a child process. At pretty much the same time, apparentorder independently built re:Web, which did the same thing with more polish. In April 2021 Harold Sun at AWS started what became the Lambda Web Adapter, which has since become the officially-blessed way to do this (and credits both projects in its README, which is nice). But it was always a hack: one request at a time per environment, billing by wall-clock duration while your app waited on IO (DynamoDB, S3, etc), a 6MB payload limit because the request body had to be smuggled inside a JSON event, and headers mangled by whichever event source you used. The docs now say, verbatim, "You don't need the AWS Lambda Web Adapter". Five and a bit years from community hack to first-party feature is longer than I would have liked, but better late than never!
The shape of it¶
A Web Function is a container for two kinds of child resources:
Revisions are immutable snapshots of code + config (timeout, env vars,
execution role, per-environment concurrency). Code comes from a versioned S3
bucket, and the bucket policy has to grant lambda.amazonaws.com access. You
can't upload a zip directly like you can with event functions.
Endpoints are HTTPS domains that route to one or two revisions by weight.
An endpoint is HomeRegion, PerRegion or MultiRegion - the last one gives
you a <id>.lambda-web.global.on.aws URL with latency-based routing. Auth is
either ApplicationManaged (i.e. none, bring your own) or IamAuth (SigV4,
or SigV4a for multi-region). CloudFront OAC supports the latter natively.
At launch it's nodejs24.x only (unless you use the approach mentioned in my other post),
arm64 only, 2 vCPU and 2GB only. There are only a few launch regions, but they
don't really matter - you can configure a function in us-east-1 and deploy
it to ap-southeast-2 (for example). You pay $0.30 per million requests,
$0.0000276944 per vCPU-second of CPU your code actually burns, and $0.0000036667
per GB-second of memory consumed (not configured) over the environment's lifetime.
Those unit prices are identical to Lambda MicroVMs, which made me
assume they share infrastructure. More on that below.
Deploying with SAM¶
The launch post uses aws lambda-web deploy, which is a lovely one-shot command
that creates a bucket, a role, a function, a revision and an endpoint for you. It
even generates an AGENTS.md in the starter project, because it is 2026. But the
idea of not using infrastructure-as-code genuinely disturbs me, so I used SAM.
There's a new AWS::Serverless::WebFunction resource that expands into
AWS::Lambda::WebFunction, AWS::Lambda::WebFunctionRevision,
AWS::Lambda::WebFunctionEndpoint and an IAM role:
Transform: AWS::Serverless-2016-10-31
Parameters:
CodeBucket: { Type: String }
CodeKey: { Type: String }
CodeVersion: { Type: String }
Resources:
Probe:
Type: AWS::Serverless::WebFunction
Properties:
Runtime: nodejs24.x
AuthType: ApplicationManaged
CodeUri:
Bucket: !Ref CodeBucket
Key: !Ref CodeKey
Version: !Ref CodeVersion
Timeout: 120
MaxConcurrencyPerEnvironment: 4
EnvironmentVariables:
HELLO: world
ScalingConfig:
MaxEnvironments: 4
Outputs:
Domain:
Value: !GetAtt ProbeEndpoint.DomainName
A few things to note:
The SAM CLI doesn't package Web Function code yet (as of 1.168.0). sam build
and sam deploy won't zip a local CodeUri for you, so I had to wrap it in a shell script
that zips, runs aws s3api put-object to capture the VersionId, and
passes it in as a parameter. Hopefully that's fixed soon.
CloudFormation replaces the revision on every code change. Revisions are
immutable, so an update creates a new revision, repoints the endpoint, and then
calls DeleteWebFunctionRevision on the old one (I watched it do so in
CloudTrail). That means the native RevisionWeights canary feature is awkward to
drive from CloudFormation: by the time the stack update finishes, the old
revision is gone. CodeDeploy-style traffic shifting for Web Functions would be
nice (like SAM supports for regular Lambda functions).
Your local AWS CLI is probably too old. Homebrew's awscli 2.37.12 has a
lambda-web subcommand with exactly one operation (get-web-account-settings) and
help text that says the commands are "experimental and for internal AWS use
only. They are not yet available to external customers". 2.37.13 has the full
set. I love a good pre-announcement leak.
New accounts get smaller quotas than the docs say. The scaling docs
say 2,000 vCPUs and 100,000 RPS per region. get-web-account-settings on my account says 200
vCPUs (i.e. 100 environments) and 10,000 RPS.
Inside the execution environment¶
I deployed a dependency-free index.js with routes that dump everything I could
think of, plus a token-protected /exec route for ad-hoc poking. Here's what I
found.
The environment looks like a Lambda MicroVM. PID 1 is
/opt/amazon/asc/worker/bin/init --domain runtime --filesystem {"root":"/runtime",...},
the kernel is 6.18.48-109.150.amzn2023.aarch64.microvm, the CPU is a Graviton4,
and the root filesystem is a slimmed-down version of
the regular nodejs:24 Lambda image - /lambda-entrypoint.sh is still sitting
in the root (unused?), and dnf, rpm, curl, bash and openssl are all
present. The VM had been up for 5 hours when my first request arrived, which
suggests VMs are pre-booted and sit in a pool before a customer's code is
attached. My Node process ran as sbx_user1051 (uid 993) in a user namespace
with no capabilities, so more similar to Lambda functions there.
/var/runtime/index.js is the new runtime interface client, and it's
surprisingly small (27KB of bundled TypeScript). It does the following:
POST http://169.254.100.1:9001/2025-09-01/loadto the runtime API (body{}, no auth), which returns your env vars as a list ofKEY=valuestrings plus aruntimeConfiguration. ItObject.assigns the env vars intoprocess.env, copies the log level intoAWS_LAMBDA_LOG_LEVEL, and stashes the whole response onglobalThis.awsLambdaInternal.loadResponse.- Monkey-patches
console.*to emit JSON lines withtimestamp,level,requestId,endpointNameandregion. Request ID comes from anAsyncLocalStoragethat it populates by monkey-patchinghttp.Server.prototype.emitto read thelambda-web-request-idheader. So your logs are correlated to requests for free, as long as you use the built-inhttpmodule (more on logs later). import()s your/var/task/index.js(orAWS_LAMBDA_NODEJS_ENTRYPOINT) with a 9 second timeout.- Polls the port in
AWS_LAMBDA_HTTP_ENDPOINTevery 5ms until something accepts a TCP connection. POST /2025-09-01/ready. Done. It never talks to the runtime API again.
There is also a hidden --otel flag that sets OTEL_EXPORTER_OTLP_ENDPOINT to
http://otlp.localdomain:4318. That hostname doesn't resolve today, so I assume
it's for something coming later.
There is no extensions API. /2020-01-01/extension/register and
/2022-07-01/telemetry both 404, as does the classic /runtime/invocation/next.
If your observability vendor's Lambda story is "install our extension", that story
doesn't apply here. You'll be shipping telemetry from inside your process, like
a normal web server. I think that's fine, but it's a change. Maybe it'll change later?
The environment variables are sparse. Here's the complete list:
| Name | Value |
|---|---|
AWS_EXECUTION_ENV |
AWS_Lambda_nodejs24.x-lite |
AWS_LAMBDA_INITIALIZATION_TYPE |
native-http |
AWS_LAMBDA_HTTP_ENDPOINT |
0.0.0.0:3000 |
AWS_LAMBDA_RUNTIME_API |
169.254.100.1:9001 |
AWS_LAMBDA_METADATA_API |
169.254.100.1:9001 |
AWS_LAMBDA_METADATA_TOKEN |
a UUID |
AWS_LAMBDA_FUNCTION_MEMORY_SIZE |
2048 |
AWS_LAMBDA_LOG_LEVEL |
INFO |
AWS_ACCOUNT_ID |
your account ID (new!) |
AWS_REGION, AWS_DEFAULT_REGION |
us-east-1 |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN |
role credentials |
NODE_PATH |
/var/task |
PWD |
/var/task |
SHLVL |
0 |
Compare that to the list for event functions I wrote down a
few years ago: no PATH, no HOME, no LANG, no TZ, no _HANDLER, no
LAMBDA_TASK_ROOT, no AWS_LAMBDA_FUNCTION_NAME, no
AWS_LAMBDA_LOG_GROUP_NAME. AWS_ACCOUNT_ID being there is a nice touch and
I'd like it in event functions too.
But the function name and revision are in the process, just not in the
environment. The /load response that the RIC stashes on
globalThis.awsLambdaInternal.loadResponse looks like this (env var values
elided):
{
"applicationConfiguration": ["EXEC_TOKEN=...", "HELLO=world"],
"runtimeConfiguration": {
"functionName": "Probe-ec9257c0",
"functionVersion": "rev-vs8yl",
"functionMemorySizeMB": 2048,
"logConfig": {
"level": "INFO",
"groupName": "/aws/lambda/web/Probe-ec9257c0"
}
}
}
So the revision ID travels under the functionVersion name, and the log
group name is right there too. None of this comes from the sandbox: nothing
on the filesystem or in PID 1's command line mentions the function or
revision, and the only place it enters is that one HTTP response from
169.254.100.1, which is the host side of the MicroVM boundary. The RIC
doesn't expose any of it, so you have to read globalThis.awsLambdaInternal
and accept that it's named Internal for some reason. POST /load works strictly
once per environment - calling it again from my app got an empty 500 - so you can't
just re-fetch it.
Behaviour¶
Lambda adds x-forwarded-for, x-forwarded-proto, x-forwarded-port,
lambda-web-request-id and lambda-web-endpoint-name to inbound requests. It
strips client-supplied lambda-web-*, X-Real-IP, Upgrade and
Connection. It passes through Forwarded, X-Amzn-Trace-Id and Cookie
untouched. If you send two Authorization headers, one AWS4-... and one
Bearer ..., the SigV4-looking one is removed and the bearer one is delivered -
this is how CloudFront OAC and app-level auth coexist.
Response headers are barely filtered. My app set lambda-web-custom,
x-forwarded-for: spoof, x-amzn-requestid: spoof, server: my-server and
via: 1.1 me and every one of them reached the client.
Error types are in a response header. Request timeout is
504 with Runtime.RequestTimeout (at exactly timeoutSeconds + 1). A process
that exits mid-request is 503 with Runtime.OriginRequestError, and the next
request transparently gets a fresh environment. Throttling is 429 with
Platform.EndpointMaxEnvThrottle. The HTML error bodies include the request ID.
SSE streaming works with no configuration. I got my first event ~1 second after connecting and the rest at the cadence I sent them. Request bodies stream too, with a documented half-duplex caveat: read the whole body before you write the response. WebSockets don't.
Cold starts are small. platform.initStart to platform.initReport was 253ms
(130ms of CPU) for my dependency-free app, and a cold request took roughly half
a second longer than a warm one, so that's about the environment provisioning
overhead on top of init.
The per-environment concurrency limit starts low and learns. This was the
most surprising thing I found. maxConcurrencyPerEnvironment defaults to 64, but
that's a ceiling on a dynamic limit that Lambda manages. With maxEnvironments:
4, my first burst of 40 concurrent 3-second requests resulted in 4
environments, each handling 2-4 requests at once, and 30 of the 40 requests
being rejected with 429 Platform.EndpointMaxEnvThrottle. The next identical
burst had 10 rejected. The one after that had none, with ~10-14 per environment.
After a few more bursts it was comfortably doing 40 per environment. I may have
done something wrong here; scaling is normally the thing Lambda is good at.
UPDATE: If you leave the max environments unconfigured, there are no such
429 errors. The platform instead overprovisions when it detects a burst of traffic.
I think this is how I will leave it in the future.
You get one second after SIGTERM. The docs say so too. My
handler logged "still alive 500ms after SIGTERM" but not the 1500ms message, and
the platform logged platform.spinDownReport with Runtime.SpinDownTimeout
after 1059ms. Going by the invokeReport lifetimes, every environment I
created lived between 4.5 and 10.5 minutes; one that was started for a burst
but never received a request was reaped after 5m08s, so the idle timeout
looks like about five minutes. You're billed for memory over that whole
lifetime, so AWS is motivated to keep it short, and so are you.
Logs¶
The logs are good. Every environment gets a stream named
YYYY/MM/DD/<function>[<revision>]<instance>, like regular functions. Lambda emits platform.initStart,
platform.initReport, platform.invokeStart, then one platform.invokeSummary
per minute with that minute's request count, peak concurrency, CPU
milliseconds and max memory, and finally platform.invokeReport and
platform.spinDownStart/spinDownReport. The invokeReport for one of my
environments said 78 requests, 810ms of CPU and 186MB over a 403 second
lifetime. By my arithmetic that's about $0.0003. The lambda-web-logstream
response header tells you which stream served a given request, which is super
useful and I wish regular Lambda functions had it too.
There are two log groups, and which one you want depends on the endpoint
type. Environments write to /aws/lambda/web/<function>.local in whichever
region they're running in, and Lambda asynchronously copies those events into
/aws/lambda/web/<function> in the home region, which the logging docs
call the centralised group. That's true even for a HomeRegion endpoint: my
single-region functions all have both groups in us-east-1, and the .local
one is populated first. For the home region's own traffic the events reached
.local 3.5-9 seconds after they were logged and the centralised group 21-31
seconds after, with the platform.invokeSummary lines trailing the rest. It's
a bit weird that they're duplicated, but at least it's consistent.
To see the multi-region version I added a MultiRegion endpoint to the probe
function spanning us-east-1 and ap-southeast-2. It took about 90 seconds to go
Active in both regions. From Melbourne the global domain is a CNAME to
<id>.735.lambda-web.ap-southeast-2.on.aws, which is in turn a CNAME to
something under ap-southeast-2.prod.frontend.lambda-lite.aws.dev (the same
"lite" as in AWS_EXECUTION_ENV). The request ran in Sydney with
AWS_REGION=ap-southeast-2, a /aws/lambda/web/<function>.local group
appeared in ap-southeast-2 - and only .local; there's no centralised group
outside the home region - and 52 seconds later the same lines turned up in
us-east-1. In the centralised group the stream name grows a
-<account>-<region> suffix, so a Sydney environment's stream is
2026/10/13/Probe-ec9257c0[rev-7ekjf]d358a4...-887559014507-ap-southeast-2,
which is why the docs
tell you to search the centralised group by stream name prefix when you've
got a lambda-web-logstream header in hand. I wonder whether it would have run
in Melbourne if I'd enabled the region. I was too lazy to find out today.
The cost consequence is that you're storing every log line at least twice.
Nothing is removed from .local after it's centralised, both groups are
created with never-expire retention, and the copy is free but the storage
isn't (the monitoring docs
are explicit about this). So a MultiRegion endpoint across N regions is
N+1 copies until you put a retention policy on each .local group in each
region plus the centralised one.
Earlier I mentioned that Lambda monkey-patches the logger. This is the result:
console.log("plain string log", req.url);
console.log({ msg: "object log", path: req.url, n: 42 });
console.warn("a warning with %s", "printf formatting");
console.error(new Error("an error object"));
console.debug("a debug line");
setTimeout(() => console.log("logged from a timer 50ms later"), 50);
CloudWatch gets this (one line each, pretty-printed here):
{"timestamp":"2026-10-13T00:17:02.203Z","level":"INFO",
"message":"plain string log /log?x=1",
"requestId":"7FLJIHSO26GZ2DCJESP6MM62VE","endpointName":"prod","region":"us-east-1"}
{"timestamp":"2026-10-13T00:17:02.203Z","level":"INFO",
"message":{"msg":"object log","path":"/log?x=1","n":42},
"requestId":"7FLJIHSO26GZ2DCJESP6MM62VE","endpointName":"prod","region":"us-east-1"}
{"timestamp":"2026-10-13T00:17:02.204Z","level":"WARN",
"message":"a warning with printf formatting",
"requestId":"7FLJIHSO26GZ2DCJESP6MM62VE","endpointName":"prod","region":"us-east-1"}
{"timestamp":"2026-10-13T00:17:02.204Z","level":"ERROR",
"message":{"errorType":"Error","errorMessage":"an error object",
"stackTrace":["Error: an error object",
" at Server.<anonymous> (/var/task/index.js:72:23)",
" at Server.emit (node:events:514:28)",
" at file:///var/runtime/index.js:641:35",
" at AsyncLocalStorage.run (node:internal/async_local_storage/async_context_frame:65:14)",
" at _InvokeStoreMulti.run (file:///var/runtime/index.js:582:21)",
" at http.Server.emit (file:///var/runtime/index.js:636:28)",
"..."]},
"requestId":"7FLJIHSO26GZ2DCJESP6MM62VE","endpointName":"prod","region":"us-east-1"}
{"timestamp":"2026-10-13T00:17:02.254Z","level":"INFO",
"message":"logged from a timer 50ms later",
"requestId":"7FLJIHSO26GZ2DCJESP6MM62VE","endpointName":"prod","region":"us-east-1"}
I like that they've injected the request ID and region. That will be super useful for querying later in CWL Insights.
CloudFront OAC does the whole HTTP¶
The docs' recommendation for custom domains
is an IamAuth endpoint behind CloudFront, with an origin access control of type lambda and
signing behaviour always-amz-auth. It's not GET-only like Lambda function
URLs.
There's no Web Function-specific policy resource, but the existing
AWS::Lambda::ResourcePolicy
accepts a web function ARN:
HelloPolicy:
Type: AWS::Lambda::ResourcePolicy
Properties:
ResourceArn: !Ref Hello # the web-function/... ARN
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: lambda:InvokeWebFunctionEndpoint
Resource: !Ref HelloEndpoint
Condition:
StringEquals:
aws:SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution}
I sank too much time into a doc mistake: the syntax block on that docs page says the property is
FunctionResourceArn, but CloudFormation rejects that with
Unsupported property [FunctionResourceArn]. The example further down the
same page uses ResourceArn, and that's the one that works. (I've filed a doc bug report).
This works, and it works with 50MB request payloads (the biggest I tested). So it's a real web server! But realistically, I think payloads that big are typically something that could be sent directly to S3 anyway.
Feature requests for AWS¶
sam build/sam deploysupport for packaging Web Function code.- A CodeDeploy-style traffic shifting integration, or at least a CloudFormation option to retain the previous revision.
AWS_LAMBDA_FUNCTION_NAMEand the revision ID as environment variables.- More in the metadata endpoint: instance ID, revision, function ARN.
- An
--otelthat actually goes somewhere. - Official support for other runtimes! I write Go - or at least my agents do.
The contract is already "listen on a port", so a
provided.al2023equivalent is mostly a matter of AWS deciding to ship it.
Overall¶
This is the Lambda I wanted in 2020. The programming model is "write a web server" and the billing model stops punishing what our code spends most of its time doing (waiting for someone else to respond). The rough edges are mostly typical AWS launching an MVP (admittedly a very good one): Node-only, fixed 2 vCPU/2GB, no SAM CLI packaging, etc. I can't wait to see how it evolves over time.