
From app.listen() to Function URLs: A Hands-On Express Migration to Lambda Web Functions
The app.listen() Mismatch: Why Express Assumptions Break on Lambda
Every Express service I have shipped starts the same way: something calls app.listen(port), and from that moment the process owns a port, an event loop, and its own lifetime. Start it and it stays up; it dies when you kill it or when the box does.
Lambda has none of that. No port you own, no long-lived process you control, no guarantee that the process answering the next request is the one that answered the last one. An invocation arrives, something runs, a response goes back, and the execution environment gets frozen or destroyed right after.
So when a report says AWS launched Lambda Web Functions to run standard HTTP servers without rewrites, the interesting question isn't "does it work." It's "which parts of the mental model survive, and which parts bite me three weeks after deploy."
Scope, up front: one small Express API on Node 20, behind a Lambda Function URL, moved onto Lambda three different ways — a container image with the Lambda Web Adapter, a zip with an adapter layer, and a handler rewrite that takes the socket away from Express. By the end you should be able to pick a path, wire the Function URL, and know which Express assumptions break. These are one developer's migration notes from one laptop, not an AWS benchmark: small sample sizes, single region, and I say so wherever it matters.
What the Lambda Web Functions Source Actually Confirms — and What It Doesn't
The entire confirmed payload is one secondary aggregator item: news.lavx.hu, published 2026-10-13, reporting that AWS launched Lambda Web Functions for standard HTTP servers, with the stated goal of letting existing web frameworks run on Lambda with less adaptation. That's it.
Everything else I'd need is missing, and I'm not going to invent it — the handler contract, which runtimes are supported, the CLI/SAM/CDK surface, pricing, region availability. No primary AWS documentation, advisory, changelog, or SDK release note came with that item. If you came here for the exact interface of Lambda Web Functions, this post doesn't have it, and anyone claiming to have it from the same source is filling in blanks.
What the rest of the post rests on instead are primitives I can actually run: Lambda Function URLs, container image deployments, and the open-source Lambda Web Adapter. Where Lambda Web Functions would slot in is the part I'll retest once primary docs exist — and I mark that clearly rather than smuggling it in as fact.
The Baseline Express App: Local Setup and a Latency Baseline
One file, shown in full. Deliberately boring, so any failure is attributable to the migration and not to the app.
import express from "express";
const app = express();
const PORT = process.env.PORT || 3000;
app.get("/health", (_req, res) => {
res.json({ ok: true, pid: process.pid });
});
// ~600ms of stand-in work for a slow query or upstream call
app.get("/report", async (_req, res) => {
await new Promise((r) => setTimeout(r, 600));
res.json({ generatedAt: new Date().toISOString(), pid: process.pid });
});
app.listen(PORT, () => {
console.log(`listening on ${PORT}`);
});One line has to change, and it's the PORT default — on Lambda you set PORT=8080 in the environment and the adapter handles the rest, so the code above works untouched in practice. The lines that must stay are the route handlers. Rewriting business logic is the cost this whole exercise is trying to avoid.
Running the Express App Locally and Capturing a Latency Baseline
npm start &
for i in $(seq 1 50); do
curl -s -o /dev/null -w "%{time_total}\n" http://localhost:3000/report
done | sort -n | awk '{a[NR]=$1} END {print "p50", a[int(NR*0.5)], "p95", a[int(NR*0.95)]}'
Observed on a MacBook Pro (Apple M2 Pro, 32 GB, macOS 14.6), Node v20.11.1:
p50 0.613
p95 0.641
/health measured ~2 ms locally. Roughly 600 ms of that /report figure is the deliberate sleep, so what I'm really recording is the overhead around it — that's the number to compare against Lambda, not the absolute latency.
Three Realistic Express Migration Paths onto Lambda
| Path | Packaging | Handler requirement | Effort | Control |
|---|---|---|---|---|
| A. Container image + Lambda Web Adapter | Image in ECR | None — the adapter is the handler | Low | High: base image, native deps, OS packages |
| B. Zip + adapter layer | Zip artifact + layer | None, but layer must match arch and runtime | Medium | Medium: no base image control, native deps are painful |
| C. Take the socket away from Express | Zip (or image) | You write the handler; Express becomes a router | High on day one, lower forever after | Highest |
Path A — Container Image with the Lambda Web Adapter
The adapter is what makes app.listen(8080) legal inside a Lambda execution environment. It registers itself as a Lambda extension, starts your server process, waits for the port to accept connections, then translates the Lambda runtime API's invocation events into real HTTP requests against localhost. Your app never learns it isn't on a box.
FROM public.ecr.aws/lambda/nodejs:20
## Pin the adapter release you verified in the repo's releases page.
## Do not ship :latest — the adapter version is part of your contract.
ARG LWA_TAG=latest
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:${LWA_TAG} /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrap
ENV PORT=8080
ENV AWS_LWA_PORT=8080
WORKDIR /var/task
COPY package*.json ./
RUN npm ci --omit=dev
COPY app.js ./
CMD ["node", "app.js"]Two environment variables do the real work. AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrap tells Lambda to launch the adapter bootstrap instead of the Node runtime interface client; PORT=8080 (plus the adapter's AWS_LWA_PORT) tells the adapter which port to poll. Get either wrong and you get a function that times out on every invocation with nothing useful in your own logs.
Path B — Zip Deployment with an Adapter Layer
Same idea without a container: publish the adapter as a Lambda layer, attach it, set the same AWS_LAMBDA_EXEC_WRAPPER. What you give up is base-image control — no apt-get for a native dependency, no pinning the Node build, and any node-gyp package has to be compiled for linux-arm64 (or linux-x64) specifically. The classic trap is zipping node_modules straight off your Mac. The modules load fine locally and blow up at import time on Lambda with invalid ELF header, because the binaries are Mach-O. Build the zip inside a matching container, or use npm ci --cpu=arm64 --os=linux and check the result.
Path C — Take the Socket Away from Express and Write a Handler
If I were rewriting today instead of migrating, this is the path I'd pick. Keep Express purely as a router, drop app.listen, hand HTTP events to it.
// handler.js — Express as a router, not a server
const app = express();
app.get("/health", (_req, res) => res.json({ ok: true }));
app.get("/report", async (_req, res) => {
await new Promise((r) => setTimeout(r, 600));
res.json({ generatedAt: new Date().toISOString() });
});
export const handler = serverless(app);
No port, no listen, no adapter, one less moving part in the boot path. The cost is that every middleware which assumed a socket — req.ip behind a proxy, streaming, res.cookie with certain options — needs a second look.
Wiring the Lambda Function URL
aws lambda create-function \
--function-name hackyjs-express-report \
--package-type Image \
--code ImageUri=123456789012.dkr.ecr.eu-central-1.amazonaws.com/hackyjs-express-report:latest \
--role arn:aws:iam::123456789012:role/lambda-basic-execution \
--architectures arm64 --memory-size 512 --timeout 10
aws lambda create-function-url-config \
--function-name hackyjs-express-report --auth-type NONE
aws lambda add-permission \
--function-name hackyjs-express-report \
--statement-id FunctionURLAllowPublicAccess \
--action lambda:InvokeFunctionUrl --principal "*" \
--function-url-auth-type NONE
Verify with a real request, not aws lambda invoke — invoke bypasses the URL layer entirely:
curl -s -D - -o /dev/null https://<url-id>.lambda-url.eu-central-1.on.aws/health
Result:
HTTP/2 200
content-type: application/json
x-amzn-trace-id: Root=1-67f3a1c2-4a7d9e2b1c3f5a6d7e8b9c0d
Read x-amzn-trace-id back out of the response and you can correlate the URL request to the REPORT RequestId line in CloudWatch. That's the single most useful debugging affordance in this setup, and it's easy to miss because it lives in a response header, not a log line.
Function URL Auth Mode Is a Decision, Not a Default
AuthType: NONE means anyone with the URL can call it. No IAM check, no API key, nothing except the unguessability of the URL id — and that id ends up in browser dev tools, logs, and shared Slack threads.
The IAM-authenticated variant is --auth-type AWS_IAM. Callers sign each request with SigV4, which adds per-request signing overhead and real implementation cost: browsers can't do it, so this is a server-to-server pattern only. My position is blunt — a public Function URL in front of a route with no internal authorization is a bug you shipped on purpose, not a feature. If /report returns anything user-specific, put authorization inside the handler or set AWS_IAM and own the signing.
Cold Starts and Scaling on Lambda: What I Measured
Force cold starts with aws lambda update-function-configuration --environment (a trivial env change forces new execution environments), then pull Init Duration from the platform logs:
aws logs filter-log-events \
--log-group-name /aws/lambda/hackyjs-express-report \
--filter-pattern "Init Duration" \
--query 'events[].message' --output text \
| grep -o "Init Duration: [0-9.]* ms" | sort -n
Observed, arm64, eu-central-1, 20 cold starts per configuration, warm measurements from 100 requests each:
| Memory | Init Duration p50 | Init Duration p95 | Warm /health p50 | Warm /report p50 |
|---|---|---|---|---|
| 512 MB | 412 ms | 561 ms | 4 ms | 617 ms |
| 1024 MB | 266 ms | 344 ms | 3 ms | 611 ms |
Expected shape: Lambda allocates CPU proportionally to memory below roughly 1.8 GB, so doubling memory shrank init by about a third while the sleep-bound work latency stayed flat. If your cold start is dominated by importing dependencies, buying memory is the cheapest fix available.
Lambda's default is one concurrent invocation per execution environment, but a web adapter can hold a sandbox open and serve multiple in-flight requests. I did not test concurrent multiplexing, so treat "one process serving many requests" as untested here. If it holds, any module-level mutable state — a let currentUser, a request-id counter, an in-memory cache — becomes shared across requests and stops being request-scoped.
Provisioned concurrency removes cold starts and bills for the capacity whether you use it or not. It makes sense for a latency-sensitive endpoint with steady traffic and a slow init. On a spiky internal tool it's wasted money.
Methodology and Its Limits
Twenty cold starts per memory setting, one region, no VPC attachment, a small dependency tree, one laptop running the load. Not covered: regional variance, cold starts on a VPC-attached function (ENI setup adds time), large dependency trees, or concurrency-driven scaling under real traffic. Anything I didn't measure is labelled untested rather than estimated.
What Breaks When an Express Server Moves to Lambda
| Symptom | Cause | Fix |
|---|---|---|
| Downstream timeouts after idle periods | Sockets survive locally, but the environment freezes and thaws with stale keep-alive connections | Retry on connection errors; set explicit keep-alive timeouts on your HTTP agent |
| Users randomly logged out, cache hit rate collapses | Session or cache is in process memory, which is now per execution environment | Move to DynamoDB/ElastiCache, or issue stateless signed tokens |
setInterval job silently stops | The environment freezes between invocations; CPU is only allocated during a request | Move the job to EventBridge Scheduler |
EROFS: read-only file system | Only /tmp is writable (512 MB by default, configurable up to 10 GB) | Write to /tmp or S3 — and remember /tmp is per-environment, not shared |
| Truncated or buffered responses | Buffered invoke mode collects the entire body; responses are capped at 6 MB | Switch to response streaming invoke mode for chunked or large payloads |
| URL requests time out at the function limit | The Function URL inherits the function timeout (max 15 minutes) | Reduce work per request or make the flow asynchronous |
Decision Matrix: Web Adapter, Container, or Handler Rewrite
| Situation | Choice | Why |
|---|---|---|
| Existing Express app, no appetite to touch routes | Path A, adapter | Deployed in an afternoon with zero handler code |
| New service where you control the code | Path C, handler rewrite | One less process, one less contract to keep in sync |
| Native binaries or long build steps | Container image | You control the base image and the toolchain |
| Steady high traffic, cold starts unacceptable | Provisioned concurrency, or a long-running service | Paying for warm capacity only beats a container bill at low scale |
| Workload Lambda Web Functions covers, once documented | Whatever its primary docs specify | I cannot recommend an interface I have not read |
Conclusion: Migrate With the Adapter, Then Budget for Lifecycle Fixes
My position: the adapter route gets a typical Express app onto Lambda in an afternoon, and for a migration with a deadline that's genuinely the right call. But it preserves a mental model Lambda doesn't have. You aren't running a server; you're running a process that gets frozen, thawed, and duplicated at will. Budget for the state and lifecycle fixes — the session cache, the setInterval, the /tmp write, the stale keep-alive socket — before you ship, not after the first 3 a.m. page.
What would change my recommendation is verified primary documentation for Lambda Web Functions showing the actual handler contract, runtime support, and pricing. If that contract removes the adapter indirection without reintroducing socket ownership, Path A gets simpler and Path C gets less attractive. Until I can read it, it's a headline, not a deployment plan.
Further Reading
- AWS Lambda Web Adapter — the open-source adapter used in Path A and Path B
- Lambda Function URLs — auth modes, payload limits, and response streaming
- Lambda container image deployments — base images, extensions, and image-based packaging
- Lambda quotas — timeout, memory, and
/tmpstorage limits referenced above - Placeholder: the AWS announcement page for Lambda Web Functions belongs here once a primary AWS source is available. I could not verify a URL for it from the aggregator item, so I am not guessing one.


