A month ago, AWS released Lambda Web Functions, a new Lambda primitive designed for web applications. My first reaction was “wasn't Lambda already good at web applications?”. Well, yes, but only because we learned how to make it work.

The old Lambda functions are event-driven. An event fires off, and that launches an invocation of a Lambda function. We've managed to turn HTTP requests into events via API Gateway or the more recent Function URLs, and it works. We've also normalized paying for the overhead of a Lambda invocation for every request, and we've grown accustomed to basically designing, operating and paying for an Event-Driven Architecture when what we needed was a fully serverless web server. Or we didn't and we went with the severless-but-not-fully-managed alternative of ECS on Fargate, or the truly fully managed options like Amplify which are nice but end up adding a different developer experience.

Lambda Web Functions is now the serverless, fully managed option. Ideal for those of us who've accepted the accidental complexity of Lambda functions (now called Lambda Event Functions) so long ago that we've forgotten that you don't need events for a web server.

This article is sponsored by Inngest

Don't go back to the starting line

Inngest makes your code durable by default.

Simply wrapping your workflows in Inngest’s step.run provides automatic step-level checkpointing & retries, with zero infra to manage.

What is a Lambda Web Function?

A Lambda Web Function runs a Node.js HTTP server on AWS Lambda. You deploy the code and dependencies as a ZIP. When someone calls the AWS-managed HTTPS URL, Lambda forwards the request to your server. Your app listens on a port and uses its usual routes and middleware.

An environment is a running copy of your application. Its routes, middleware and connection pools live together, and the same process can start handling another request while the first is waiting on a database. Each environment handles multiple requests concurrently, up to a configurable limit. Lambda takes care of starting more environments as traffic grows and removing them as traffic wanes, including scaling to zero. Each environment runs Node.js 24 on Linux Arm64, with 2 vCPUs and 2 GB of memory. The deployment ZIP can be up to 512 MB unzipped.

How to deploy an Express app as a Lambda Web Function

Let's deploy a small Express app. We'll start in a new directory, with Express as our only dependency:

mkdir express-web-demo
cd express-web-demo
npm init -y
npm install express

Our server goes in index.js:

const express = require('express');
const app = express();

app.get('/health', (req, res) => {
  res.json({ ok: true });
});

const [host, port] = (process.env.AWS_LAMBDA_HTTP_ENDPOINT ?? '127.0.0.1:3000').split(':');
app.listen(Number(port), host);

The Lambda-specific part is the listen address. Lambda gives us AWS_LAMBDA_HTTP_ENDPOINT, which contains the host and port where it expects to find our server. Locally, that variable isn't set, so we'll use 127.0.0.1:3000. Everything else is ordinary Express code.

In our computer, we can start the with node index.js, then call curl -fsS http://127.0.0.1:3000/health from another terminal. That should give us {"ok":true}. Now we know it works, so let's deploy it.

We'll use the AWS CLI, and there's aws lambda-web deploy help if you need help with the commands. For this example, we'll let the CLI create the deployment bucket and execution role, so you'll need AWS credentials with the following permissions:

  • To create the Web Function and check its status: lambda:CreateWebFunction, lambda:GetWebFunction, lambda:GetWebFunctionRevision, and lambda:GetWebFunctionEndpoint.

  • To deploy changes afterwards: lambda:CreateWebFunctionRevision, lambda:ListWebFunctionRevisions, and lambda:ListWebFunctionEndpoints. The CLI reads the existing configuration before creating the next revision.

  • For the deployment bucket: s3:CreateBucket, s3:PutBucketPolicy, s3:PutBucketVersioning, and s3:ListBucket.

  • For the deployment packages in that bucket: s3:PutObject, s3:GetObject, and s3:GetObjectVersion.

  • For the execution role: iam:GetRole, iam:CreateRole, iam:PutRolePolicy, and iam:PassRole on the role we'll use for this app.

Those are the permissions for our deployment credentials. Separately, there's an execution role, which is what Lambda assumes when running our app. The CLI automatically gives that role logs:CreateLogGroup, logs:CreateLogStream, and logs:PutLogEvents, so our server can write to CloudWatch Logs. If the app later needs access to a DynamoDB table or something like that, we'd add that access to its execution role.

With that in place, we're ready to deploy from the app directory. For an app with native dependencies, we'd install and build those on Linux Arm64 before packaging:

npm ci --omit=dev
aws lambda-web deploy --name express-web-demo --code . --region us-east-1 --create

npm ci --omit=dev installs the production dependencies from our lockfile. The deploy command packages the app directory, including those dependencies, as a ZIP and uploads it to a versioned S3 bucket. It creates the Lambda Web Function, its first code revision, and an HTTPS endpoint named dev in us-east-1. The --create flag skips the confirmation prompt.

The endpoint uses ApplicationManaged authentication by default. That means authentication belongs to our app, which is not a problem right now because our /health route doesn't require any. Anyone with the URL can call it, which is fine for this sample response. We'll get into authenticated applications a bit further below.

The CLI prints the HTTPS URL when deployment finishes. You can test the app by replacing ENDPOINT_URL with that URL in the following command:

curl -fsS 'ENDPOINT_URL/health'

You should get {"ok":true} again, but this time the request is going to Lambda. Lambda starts our server, supplies its listen address, and forwards the request to Express. It also handles HTTPS for us, the server inside the environment uses plain HTTP.

What happens when requests overlap

On standard on-demand Event Lambda functions, each execution environment runs one invocation at a time, even with the Web Adapter. It can be reused afterwards, but while a request waits for a database that environment stays occupied. With a Web Function, Lambda can send the next request to the server process we already started.

At a steady 40 requests per second with an average response time of 200 ms, about eight requests are in flight on average. Eight simultaneous Event invocations need eight environments. A Web Function can serve several of those requests from one environment, using the same connection pool while different requests wait for their queries.

The other shared resource is the Node event loop, and remember here that Node uses a single thread. A long synchronous calculation will block the other requests in that process, so stuff like long exports or image conversions should be moved to a worker (likely running on a Lambda Event Function). Anything that waits for external events, such as a database query, should use async. Anything that actually processes things will still use the main thread, even if you use async.

Lambda adjusts how many requests each copy accepts based on its resource use. You can also set a limit with maxConcurrencyPerEnvironment. Also, scaling isn't instantaneous: bursts can queue during startup or receive HTTP 429 when an endpoint or account limit is reached.

What Lambda Web Functions mean to your AWS bill

With Lambda Event Functions we've been paying for the time an Event invocation spends doing work, and for the time it spends waiting. Standard on-demand Event Lambda bills for configured memory over elapsed billed time, including a database query or an upstream API call. Web Functions split that into active vCPU time and the memory each environment consumes for as long as it is alive. Waiting uses little CPU, but the process keeps using memory, including between requests. You're also still charged for requests.

In us-east-1, the Arm list prices for Web Functions are:

  • Requests: $0.30 per million.

  • CPU: $0.0000276944 per active vCPU-second, or about $0.10 per vCPU-hour.

  • Memory: $0.0000036667 per GB-second consumed, or about $0.0132 per GB-hour.

Compute is billed at 1 ms granularity. Memory follows consumption, you aren't automatically billed for the environment's full 2 GB capacity.

The ability to serve multiple requests from a single environment doesn't make Web Functions automatically cheaper for every workload. Standard on-demand Event Functions charge $0.20 per million requests and $0.0000133334 per GB-second at the same Arm rates. A 128 MB Event Function taking 20 ms of billed time costs about $0.233 per million calls through a Function URL. That's below Web Functions' request charge alone.

Let's consider 10 million requests over 30 days. Suppose our Event Function needs 256 MB and 250 ms of billed time per request. For Web, assume total metered CPU averages 20 ms of vCPU time per request, including startup and runtime overhead, and each live environment consumes 0.5 GB. Using direct Lambda endpoints and excluding data transfer, logs and downstream services, we'd pay:

Deployment

Requests

Duration or CPU

Memory

Total

Standard on-demand Event

$2.00

$8.33

Included in duration

$10.33

Web

$3.00

$5.54

$1.19

$9.73

Cold Starts on Lambda Event and Web Functions

If the first request after idle time can't wait for startup (which is practically always the case for web servers), we'd need some hot capacity. Unfortunately, Web Functions have no minimum-environment setting (which I think is a big limitation). On Lambda Event Functions we'd allocate Provisioned Concurrency and ensure we always have a minimum number of execution environments running. Each environment still handles one invocation at a time though, so while Lambda can scale out a lot with on-demand capacity, you will still experience cold starts.

There's also SnapStart. Lambda initializes the function when we publish a version, takes a snapshot of its initialized state, and restores that snapshot when it needs a new environment. We still have restore latency, so this doesn't give us the same ready capacity as Provisioned Concurrency, but it significantly improves cold starts. Managed Node ZIP functions don't support SnapStart, but Node Event Functions packaged as container images can use it. We'd need to check that restoring the initialized state won't leave us with duplicate IDs, expired credentials, invalid connections or that kind of stuff. Lambda Web Functions can't use SnapStart, unfortunately.

Web Functions reduce the need for new environments by sharing warm servers across concurrent requests. While traffic is flowing, Lambda also starts environments ahead of demand to leave some spare capacity, though the AWS documentation isn't very specific on how this works.

Lambda chooses each environment's actual concurrency limit from resource use, with a default of 64 requests per environment. maxConcurrencyPerEnvironment serves as a maximum for that limit, with an allowed range of 1-128. Lambda can admit fewer requests and start another environment before reaching that configured cap, but there's no configuration for when it should do that, it just… does it. New functions start conservatively, then learn from traffic. This means the first load test can start more environments than later runs, something to account for if benchmarking cold start latency.

We do have some early measurements. In an October 2026 LambdaBench investigation, a minimal Node Web Function in Frankfurt took roughly 380 ms median time to first byte on a cold request, versus 225-240 ms for the Event handler called through Invoke. Both used Arm and 2 GB, with different CPU allocations. The measurements used existing TLS connections from a same-Region caller and only 6-15 cold samples per configuration, so there might be some statistical artifacts in the results.

The same investigation served 64 simultaneous one-second I/O requests across 32 environments on a new endpoint, and then served the same traffic from one environment after it had learned from traffic patterns. Idle environments were mostly removed 5 to 7 minutes after their last request (which is great because you won't get a cold start, but remember you still pay for those idle environments).

Exposing a Lambda Web Function

There are several ways to expose the endpoint of a Lambda Web Function. I showed you the straightforward one already: just use the public URL you get when you create a Web Function, with the default ApplicationManaged authentication mode. Let's take a look at the other options.

Public URL with ApplicationManaged or IAM authentication

For a public app, we can let the server's usual authentication middleware decide who gets in. That's what an ApplicationManaged Web Function endpoint gives us: the request reaches Express, and Express checks the session cookie or token. This sounds like the one that needs the most work, because we're writing the authentication part ourselves instead of letting AWS manage it for us. But keep in mind this is also the most portable option, and it's what we've been doing for web servers since before I got my first job some 14 years ago.

If the caller is another AWS workload and we want Lambda to check its permission before running the app, we'd choose IamAuth. The caller needs lambda:InvokeWebFunctionEndpoint permissions on the endpoint. For a direct regional request, it signs with SigV4 for service lambda and includes X-Amz-Content-SHA256, even on a GET. That authenticates the AWS caller before the request reaches our code. If Express also needs to authenticate an application user, we'd send that credential separately, because Lambda removes the SigV4 Authorization header before forwarding the request.

There are also endpoints where a web server doesn't make much sense. If all a webhook does is verify a signature and put a message on SQS, I'd just write an Event handler with a Function URL. An AWS-signed sender can use the Function URL's AWS_IAM mode. An external webhook provider usually needs AuthType: NONE, leaving the sender's signature check to our handler.

Custom domain through CloudFront

If we want to use our own domain, we'd put CloudFront in front of the Web Function. The client connects to our domain on CloudFront, and CloudFront connects to the Lambda endpoint over HTTPS. We'd add the domain as an alternate name on the distribution, attach an ACM certificate, and point our DNS at the distribution.

You then need to set IamAuth on the Lambda Web Function endpoint, and CloudFront origin access control (OAC) so callers can't bypass the distribution with an unsigned request to the Lambda URL. For our regional endpoint, the OAC settings are:

{
  "OriginAccessControlOriginType": "lambda",
  "SigningProtocol": "sigv4",
  "SigningBehavior": "always-amz-auth"
}

CloudFront signs the origin request, and Lambda checks that signature before passing the request to our server. By setting "SigningBehavior": "always-amz-auth", CloudFront puts its signature in a separate header and preserves the viewer's Authorization header intact. We also need to include Authorization in the API behavior's cache policy so CloudFront forwards it, including on GET requests.

As a reminder, a Lambda Event Function URL can also sit behind CloudFront OAC using AWS_IAM authentication.

API Gateway

Sometimes we want a user's token checked before the app runs at all. Say POST /exports requires the exports:write scope. With API Gateway HTTP API, the JWT authorizer can check the token's issuer, audience and route scope, and reject a read-only caller before invoking Lambda. I'd choose an Event Function behind that integration when this is a requirement.

We could put an HTTP proxy integration in front of an ApplicationManaged Web Function, but callers could still send requests directly to its Lambda URL, since there's no way to disable the Lambda URL of a Web Function, and API Gateway can't have an AWS identity like CloudFront's OAC to authenticate against a Web Function using IamAuth.

For the Event Function, we'd use a Lambda proxy integration instead of an HTTP proxy integration. Gateway would get lambda:InvokeFunction permission restricted to the API execution ARN, and we'd leave the standalone Function URL off.

Lambda Web Functions in a VPC

Having an endpoint such as an RDS database in a private subnet in a VPC we manage still works with Web Functions. The client's request can arrive at a public Lambda endpoint, while the server's database connection goes through an egress network connector into our VPC. What we need to do is create a connector with aws lambda-core create-network-connector, selecting subnets in at least two Availability Zones and security groups that can reach RDS. The RDS security group would allow connections from the connector's security group. Once the connector is ACTIVE, we'd put its version-qualified ARN in the revision's serviceConfig.egressNetworkConnectorArn. The connector must be in the same account and Region as the Web Function.

Adding the connector replaces the default outbound path, much like “putting” a Lambda Events Function in a VPC removes its internet connectivity. If your app also calls public IPv4 APIs, you'll need an internet route through NAT from those subnets. The public Lambda URL still works though.

What I'd choose for a new app

For a new public Node.js API that fits the runtime and can start on demand, I'd deploy a Web Function and let the app's middleware handle credentials. Routes that share permissions can live in the same server, while queue-triggered background work runs on Event Functions. I'd test the first request after idle time and then send overlapping requests, watching latency and database connections as Lambda adds environments.

If I had a requirement for a custom runtime, another language or framework other than Node.js, a container image, more than 2 GB of memory, or Gateway authorization before the app runs, I would instead use an Event Function with the Web Adapter, like we've been doing for years.

And that's it for part 1. I'll publish part 2 in 2 weeks.

Did you like this issue?

Login or Subscribe to participate

Recommended for you

View all
caret-right