A UK-first decision framework with whole-architecture costs, an Express pilot and measurable rollback gates.

Your Node.js API already works behind API Gateway and Lambda. Now AWS offers a direct HTTP model, and removing a service sounds attractive. But a smaller architecture diagram does not automatically mean a cheaper or safer API. Lambda Web Functions launched on 12 October 2026 with Ireland among its regions, not London. Start with that constraint, then work through authentication, shared concurrency and complete running costs before committing engineering time to a migration.
Start with the regional gate, not the launch announcement
Use a keep, pilot or migrate decision matrix
Price the whole API path for three traffic patterns
Move Express without rewriting your business logic
Prove isolation and performance before moving customers
Roll out one route and keep two rollback paths
The first question in Lambda Web Functions vs API Gateway is whether the new model can run where your contracts require. Launch excitement is not a reason to replace API Gateway.
Use Cloudline’s Five-Gate API Migration Test: region, identity, isolation, connections and economics. A failed mandatory gate means keep; an unresolved measurable gate means pilot. Passing every gate makes migration a candidate, not an obligation.
The 12 October 2026 launch announcement names five launch regions: Northern Virginia, Ohio, Oregon, Tokyo and Ireland. Ireland is eu-west-1; London is eu-west-2. The announcement establishes the launch footprint: a sample configuration containing London does not establish availability there.
For a UK-only execution requirement, Ireland is a no-go. Serving UK customers does not itself establish that requirement. Check your architecture and contractual commitments; this is not a blanket claim about GDPR.
Next, establish what changes operationally. The Web Functions developer guide describes a different execution model, not merely another endpoint:
| Dimension | Web Functions | Event Functions |
|---|---|---|
| Application interface | Port-listening HTTP server | Event handler |
| Per-environment execution | Concurrent HTTP requests | One invocation at a time |
| Compute billing basis | Active CPU plus consumed memory over the environment lifetime | Configured memory multiplied by invocation duration |
| Runtime model | Node.js-only at launch | Existing event-driven Lambda model |
Existing Lambda functions were renamed Event Functions, without requiring redeployment. Classic Lambda Function URLs expose HTTP endpoints for that event-driven model. Lambda Web Adapter bridges HTTP applications into Lambda’s invocation model; it is not Web Functions.
That distinction matters for Lambda Web Functions pricing and migration: concurrent requests can share process state and connection pools. Identity, isolation, connections and economics therefore need separate evidence, not assumptions carried over from today’s handlers. If you are reviewing the baseline, revisit Node.js and Lambda workload design before comparing deployment models.

| Decision | Keep | Pilot | Migrate candidate |
|---|---|---|---|
| Regional requirements | London execution is mandatory. | Ireland only where requirements permit. | Execution region and data flows approved. |
| Authentication | Gateway policies have no funded replacement. | Application JWT validation. | Issuer, audience, expiry, scopes and tenant authorisation pass contract tests. |
| Concurrency safety | Unsafe request-global state remains. | Request-local state and AsyncLocalStorage tested under overlapping requests. | No cross-request leakage under load. |
| Database connections | No explicit connection budget. | Bounded pool; measure queueing. | Budget includes deployment overlap; queueing meets latency targets. |
| Custom domains and WAF | Gateway controls remain necessary. | CloudFront, origin authentication and optional WAF. | Domain, policy and bypass tests pass. |
| Workload economics | No demonstrated whole-path saving. | Replay representative traffic; include operational costs. | Measured cost and latency justify ownership. |
Web endpoints offer ApplicationManaged or IamAuth, not an automatic transplant of gateway authorisers. Authentication plumbing is not tenant authorisation.
Budget connections before increasing concurrency: 20 environments × pool size 5 = 100 potential connections. Reserve additional capacity while old and new deployments overlap.
Outbound VPC connectivity uses an egress network connector; it does not create private ingress. It supports HomeRegion endpoints, not multi-region endpoints. A London database needs a deliberate regional networking decision: existing Lambda VPC settings do not transfer unchanged.
The gateway-free custom-domain design requires CloudFront: client → CloudFront and optional WAF → IAM-protected Web Function → database. Configure OAC with origin type lambda and signing behaviour always-amz-auth. Forward the viewer’s bearer token separately; restrict the resource policy to the intended distribution. CloudFront authenticating the origin is not end-user authorisation.
Compare the gateway product you actually run. REST API usage plans, API keys and direct WAF support are not HTTP API features. The buyer’s question in Lambda Web Functions vs API Gateway is which ingress responsibilities your team wants to own, not merely which endpoint to replace.
Removing API Gateway does not remove the responsibilities it was carrying.
Share this quote

Lambda Web Functions pricing needs a whole-path calculation. Removing API Gateway does not remove delivery, logging or network costs. Follow workload-led cost optimisation, not headline request prices.
The 20 October 2026 Northern Virginia Arm control uses these Lambda rates: Web requests $0.30/million, active CPU $0.0000276944/vCPU-second and consumed memory $0.0000036667/GB-second. Event Functions cost $0.20/million requests and $0.0000133334/GB-second. Web memory accrues throughout environment lifetime, including gaps between requests.
The US API Gateway initial-tier inputs are $1/million for HTTP API and $3.50/million for REST API. Neither is an Ireland rate.
Cweb = requests + active-vCPU-seconds
+ sum(consumed-memory GB-seconds)
+ front door + logs + transfer + dependency-network changes
Cevent = gateway requests + Lambda requests + billed GB-seconds
+ the same applicable extras
These are model inputs, not performance benchmarks. Different CPU allocations make equal elapsed durations an assumption to test.
| Workload | Request measurements | HTTP + Event | REST + Event | Web |
|---|---|---|---|---|
| Sporadic: 100,000 | 200 ms elapsed; 10 ms CPU; 512 MB Event; 0.25 GB Web | $0.51 | $0.76 | $0.32–$2.69 |
| Steady I/O: 10 million | 200 ms elapsed; 10 ms CPU; 512 MB Event; 0.25 GB Web | $50.89 | $75.89 | $33.71 |
| CPU-heavy: 10 million | 200 ms elapsed and CPU; 1,769 MB Event; 0.5 GB Web | $83.63 | $108.63 | $88.70 |
Steady cases assume one Web environment for 2,592,000 seconds. Sporadic lifetime ranges from one aggregate environment-hour to the full month: no idle-retention promise. Each additional full-month environment adds $2.38 at 0.25 GB, or $4.75 at 0.5 GB.
Assumptions beside these totals: exhausted free allowances, no caching, negligible request bodies, no WAF, existing DNS, no new NAT or proxy, and no incremental paid metrics.
All totals include $2.556/million shared marginal overhead: European CloudFront HTTPS $1.20, 10 GB response transfer × $0.085, and US-example logs: 1 GB ingestion × $0.50 plus 0.2 GB retained × $0.03/month. This is a mixed-location control, not a deployable Ireland estimate.
For eu-west-1, supply explicit regional rates for every meter below. Record currency, rate date and architecture alongside them. US arithmetic does not establish UK migration economics.
This Python calculator accepts regional rates without US defaults. Request rates and free allowances use millions; other meters use seconds or GB-seconds. Set environment_gbs to the sum across all environments; Event allocation is MB ÷ 1,024.
def price(n, elapsed, cpu, event_gb, environment_gbs,
card, extras, free=None):
# card: region, currency, rate_date, architecture, rates
for field in ("region", "currency", "rate_date", "architecture"):
assert card[field]
rates, free = card["rates"], free or {}
def bill(key, quantity):
return max(0, quantity - free.get(key, 0)) * rates[key]
web = (bill("web_requests", n / 1e6)
+ bill("active_cpu", n * cpu)
+ bill("web_memory", environment_gbs))
event = (bill("event_requests", n / 1e6)
+ bill("event_gbs", n * elapsed * event_gb))
return {
path: base + sum(extras[path].values())
for path, base in {
"web": web,
"http": event + bill("http_requests", n / 1e6),
"rest": event + bill("rest_requests", n / 1e6)
}.items()
}
Run free-tier-adjusted variants through free, and WAF-enabled variants through extras. Each path’s extras must include applicable front-door charges, application/system logs, gateway access logs, transfer and database/network changes. Do not count origin-to-CloudFront traffic again as internet delivery.
A cheaper function is not necessarily a cheaper API.
Share this quote

Keep one Express application and two entry points. A Lambda Web Functions migration should change transport, not business rules. Rollback becomes risky when fixes reach one implementation but not the other.
Export your existing application from app.js. Leave routes, middleware and business services there. Keep the existing Event Function adapter and its dependency available.
// app.js: minimal illustration; retain your existing routes here.
const express = require('express');
const app = express();
app.get('/healthz', (_req, res) => res.json({ ok: true }));
module.exports = app;
// event.js: existing Event Function entry point.
// This example assumes your project already uses this adapter.
exports.handler = require('@codegenie/serverless-express')({
app: require('./app')
});
// index.js: Web Function entry point.
const app = require('./app');
const [host, port] = (
process.env.AWS_LAMBDA_HTTP_ENDPOINT || '127.0.0.1:3000'
).split(':');
app.listen(Number(port), host);
Use Node.js 24, deployment credentials, appropriate IAM permissions and an AWS CLI exposing lambda-web. This pilot contains no customer data. Its /probe route is instrumentation, not part of the production API.
mkdir cl-api-pilot && cd cl-api-pilot
npm init -y
npm install express
git add package.json package-lock.json
git commit -m "Pin pilot dependencies"
npm ci --omit=dev
Save this complete, standalone pilot as index.js:
const express = require('express');
const { randomUUID } = require('node:crypto');
const app = express();
const instance = randomUUID();
let active = 0;
app.get('/healthz', (_req, res) => res.json({ ok: true }));
app.get('/probe', async (req, res) => {
const marker = String(req.query.marker || '');
active++;
const overlapping = active;
try {
await new Promise(resolve => setTimeout(resolve, 100));
res.json({ marker, instance, overlapping });
} finally {
active--;
}
});
const [host, port] = (
process.env.AWS_LAMBDA_HTTP_ENDPOINT || '127.0.0.1:3000'
).split(':');
app.listen(Number(port), host);
active is deliberately process-wide instrumentation; marker is request-local. Lambda supplies the listen address through AWS_LAMBDA_HTTP_ENDPOINT. The deployment entry file is index.js, unless AWS_LAMBDA_NODEJS_ENTRYPOINT overrides it.
node index.js
# In another terminal:
curl http://127.0.0.1:3000/healthz
aws lambda-web deploy --name cl-api-pilot --code . --region eu-west-1 --auth-type IamAuth \
--scaling-config '{"maxEnvironments":2}' \
--throttle-config '{"rateLimit":100}'
The helper creates a dev endpoint with LatestRevision behaviour. These caps are pilot settings, not production sizing.
Download the synthetic pilot file bundle (JSON). Extract it into an empty directory, run sh setup.sh, then commit the generated lockfile:
node -e 'const fs=require("node:fs"); for(const [name,text] of Object.entries(JSON.parse(fs.readFileSync("pilot-bundle.json","utf8")))) fs.writeFileSync(name,text)'
Retain the full configuration when creating each subsequent revision; do not submit only changed fields. This example fixes runtime, concurrency, role, timeout and environment explicitly:
{
"runtime": "nodejs24.x",
"maxConcurrencyPerEnvironment": 16,
"role": "arn:aws:iam::123456789012:role/cl-api-pilot-execution",
"timeout": 10,
"environment": {
"variables": {
"NODE_ENV": "production",
"AWS_LAMBDA_NODEJS_ENTRYPOINT": "index.js"
}
}
}
For CloudFront, disable caching for personalised responses. Preserve the bearer token separately before OAC signs the origin request; otherwise IAM signing replaces Authorization.
// CloudFront viewer-request function: associate with the API behaviour.
function handler(event) {
var request = event.request;
delete request.headers['x-app-authorization'];
if (request.headers.authorization) {
request.headers['x-app-authorization'] = {
value: request.headers.authorization.value
};
}
return request;
}
Use these CloudFormation fragments within the distribution configuration. PilotEndpoint, PilotOAC and BearerForwarder reference resources in the same stack.
{
"PilotOAC": {
"Type": "AWS::CloudFront::OriginAccessControl",
"Properties": {
"OriginAccessControlConfig": {
"Name": "cl-api-pilot",
"OriginAccessControlOriginType": "lambda",
"SigningBehavior": "always",
"SigningProtocol": "sigv4"
}
}
},
"Origin": {
"Id": "pilot",
"DomainName": { "Fn::GetAtt": ["PilotEndpoint", "DomainName"] },
"OriginAccessControlId": { "Ref": "PilotOAC" },
"CustomOriginConfig": {
"OriginProtocolPolicy": "https-only",
"HTTPSPort": 443,
"OriginSSLProtocols": ["TLSv1.2"]
}
},
"CacheBehaviour": {
"TargetOriginId": "pilot",
"ViewerProtocolPolicy": "https-only",
"AllowedMethods": ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
"CachedMethods": ["GET", "HEAD"],
"CachePolicyId": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad",
"OriginRequestPolicyId": "b689b0a8-53d0-40ab-baf2-68738e2966ac",
"FunctionAssociations": [{
"EventType": "viewer-request",
"FunctionARN": { "Fn::GetAtt": ["BearerForwarder", "FunctionARN"] }
}]
}
}
The policy IDs select CachingDisabled and AllViewerExceptHostHeader. Authenticate x-app-authorization in the application. Restrict origin access to the distribution; do not make the endpoint public.
{
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Condition": {
"StringEquals": {
"AWS:SourceArn": {
"Fn::Sub": "arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${ApiDistribution}"
}
}
}
}
Merge that principal and condition into the endpoint’s invocation permission, retaining its required action and resource. Keep this a phased migration with a reversible boundary, not an excuse to rewrite the API.

Concurrent async handlers share a Node.js process. Request-local state must stay request-local; synchronous CPU-heavy work blocks everybody’s event loop. A quiet staging environment will not expose either problem.
Save these Node.js 20+ scripts, or download isolation.mjs and fixture.mjs. The fixture reports process identity and in-flight requests. Keep it confined to the synthetic pilot.
// isolation.mjs
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
const base = process.env.BASE_URL;
assert.ok(base, 'Set BASE_URL');
let inconclusive = false;
for (let round = 1; round <= 10; round++) {
const markers = Array.from({ length: 64 }, () => randomUUID());
const results = await Promise.all(markers.map(async marker => {
const response = await fetch(new URL('/probe', base), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ marker }),
signal: AbortSignal.timeout(15000)
});
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.marker, marker, 'Identity mismatch');
assert.equal(typeof body.instance, 'string');
assert.ok(body.instance.length);
assert.ok(Number.isInteger(body.overlapping));
return body;
}));
const counts = new Map();
for (const r of results) {
counts.set(r.instance, (counts.get(r.instance) || 0) + 1);
}
const overlap = results.some(r =>
counts.get(r.instance) > 1 && r.overlapping > 1
);
console.log({ round, overlap, instances: counts.size });
if (!overlap) inconclusive = true;
}
if (inconclusive) {
console.error('INCONCLUSIVE: at least one round lacked observed overlap');
process.exitCode = 2;
} else {
console.log('PASS: observed isolation only');
}
// fixture.mjs
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
const instance = randomUUID();
let active = 0;
let currentMarker; // Deliberately unsafe when RACE=1.
createServer(async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
if (req.url === '/healthz') {
res.end('ok');
return;
}
if (req.url !== '/probe' || req.method !== 'POST') {
res.writeHead(404).end();
return;
}
let counted = false;
try {
let raw = '';
for await (const chunk of req) raw += chunk;
const { marker } = JSON.parse(raw);
currentMarker = marker;
active++;
counted = true;
await delay(100);
const body = {
marker: process.env.RACE === '1' ? currentMarker : marker,
instance,
overlapping: active
};
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(body));
} catch {
res.writeHead(400).end();
} finally {
if (counted) active--;
}
}).listen(Number(process.env.PORT || 3000), '0.0.0.0');
Run locally first, using separate terminals. Restart the fixture with RACE=1; the shared currentMarker must cause a mismatch.
node fixture.mjs
BASE_URL=http://127.0.0.1:3000 node isolation.mjs
# Stop the fixture, then start the negative control:
RACE=1 node fixture.mjs
BASE_URL=http://127.0.0.1:3000 node isolation.mjs
Repeat against the synthetic CloudFront pilot using BASE_URL="$PILOT_URL" node isolation.mjs. A mismatch fails; missing overlap is inconclusive, never a pass. Remote results cover only observed instances, not whole-application safety.
For the real API, derive tenant identity from validated credentials, never a caller-supplied tenant header. Test two tenants across authorisation and cache boundaries, including cross-tenant reads and writes.
Run against both baseline and candidate, changing BASE_URL:
npx autocannon -c 1 -d 60 "$BASE_URL/healthz"
npx autocannon -c 16 -d 120 "$BASE_URL/healthz"
npx autocannon -c 64 -d 120 "$BASE_URL/healthz"
These are concurrency tests, not fixed arrival-rate tests. Health checks represent neither database nor CPU work. Add authenticated representative read/write routes and a sustained soak.
Proposed acceptance gates are team policy, not AWS guarantees:
Capture cold and warm runs, 429s, event-loop delay, memory, pool wait and connection counts. Log build identifiers: platform Web metrics do not separate revisions. Treat operational visibility and incident readiness as Lambda Web Functions migration requirements.

A Lambda Web Functions migration should start with one bounded route, not a DNS switch. Use your existing CloudFront front door, or deliberately introduce one. Retain the API Gateway origin and its working deployment.
Test through a separate pilot hostname first. Move one selected production path only after regional, authorisation, isolation, performance and cost gates pass. Never shadow writes or duplicate side effects. Keep schema changes backward-compatible while both architectures operate.
Before cutover, export DISTRIBUTION_ID, PATH_PATTERN and API_GATEWAY_ORIGIN_ID for your selected behaviour. Save the distribution configuration and ETag:
aws cloudfront get-distribution-config \
--id "$DISTRIBUTION_ID" > before-cutover.json
Keep this rollback.sh. It fetches the current configuration, changes only the selected behaviour’s origin, and submits the current ETag. Freeze concurrent distribution changes during rollback.
#!/usr/bin/env bash
set -euo pipefail
: "${DISTRIBUTION_ID:?}" "${PATH_PATTERN:?}" "${API_GATEWAY_ORIGIN_ID:?}"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
aws cloudfront get-distribution-config \
--id "$DISTRIBUTION_ID" > "$work/current.json"
etag=$(jq -r '.ETag' "$work/current.json")
jq -e --arg path "$PATH_PATTERN" \
--arg origin "$API_GATEWAY_ORIGIN_ID" '
.DistributionConfig
| if ([.CacheBehaviors.Items[]?
| select(.PathPattern == $path)] | length) != 1
then error("Expected exactly one matching path behaviour")
else .CacheBehaviors.Items |= map(
if .PathPattern == $path
then .TargetOriginId = $origin
else . end)
end
' "$work/current.json" > "$work/restore.json"
aws cloudfront update-distribution \
--id "$DISTRIBUTION_ID" --if-match "$etag" \
--distribution-config "file://$work/restore.json"
aws cloudfront wait distribution-deployed \
--id "$DISTRIBUTION_ID"
Propagation is not instantaneous. After deployment, smoke-test the production path, including authorisation and error responses.
Web Function revision weights cannot send traffic back to API Gateway. They only select Web Function revisions:
aws lambda-web update-web-function-endpoint \
--function-name cl-api-pilot --endpoint-name dev \
--auto-deployment-mode Disabled \
--revision-weights "[{\"revisionId\":\"$KNOWN_GOOD_REVISION\",\"weight\":100}]" \
--region eu-west-1
aws lambda-web wait endpoint-updated \
--function-name cl-api-pilot --endpoint-name dev \
--region eu-west-1
Run endpoint smoke tests afterwards. In-flight requests can finish on the outgoing revision.
Retain API Gateway for non-Node runtimes, unmet regional requirements, irreplaceable gateway controls or unsafe shared state. Keep it when savings cannot repay migration effort, too.
For a scoped assessment, talk to a Cloudline cloud engineer. Request a route and authorisation inventory, regional go/no-go decision, workload cost model, connection budget, pilot acceptance report and rollback runbook. Bring request volumes, latency distributions, current bills and residency requirements.
Keep the current API when a mandatory gate fails. Pilot when the architecture fits but the evidence is missing. Migrate only when the regional decision, security controls, isolation tests and measured economics agree. Cloudline Consulting can scope an API architecture and migration assessment around those decisions, with a cost model, connection budget and rollback runbook rather than a predetermined destination. Talk to a cloud engineer with your current architecture, traffic profile and operational requirements.