Lambda Web Functions: When a Function Becomes a Web Server

Since 2014 a Lambda function has been one thing: a handler that receives an event, runs alone in its execution environment, and is billed for every millisecond between start and return. Running a web application on it meant translating HTTP into events and back, through API Gateway, a function URL or an adapter layer, and paying for the time each request spent waiting on a database.

On 12 October AWS announced a second form factor. A Lambda Web Function is an ordinary HTTP server. Your code listens on a port, one execution environment serves many requests at once, and compute is billed by CPU time used and not by elapsed time. This article looks at what that changes for an architect: the contract a server has to meet, what the billing model rewards and punishes, and where a Web Function sits against a function URL, API Gateway and App Runner. The server in the sample was run and measured locally, and the cost model was checked against AWS’s own worked examples. Nothing was deployed, and the article is explicit about what that leaves untested.

What was announced

Event Function (the existing model)Web Function
Your code isA handler invoked once per eventAn HTTP server listening on a port
ConcurrencyOne request per execution environmentUp to 128 requests per environment, 64 by default
Compute billingConfigured memory multiplied by elapsed timeActive vCPU time, plus memory for the life of the environment
Runtime at launchAll Lambda runtimesNode.js 24 on Arm64 only
Environment sizeConfigured2 vCPUs and 2 GB
EndpointFunction URL, API Gateway, load balancer or an event sourceA built-in HTTPS endpoint; optional multi-Region routing behind one domain
Deploy unitVersion and aliasImmutable revision; an endpoint routes to one or two revisions by weight
Scales to zeroYesYes

AWS describes three form factors on the same Firecracker isolation: Event Functions, Web Functions, and MicroVMs for running untrusted code. At launch Web Functions are available in five Regions, including Europe (Ireland). An endpoint is either IamAuth, where callers sign requests, or ApplicationManaged, which is public and leaves authentication to your code. Custom domains, caching and a web application firewall come from putting CloudFront in front.

The contract: listen where you are told

The whole integration is one environment variable. Lambda sets AWS_LAMBDA_HTTP_ENDPOINT to a host and port, and the server must be listening there within ten seconds. There is no handler signature and no event object. This is the sample: an appointments API on Node’s built-in http module, with no dependencies and no Lambda-specific code beyond reading that variable.

// An appointments API as an ordinary HTTP server. No Lambda handler, no adapter, no dependencies.
// Lambda Web Functions tell the server where to listen through AWS_LAMBDA_HTTP_ENDPOINT (host:port);
// on a laptop the same variable, or the default below, does the same job.
'use strict';

const http = require('node:http');
const crypto = require('node:crypto');
const { setTimeout: sleep } = require('node:timers/promises');

// Synthetic data. No real patients, clinics or clinicians.
const APPOINTMENTS = [
  { id: 'apt-1001', clinic: 'north', start: '2026-10-19T09:00:00Z', status: 'booked' },
  { id: 'apt-1002', clinic: 'north', start: '2026-10-19T09:30:00Z', status: 'free' },
  { id: 'apt-1003', clinic: 'south', start: '2026-10-19T10:00:00Z', status: 'booked' },
];

// Start-up work that must not delay listening: the platform expects the port to be open within
// ten seconds, and routes requests as soon as it is. Handlers that need the result await it.
let ready;
function warmUp() {
  ready ??= sleep(20).then(() => ({ loadedAt: new Date().toISOString() }));
  return ready;
}

function send(res, status, body, headers = {}) {
  const payload = JSON.stringify(body);
  res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload), ...headers });
  res.end(payload);
}

// Lambda appends the connecting client's address as the last value. Earlier values are whatever the
// caller sent, so only the last one is trusted.
function clientAddress(req) {
  const forwarded = String(req.headers['x-forwarded-for'] ?? '').split(',').map((part) => part.trim()).filter(Boolean);
  return forwarded.at(-1) ?? req.socket.remoteAddress;
}

async function route(req, res) {
  const url = new URL(req.url, 'http://placeholder');
  const requestId = req.headers['lambda-web-request-id'] ?? crypto.randomUUID();

  if (req.method === 'GET' && url.pathname === '/healthz') {
    return send(res, 200, { status: 'ok' });
  }

  if (req.method === 'GET' && url.pathname === '/api/appointments') {
    await warmUp();
    const clinic = url.searchParams.get('clinic');
    const items = clinic ? APPOINTMENTS.filter((a) => a.clinic === clinic) : APPOINTMENTS;
    return send(res, 200, { items, requestId, client: clientAddress(req) });
  }

  // Stands in for a call to a database or another service: the request waits and uses almost no CPU.
  if (req.method === 'GET' && url.pathname === '/api/slow') {
    const wait = Math.min(Number(url.searchParams.get('ms') ?? 50), 5000);
    await sleep(wait);
    return send(res, 200, { waitedMs: wait, requestId });
  }

  // Stands in for CPU-bound work. In a single-threaded server this blocks every other request.
  if (req.method === 'GET' && url.pathname === '/api/cpu') {
    const rounds = Math.min(Number(url.searchParams.get('rounds') ?? 200000), 2000000);
    let digest = Buffer.alloc(32);
    for (let i = 0; i < rounds; i++) {
      digest = crypto.createHash('sha256').update(digest).digest();
    }
    return send(res, 200, { rounds, digest: digest.toString('hex').slice(0, 16), requestId });
  }

  // Server-sent events: the response is written as it is produced.
  if (req.method === 'GET' && url.pathname === '/events') {
    const count = Math.min(Number(url.searchParams.get('count') ?? 5), 100);
    res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
    for (let i = 1; i <= count; i++) {
      res.write(`data: ${JSON.stringify({ sequence: i, of: count })}\n\n`);
      await sleep(10);
    }
    return res.end();
  }

  return send(res, 404, { error: 'not found', requestId });
}

function createServer() {
  return http.createServer((req, res) => {
    route(req, res).catch(() => {
      // Never put request content in an error response or a log line.
      if (!res.headersSent) send(res, 500, { error: 'internal error' });
      else res.destroy();
    });
  });
}

function listenAddress() {
  const [host, port] = (process.env.AWS_LAMBDA_HTTP_ENDPOINT || '127.0.0.1:3000').split(':');
  return { host, port: Number(port) };
}

if (require.main === module) {
  const server = createServer();
  const { host, port } = listenAddress();
  server.listen(port, host, () => console.log(JSON.stringify({ event: 'listening', host, port })));
  warmUp();

  // The platform allows about one second after SIGTERM, and does not promise the signal arrives.
  // Flush what is cheap to flush and leave; do not start new work here.
  process.on('SIGTERM', () => {
    console.log(JSON.stringify({ event: 'sigterm' }));
    process.exit(0);
  });
}

module.exports = { createServer, listenAddress };

Four details in that file come straight from the platform’s documented behaviour.

  • Listen first, initialise after. Requests are routed as soon as the port is open, and the port has to open within ten seconds. Start-up work that takes time runs after listen, and handlers that need it await it.
  • Trust only the last forwarded address. Lambda appends the connecting client’s address to X-Forwarded-For. Earlier values are whatever the caller sent.
  • Use the platform’s request id. Lambda adds lambda-web-request-id to each request and to the response, which is what ties an application log line to the platform’s.
  • Expect an abrupt end. On scale-in Lambda stops routing, lets in-flight requests finish on a best-effort basis, sends SIGTERM and allows about one second. It also says not to rely on the signal arriving. Anything that must not be lost has to be written before the response, not in a shutdown hook.

The same file runs on a laptop with node index.js. In CI it passed its eight tests on Node.js 24, and started as a separate process with the variable set, it answered its health check and exited with code 0 within 10 milliseconds of SIGTERM. Deployment is one command, aws lambda-web deploy --name appointments --code ./app, which was not run for this article.

What the billing model meters

The pricing change is easy to state and its consequences are easy to miss. An Event Function is charged for configured memory over the full duration of each request, including time spent waiting. A Web Function is charged for CPU time the process uses, and separately for memory for as long as an execution environment exists, busy or idle.

To put numbers on the difference, the sample runs the server as its own process, drives load at it, and reads the server’s CPU time from the operating system, so the load generator is not counted.

// Measures what a Web Function is billed for. The server runs as its own process, as it does on the
// platform, and its CPU time is read from the operating system, so the load generator is not counted.
// Linux only (reads /proc). Usage: node measure.js [metrics.json]
'use strict';

const { spawn } = require('node:child_process');
const fs = require('node:fs');
const { performance } = require('node:perf_hooks');
const { setTimeout: sleep } = require('node:timers/promises');

const PORT = 38081;
const BASE = `http://127.0.0.1:${PORT}`;
const TICKS_PER_SECOND = 100; // USER_HZ on Linux

function cpuSecondsOf(pid) {
  const fields = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1].split(' ');
  return (Number(fields[11]) + Number(fields[12])) / TICKS_PER_SECOND; // utime + stime
}

async function measure(pid, work) {
  const cpuBefore = cpuSecondsOf(pid);
  const started = performance.now();
  const requestSeconds = await work();
  return {
    wallSeconds: Number(((performance.now() - started) / 1000).toFixed(2)),
    sumOfRequestDurationsSeconds: Number(requestSeconds.toFixed(2)),
    serverCpuSeconds: Number((cpuSecondsOf(pid) - cpuBefore).toFixed(2)),
  };
}

async function timed(url) {
  const started = performance.now();
  await (await fetch(url)).arrayBuffer();
  return (performance.now() - started) / 1000;
}

async function main() {
  const server = spawn(process.execPath, ['index.js'], { cwd: __dirname, env: { ...process.env, AWS_LAMBDA_HTTP_ENDPOINT: `127.0.0.1:${PORT}` }, stdio: 'ignore' });
  try {
    for (let i = 0; i < 100; i++) {
      try { await timed(`${BASE}/healthz`); break; } catch { await sleep(50); }
    }

    // 1,280 requests that each wait 200 ms on I/O, 128 at a time.
    const ioBound = await measure(server.pid, async () => {
      let total = 0;
      for (let batch = 0; batch < 10; batch++) {
        total += (await Promise.all(Array.from({ length: 128 }, () => timed(`${BASE}/api/slow?ms=200`)))).reduce((a, b) => a + b, 0);
      }
      return total;
    });

    // 20 requests of hashing, one after another.
    const cpuBound = await measure(server.pid, async () => {
      let total = 0;
      for (let i = 0; i < 20; i++) total += await timed(`${BASE}/api/cpu?rounds=200000`);
      return total;
    });

    // One long CPU-bound request, and a health check sent while it is running.
    const long = timed(`${BASE}/api/cpu?rounds=1500000`);
    await sleep(150);
    const healthCheckSeconds = Number((await timed(`${BASE}/healthz`)).toFixed(2));
    const longRequestSeconds = Number((await long).toFixed(2));

    const metrics = { node: process.version, ioBound: { requests: 1280, ...ioBound }, cpuBound: { requests: 20, ...cpuBound }, blocking: { longRequestSeconds, healthCheckSeconds } };
    console.log(JSON.stringify(metrics, null, 2));
    if (process.argv[2]) fs.writeFileSync(process.argv[2], JSON.stringify(metrics, null, 2));
  } finally {
    server.kill('SIGTERM');
  }
}

main().catch((error) => { console.error(error); process.exit(1); });
For 1,280 requests that each wait 200 ms, an event function is billed for 275.75 seconds of request time while a web function is billed for 0.20 seconds of CPU plus memory for the life of the environment. Two cards: waiting is nearly free, computing is not.
The same 1,280 requests under each billing model. The orange bar is what an Event Function meters; the green sliver is what a Web Function meters for compute.
Measurement on one server processRequestsSum of request durationsServer CPU time
Each waits 200 ms on I/O, 128 at a time1,280275.75 s0.20 s
Each hashes in a loop, one at a time208.67 s11.78 s

The first row is the case the feature is built for. Those 1,280 requests accumulated 275.75 seconds of request time, which is what an Event Function would meter, and the server used a fifth of a second of CPU to serve them. One process handled 128 at a time without difficulty, because a request that is waiting costs the event loop nothing.

The second row is the warning. CPU-bound requests used more CPU time than elapsed time, since the runtime’s helper threads work alongside the main one. And a third measurement shows what that work does to everything else: a health check sent while one 3.06-second hashing request was running took 2.91 seconds to answer. AWS’s documentation says it plainly: Node.js is single-threaded, so synchronous CPU-intensive work blocks every concurrent request in that environment. With one request per environment that never mattered. With 64 or 128 sharing a process, a single slow synchronous path becomes latency for all of them.

⚠️
MULTI-CONCURRENCY CHANGES WHAT IS SAFE

Code written for one request per environment often keeps request state in module-level variables, because nothing else could be running. In a Web Function that is a data leak between users. Before moving a handler, look for module-level state that holds anything about a request, a user or a tenant, and for synchronous work: large JSON parsing, compression, cryptography, template rendering in a loop.

A cost model checked against AWS’s arithmetic

Without a deployment there is no bill to show, so the model has to earn trust another way. AWS publishes four worked examples for Web Functions on the Lambda pricing page. The model reproduces all four to within one cent before it is used for anything else.

"""Monthly cost of one HTTP workload on four ways of running it on AWS.

    python model.py                  the comparison table for the scenarios in this file
    python model.py --json           the same as JSON

The point of the model is the shape of each bill, not the totals: which quantity each option meters.
  web function        requests + CPU time actually used + memory for as long as an environment exists
  event function      requests + memory x wall-clock duration of every request
  HTTP API in front   the event function, plus a charge per request
  App Runner          vCPU and memory per hour while instances are active, memory per hour otherwise
Rates are in rates.json. Free tiers and data transfer are excluded.
"""
from __future__ import annotations

import argparse
import json
import math
import pathlib
from dataclasses import dataclass

RATES = json.loads(pathlib.Path(__file__).with_name("rates.json").read_text(encoding="utf-8"))
SECONDS_PER_MONTH = 30 * 24 * 3600


@dataclass(frozen=True)
class Workload:
    name: str
    requests: int               # per 30-day month
    cpu_ms: float               # CPU time one request actually uses
    duration_ms: float          # how long one request takes end to end
    memory_gb: float            # memory the server process uses
    busy_hours_per_day: float   # hours a day with traffic; outside them there is none


def web_function(w: Workload, environments: float = 1.0, always_on: bool = True) -> dict:
    """Requests, active CPU, and memory for the life of each environment."""
    rates = RATES["web_function"]
    alive_seconds = SECONDS_PER_MONTH if always_on else w.busy_hours_per_day * 3600 * 30
    cpu = w.requests * w.cpu_ms / 1000 * rates["active_vcpu_second"]
    memory = w.memory_gb * alive_seconds * environments * rates["memory_gb_second"]
    requests = w.requests / 1e6 * rates["per_million_requests"]
    return {"cpu": cpu, "memory": memory, "requests": requests, "total": cpu + memory + requests}


def event_function(w: Workload, configured_memory_gb: float, architecture: str = "arm", http_api: bool = False) -> dict:
    """Requests, and configured memory for the whole duration of every request, waiting included."""
    rates = RATES["event_function"]
    compute = w.requests * w.duration_ms / 1000 * configured_memory_gb * rates[f"gb_second_{architecture}"]
    requests = w.requests / 1e6 * rates["per_million_requests"]
    gateway = w.requests / 1e6 * RATES["http_api"]["per_million_requests"] if http_api else 0.0
    return {"compute": compute, "requests": requests, "gateway": gateway, "total": compute + requests + gateway}


def app_runner(w: Workload, vcpu: float = 1.0, memory_gb: float = 2.0, concurrency: int = 80) -> dict:
    """Instances are charged for vCPU and memory while active and for memory alone while idle."""
    rates = RATES["app_runner"]
    busy_seconds = w.busy_hours_per_day * 3600 * 30
    in_flight = w.requests * w.duration_ms / 1000 / busy_seconds if busy_seconds else 0.0
    instances = max(1, math.ceil(in_flight / concurrency))
    active_hours = w.busy_hours_per_day * 30
    idle_hours = 24 * 30 - active_hours
    active = instances * active_hours * (vcpu * rates["active_vcpu_hour"] + memory_gb * rates["gb_hour"])
    idle = idle_hours * memory_gb * rates["gb_hour"]
    return {"active": active, "idle": idle, "instances": instances, "total": active + idle}


SCENARIOS = [
    Workload("I/O-heavy API: 10M requests, 50 ms CPU inside a 300 ms request", 10_000_000, 50, 300, 0.29, 24),
    Workload("Streaming endpoint: 5M requests, 200 ms CPU inside a 3 s response", 5_000_000, 200, 3000, 0.50, 24),
    Workload("CPU-bound API: 10M requests, 280 ms CPU inside a 300 ms request", 10_000_000, 280, 300, 0.29, 24),
    Workload("Internal tool: 60,000 requests in office hours, 50 ms CPU inside 300 ms", 60_000, 50, 300, 0.29, 8),
]


def compare(w: Workload) -> dict:
    return {
        "workload": w.name,
        "web_function_always_on": round(web_function(w)["total"], 2),
        "web_function_scaled_to_zero_off_hours": round(web_function(w, always_on=False)["total"], 2),
        "event_function_url_512mb": round(event_function(w, 0.5)["total"], 2),
        "event_function_http_api_512mb": round(event_function(w, 0.5, http_api=True)["total"], 2),
        "app_runner_1vcpu_2gb": round(app_runner(w)["total"], 2),
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--json", action="store_true")
    rows = [compare(w) for w in SCENARIOS]
    if parser.parse_args().json:
        print(json.dumps(rows, indent=2))
        return
    for row in rows:
        print(row["workload"])
        for key, value in row.items():
            if key != "workload":
                print(f"  {key:<40} ${value:>10,.2f}")
        print()


if __name__ == "__main__":
    main()
{
  "_comment": "US East (N. Virginia), USD. Read from the AWS pricing pages named in 'source'. Free tiers and data transfer are left out, as they are in AWS's own worked examples.",
  "web_function": {
    "source": "https://aws.amazon.com/lambda/pricing/ (Web Functions, Arm)",
    "active_vcpu_second": 0.0000276944,
    "memory_gb_second": 0.0000036667,
    "per_million_requests": 0.30
  },
  "event_function": {
    "source": "https://aws.amazon.com/lambda/pricing/ (Event Functions)",
    "gb_second_arm": 0.0000133334,
    "gb_second_x86": 0.0000166667,
    "per_million_requests": 0.20
  },
  "http_api": {
    "source": "https://aws.amazon.com/api-gateway/pricing/ (HTTP APIs, first 300 million requests a month)",
    "per_million_requests": 1.00
  },
  "app_runner": {
    "source": "https://aws.amazon.com/apprunner/pricing/",
    "active_vcpu_hour": 0.064,
    "gb_hour": 0.007
  }
}
"""python test_model.py

The model is checked against the worked examples AWS publishes on its own pricing pages. If it cannot
reproduce those to the cent, nothing else it prints deserves to be read.
"""
import sys

from model import SCENARIOS, Workload, app_runner, compare, event_function, web_function

passed = failed = 0


def check(name: str, actual: float, expected: float, tolerance: float = 0.011) -> None:
    global passed, failed
    ok = abs(actual - expected) <= tolerance
    passed += ok
    failed += not ok
    print(f"{'ok  ' if ok else 'FAIL'}  {name}: {actual:,.2f} (published {expected:,.2f})")


# Lambda pricing page, Web Functions examples 1 to 4: one environment for 30 days.
published = [
    ("AWS example 1, server-side rendered app", Workload("", 15_000_000, 40, 0, 0.49, 24), 25.78),
    ("AWS example 2, I/O-heavy API", Workload("", 10_000_000, 50, 0, 0.29, 24), 19.61),
    ("AWS example 3, authorization middleware", Workload("", 100_000_000, 12, 0, 0.14, 24), 64.56),
    ("AWS example 4, LLM streaming endpoint", Workload("", 5_000_000, 200, 3000, 0.50, 24), 33.94),
]
for name, workload, total in published:
    check(name, web_function(workload)["total"], total)

# App Runner pricing page, example 2: 1 vCPU and 2 GB, active 8 hours a day, memory provisioned all day.
# AWS prints $25.50 from daily figures rounded to cents; unrounded arithmetic gives $25.44.
runner = app_runner(Workload("", 80 * 8 * 3600 * 30, 0, 100, 2.0, 8))
check("App Runner example 2, lightweight API (AWS rounds daily figures)", runner["total"], 25.50, tolerance=0.07)

# Event function arithmetic: 1M requests of 1 s at 1 GB on Arm is 1,000,000 GB-seconds.
check("event function, 1M requests x 1 s x 1 GB on Arm", event_function(Workload("", 1_000_000, 0, 1000, 1, 24), 1.0)["total"], 13.33 + 0.20)
check("the same behind an HTTP API", event_function(Workload("", 1_000_000, 0, 1000, 1, 24), 1.0, http_api=True)["total"], 13.33 + 0.20 + 1.00)

rows = {row["workload"]: row for row in map(compare, SCENARIOS)}
io, streaming, cpu, tool = (rows[w.name] for w in SCENARIOS)
checks = [
    ("I/O-heavy: the web function costs less than the event function", io["web_function_always_on"] < io["event_function_url_512mb"]),
    ("streaming: the gap is widest where requests wait longest", streaming["event_function_url_512mb"] / streaming["web_function_always_on"] > io["event_function_url_512mb"] / io["web_function_always_on"]),
    ("CPU-bound: the web function is the more expensive of the two", cpu["web_function_always_on"] > cpu["event_function_url_512mb"]),
    ("low traffic: an always-on environment costs more than paying per request", tool["web_function_always_on"] > tool["event_function_url_512mb"]),
    ("an HTTP API adds exactly its per-request charge", abs(io["event_function_http_api_512mb"] - io["event_function_url_512mb"] - 10.00) < 0.011),
]
for name, ok in checks:
    passed += ok
    failed += not ok
    print(f"{'ok  ' if ok else 'FAIL'}  {name}")

print(f"\n{passed} of {passed + failed} checks passed")
sys.exit(1 if failed else 0)
Cost table: the Web Function is cheapest for an I/O-heavy API (19.60 dollars) and a streaming endpoint (33.95 dollars); an event function behind a function URL is cheapest for a CPU-bound API (22 dollars) and a low-traffic internal tool (13 cents).
Modelled monthly cost for four workload shapes. The lowest figure in each row is green.
WorkloadWeb FunctionEvent Function, function URLEvent Function, HTTP APIApp Runner
I/O-heavy API: 10M requests, 50 ms CPU in a 300 ms request$19.60$22.00$32.00$56.16
Streaming endpoint: 5M requests, 200 ms CPU in a 3 s response$33.95$101.00$106.00$56.16
CPU-bound API: 10M requests, 280 ms CPU in a 300 ms request$83.30$22.00$32.00$56.16
Internal tool: 60,000 requests in office hours$2.86$0.13$0.19$25.44

Read the table for its shape. Where requests mostly wait, the Web Function wins, and the longer they wait the wider the gap: the streaming endpoint is a third of the Event Function’s cost. Where requests mostly compute, it loses badly, because a vCPU-second is priced well above the memory-seconds an Event Function charges for the same interval. And at low traffic, an environment that stays alive charges for memory around the clock, so paying per request is cheaper by an order of magnitude; if the environment scales to zero outside office hours the model gives $1.02, still above the Event Function.

The assumptions are in the code and they matter: one always-on environment, a 512 MB Event Function on Arm, one App Runner instance of 1 vCPU and 2 GB, list prices for US East (N. Virginia), no free tier, no data transfer. The CPU-bound row is also kind to the Event Function, since 512 MB buys a fraction of a vCPU and the same work would take longer there. Change the memory figure or the number of environments and the totals move. The ordering by workload shape does not.

Against the alternatives

OptionAdvantagesDisadvantagesFits
Web FunctionStandard server code; many requests per environment; CPU-time billing; built-in endpoint and multi-Region routingNode.js on Arm64 only at launch; your code must be safe under concurrency; memory billed while idle; new and unprovenI/O-bound web apps and APIs, server-side rendering, streamed responses
Event Function with a function URLEvery runtime; isolation per request; scales to zero cleanly; matureBilled for waiting; one request per environment; HTTP is translated into eventsLow or spiky traffic, CPU-bound work, anything not in Node.js
Event Function behind API GatewayRequest validation, throttling, usage plans, authorisers, stagesA charge per request on top; another service to configurePublic APIs that need those gateway features
App RunnerAny language in a container; steady performance; simple to reason aboutPays for instances by the hour whether busy or notSteady traffic, containers you already have
ECS or EKSFull controlFull responsibilityWorkloads that have outgrown the others
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
    A[HTTP workload on AWS] --> B{Written in Node.js, or willing to be?}
    B -->|No| C[Event Function or a container service]
    B -->|Yes| D{Do requests mostly wait on I/O?}
    D -->|No, they compute| E[Event Function: isolation and per-request billing suit it]
    D -->|Yes| F{Traffic for most of the day?}
    F -->|No, occasional| G[Event Function with a function URL]
    F -->|Yes| H{Code safe with many requests in one process?}
    H -->|Not yet| I[Fix shared state and blocking paths first]
    H -->|Yes| J[Web Function. Put CloudFront in front for domain and WAF]
    classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
    classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
    classDef net fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74
    classDef danger fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
    class A,B,D,F,H compute
    class C,E,G net
    class I danger
    class J store

Security and compliance

  • Public by choice, not by default. An ApplicationManaged endpoint is reachable from the internet and trusts your code to authenticate. AWS documents a condition key, lambda:Request/WebFunctionAuthType, that a service control policy can use to forbid public endpoints across an organisation. For a regulated estate, set that policy before the first team discovers the feature.
  • Front door. With IamAuth and CloudFront origin access control, only your distribution can invoke the endpoint, which gives you a custom domain, TLS policy and WAF without the endpoint being callable directly.
  • Tenant and patient isolation. An Event Function gave you one request per environment for free. A Web Function does not. Any design that relied on that for isolation between tenants has to be re-argued.
  • Region. Multi-Region deployment replicates code and configuration to every Region you list, and routes by latency. For data that must stay in the EU, list EU Regions only and check where the data stores are.
  • Logs. Each environment writes a summary record every minute with request count, peak concurrency and CPU time. Keep request content out of application logs as before.
⚖️
A NEW SERVICE IN A REGULATED SYSTEM

The feature is days old. Before it carries clinical or personal data, confirm it is covered by the compliance programmes your organisation relies on and by your agreement with AWS, and get an answer on isolation under multi-concurrency from whoever owns security architecture. A sensible first use is an internal, non-clinical API where the billing model can be observed on a real bill.

Operations

Three things change for the team running it. Capacity is now two numbers, requests per environment and environments per endpoint, and requests beyond either limit are rejected with HTTP 429, not queued, so clients need retry with backoff. Deployments are revisions with weighted routing, which makes a canary a routing change and a rollback the same. And the metric to watch moves from duration to CPU time per request and peak concurrency per environment: a rise in CPU per request is a rise in cost and a warning that the event loop is being held.

Key takeaways

  • A Lambda Web Function is a normal HTTP server: listen on the address in AWS_LAMBDA_HTTP_ENDPOINT within ten seconds, and expect about one second after SIGTERM. At launch it is Node.js 24 on Arm64 only.
  • Compute is billed by CPU time. In the measurement, 1,280 requests with 275.75 seconds of total duration used 0.20 seconds of server CPU.
  • CPU-bound work is the wrong fit twice over: it is priced higher than on an Event Function, and it blocks every other request in the process. A health check waited 2.91 seconds behind one hashing request.
  • Memory is billed while an environment exists. For occasional traffic an Event Function behind a function URL remains far cheaper.
  • Multi-concurrency removes the per-request isolation Event Functions gave for free. Check for shared state before moving a handler.

If you have a Web Function carrying real traffic, the numbers worth sharing are CPU milliseconds per request and peak concurrency per environment. Leave them in a comment.

ℹ️
HOW THIS WAS CHECKED

The server passed 8 of 8 tests and the measurements above on Node.js 24.22.2 in GitHub Actions (run 43338914911); CPU time was read from the operating system for the server process alone. The cost model passed 12 of 12 checks, including the four Web Functions examples on the Lambda pricing page to within one cent. The listings are the files in the samples repository. Nothing was deployed to AWS: cold starts, scaling, the concurrency limits, endpoint authentication and real bills were not observed. Facts about the service are from the AWS announcement of 12 October and the Lambda documentation. The model uses list prices for US East (N. Virginia) from the pages named in rates.json and excludes free tiers and data transfer.

Glossary

  • Event Function— The original Lambda model: a handler invoked once per event, one request per execution environment.
  • Web Function— A Lambda form factor in which your code is an HTTP server and one environment serves many requests.
  • Execution environment— The isolated microVM in which Lambda runs your code.
  • Revision— An immutable snapshot of a Web Function’s code and configuration.
  • Endpoint— The HTTPS entry point of a Web Function, which routes requests to one or two revisions.
  • Active CPU time— CPU seconds the process actually consumes, as opposed to elapsed time.
  • Multi-concurrency— More than one request in flight in the same process at once.
  • Function URL— A built-in HTTPS endpoint for an Event Function.

References & Further Reading


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.