Lambda is usually introduced as “run code without managing servers”, which describes the billing and hides the machine. The useful mental model is narrower: Lambda is an execution model in which AWS owns a pool of single-tenant sandboxes, hands one sandbox exactly one request at a time, and charges you for the wall-clock milliseconds that sandbox spends on your work. Almost every Lambda behaviour that surprises people — a global variable that mysteriously remembers the previous caller, a function that throttles while the account has capacity to spare, one bad record replaying for six hours — falls out of that single sentence. This article works through the model rather than the API surface.

One sandbox, one request at a time

The unit you are renting is the execution environment: a sandbox holding your deployment package, a language runtime, a writable /tmp scratch directory, and a memory allocation. You cannot address it, name it, or ssh into it. You only observe it indirectly, through the state it keeps.

The load-bearing invariant is that one execution environment serves at most one invocation at any instant. There is no request multiplexing inside the process. This is the opposite of a conventional thread-pooled or event-loop server, where a single process interleaves thousands of connections and every shared data structure needs a lock.

Two consequences follow immediately. First, concurrency inside your handler buys you nothing at the platform level: awaiting three HTTP calls in parallel still overlaps I/O within one request, but it cannot make one environment serve two callers. Horizontal scale comes only from AWS cloning more environments. Second, because requests never overlap inside the process, handlers routinely get away with libraries that are not thread-safe and with sloppy global access — right up until the environment is reused, which is where the interesting bugs live.

Three phases: init -> invoke -> shutdown

The lifetime of an execution environment has three phases, and billing, timeouts and error handling treat them as genuinely different things.

Init runs once, when the environment is created. AWS fetches and unpacks your package, starts the language runtime, and then executes everything outside your handler function: module imports, static initializer blocks, top-level client construction, framework bootstrapping. Whatever you wrote at module scope runs here.

Invoke is the handler body itself, once per request. This is the only phase your configured function timeout governs. Init has its own separate ceiling, which is why a function with a generous timeout can still fail during startup for reasons the timeout setting does not control — the two limits are not the same limit.

Shutdown is the phase most people forget exists. When the platform decides to reclaim the environment, the runtime is signalled and registered extensions receive a shutdown event with a short grace window. The plain handler contract gives you no equivalent hook. That matters more than it sounds: if you buffer log lines, metrics, or spans in module scope and rely on a later invocation to flush them, the environment can be reclaimed with the buffer still full and the data is gone silently — no error, no alarm, just a gap. The operating rule is that anything you cannot afford to lose must already be durable by the time the handler returns.

Advertisement

The architecture

A function is code, a runtime, a memory allocation that also fixes its CPU share, and a handler entry point. Invocation is synchronous, asynchronous, or driven by an event source mapping that polls on your behalf, and the diagram below shows how those pieces meet the execution environment they run in.

Lambda execution modelFunction code + confighandler + memoryTriggerevent sourceExecution envcontainer per instanceCold start creates container; warm invocations reuse it until scale-down
Lambda function anatomy.

Cold starts, and which half of them is yours

A cold start is just the init phase happening on the request path instead of ahead of it. Three things occur in sequence: the platform builds the sandbox and mounts your code, the language runtime boots, and then your own initialization code runs.

The first two belong to AWS, and you influence them only indirectly. Package size matters because there are more bytes to fetch and unpack before anything executes, which is why dependency hygiene and tree-shaking are latency work and not just tidiness. Runtime choice matters because start-up cost is a property of the language: a compiled binary with no runtime to initialize starts faster than an interpreter walking an import graph, which starts faster than a managed-VM runtime doing class loading and JIT warm-up.

The third part is entirely yours, and on a slow function it is usually where most of the time is: loading a model file, constructing a dependency-injection container, fetching configuration from a remote parameter store, opening connections one at a time.

The two standard mitigations answer different questions and are often confused. Provisioned concurrency pre-creates and pre-initializes a fixed number of environments and holds them ready, so up to that many concurrent invocations never pay init at all — you buy the cold start away by paying for idle capacity. SnapStart keeps nothing warm: it runs init once when you publish a version, snapshots the initialized memory image, and restores that image on each cold start — it makes the init work cheap rather than making it not happen. Neither is free. Provisioned concurrency bills for capacity you are not using, and restoring from a snapshot forces you to reason about everything frozen into it: seeded random state that must be re-seeded, cached credentials that will expire, sockets that will be dead on the other end.

Phase-by-phase timings and the full mitigation ladder are covered in Lambda cold start architecture.

Environment reuse - why your globals survive

When your handler returns, the environment is not destroyed. It is frozen. The process stays resident with its heap intact, and the next invocation routed to it is thawed and calls your handler again with no init phase. Environments survive for minutes or hours, and are reclaimed on idle, on deploy, or whenever the platform feels like it. You are told none of this.

So module-scope state persists between unrelated requests. This is simultaneously the most valuable optimization on the platform and its single most common source of bugs.

The good half: hoist expensive, shareable things

Anything costly to build and safe to share belongs at module scope, where it is paid for once per environment rather than once per request: a connection pool, an HTTP client with its keep-alive pool intact, an SDK client with its cached credentials, a compiled regex, a parsed config object, a deserialized model.

import os
import boto3

# INIT phase - runs once per execution environment.
# The client caches credentials and holds TLS connections open,
# so subsequent invocations skip the handshake entirely.
_ddb = boto3.resource("dynamodb")
_table = _ddb.Table(os.environ["TABLE"])

def handler(event, context):
    # INVOKE phase - runs once per request.
    return _table.get_item(Key={"id": event["id"]})["Item"]

The bad half: mutable state is a cross-request leak

The identical mechanism, applied to anything mutable, leaks state between callers who have nothing to do with each other.

_seen = []        # BUG: grows for the life of the environment
_user = None      # BUG: request N can observe request N-1's user

def handler(event, context):
    global _user
    _seen.append(event["id"])      # unbounded memory growth
    _user = event["user"]          # tenant A's identity, visible to tenant B
    return lookup(_user)

What makes this class of bug vicious is that it is invisible exactly where you would look for it. Unit tests get a fresh process. Low-traffic environments cold-start constantly, so every request effectively gets its own sandbox and the code looks correct. The bug appears only once traffic is high enough that environments are actually reused — and when it appears it presents as one customer seeing another customer's data, which is a security incident rather than a correctness defect.

The same reasoning applies to /tmp. It is per environment and it persists across invocations. A function that writes a fixed-name scratch file and never cleans up will either read stale content from a previous request or slowly fill a fixed-size local disk and start failing on a subset of environments — an error rate that looks random because it tracks which sandbox you landed on.

One more reuse hazard: background work does not get killed at the end of an invocation, it gets frozen. A thread you spawned, a promise you never awaited, a fire-and-forget metrics flush — all of it stops mid-flight when the handler returns and resumes during the next invocation, possibly minutes later, with its callbacks firing inside some other request's trace context. There is no such thing as fire-and-forget in a Lambda handler.

The discipline is one sentence: module scope is for things that are safe to share between arbitrary unrelated requests. Everything else lives inside the handler.

Advertisement

Concurrency is the scaling unit, not requests per second

Lambda does not scale on request rate. It scales on concurrent executions — how many environments are mid-invocation at one instant. The relationship is Little's law: concurrency equals invocation rate multiplied by average duration. One hundred requests per second at 200 ms of work is twenty concurrent executions; the same hundred requests per second at two seconds of work is two hundred. Halving your duration halves your concurrency footprint, which is why latency work on Lambda pays twice.

Every function in an account shares a single per-region concurrency pool. Functions are isolated in execution but not in capacity. A runaway backfill job that consumes the pool will throttle your latency-critical API function even though that function is behaving perfectly — a noisy-neighbour problem inside your own account, between services you own, which is exactly the failure mode teams assume serverless removed.

Reserved concurrency is a floor and a ceiling at once

This is the detail that trips people. Setting reserved concurrency on a function does two things simultaneously: it guarantees the function that much of the pool, so no other function can take it, and it caps the function at that number, so it can never exceed it. You do not get one without the other. That is why setting reserved concurrency to zero is the documented kill switch — it guarantees zero and caps at zero, disabling a function without deleting it or unwiring its triggers.

Everything you reserve is subtracted from the pool left for unreserved functions, so over-reserving starves the rest of the account.

The usual reason to reserve is not about Lambda at all — it is about protecting whatever the function calls. Lambda scales far faster than most things downstream of it. A relational database with a bounded connection count falls over long before Lambda reaches any limit; a third-party API with a request quota gets you rate-limited or billed. Capping the function is the cheapest backpressure mechanism available.

Finally, scale-out is not instantaneous. New environments become available at a bounded ramp rate after an initial burst allowance, so a sharp spike can be throttled while capacity climbs even when the account ceiling is nowhere in sight. Throttling that clears itself in under a minute usually means you hit the ramp, not the limit.

Retries and DLQs differ by invocation source

There are three invocation paths, and their failure semantics differ so much they are better treated as three different products that happen to share a runtime.

Synchronous

API Gateway, an ALB, a Function URL, a direct SDK Invoke. Lambda does nothing on error: the exception is marshalled back to the caller, and a throttle surfaces as a throttling error the caller must interpret. Retry policy belongs entirely to the caller, and most callers in front of Lambda do not retry on your behalf, so a client that gives up loses the request. There is no dead-letter queue on this path, because there is no queue.

Asynchronous

S3 notifications, SNS, EventBridge, an explicit async invoke. Lambda accepts the event into an internal queue, returns immediately, and takes responsibility for delivery. On failure it retries a small number of times with backoff; during throttling events wait in that internal queue rather than being rejected. Once the retry budget is exhausted the event goes to the configured on-failure destination or DLQ. Two consequences deserve to be underlined: delivery is at least once, so duplicate invocations happen with no error anywhere and every async handler must be idempotent; and if you never configured a destination, exhausted events are discarded silently. A function can be dropping events for weeks with a clean error dashboard.

Poll-based

Kinesis, DynamoDB Streams, SQS, MSK and self-managed Kafka. Lambda runs a poller you do not see and hands you batches. On ordered stream sources the default failure mode is the harshest on the platform: a failing batch is retried until it succeeds or its records age out, and because the shard's ordering guarantee must hold, that one poison record blocks every record behind it on the same shard. A single unparseable message stalls a partition for hours while your error rate sits at a steady, unhelpful trickle. The controls are a maximum retry count, a maximum record age, an on-failure destination for discarded batches, and split-batch-on-error, which bisects a failing batch repeatedly to isolate the offending record. SQS behaves differently because it carries no global ordering guarantee: a failed message simply returns to the queue and follows that queue's own redrive policy to its DLQ.

Event source mappings and partial batch failures

An event source mapping is a managed poller that belongs to Lambda, not to your function. It reads from the source, assembles batches, invokes you, and tracks progress independently of your code. Its knobs are worth knowing because two of them interact badly with defaults.

Batch size sets the maximum records per invocation. Batching window sets how long the poller will wait to fill a batch, trading latency for fewer invocations. Parallelization factor on stream sources allows several concurrent invocations per shard while preserving order within each partition key — the only way to get more throughput from a shard without resharding.

The interaction people get bitten by: your function timeout applies to the whole batch, not to one record. Raising batch size from ten to one hundred multiplies the work inside a fixed timeout by ten, and a function that was comfortably within budget starts timing out — which, on a stream source, then triggers the head-of-line blocking described above.

The default batch failure semantics are all-or-nothing. Throw, and the entire batch is redelivered, including the records that already succeeded. If those records had side effects, the retry duplicates them. Partial batch response fixes this: enable the report-batch-item-failures option on the mapping and return a structured response naming only the items that failed.

exports.handler = async (event) => {
  const batchItemFailures = [];

  for (const record of event.Records) {
    try {
      await process(record);
    } catch (err) {
      // Report the item; do NOT rethrow, or the whole batch retries.
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }

  return { batchItemFailures };
};

Two traps. The option must be enabled on the event source mapping and the field must be returned — returning it without enabling the option is a silent no-op, and it looks like it works because the successful records are not retried either way. And on ordered stream sources the semantics are “retry from the earliest reported failure”, so naming one failure in the middle of a batch replays everything after it. Idempotency is not optional here; it is the thing that makes partial batch reporting safe.

VPC attachment and the ENI model

Attaching a function to a VPC is how it reaches private resources: an RDS instance on a private subnet, an internal load balancer, a VPC endpoint.

The original implementation created a network interface per execution environment, which meant that scaling out created ENIs on the request path. VPC-attached functions carried a cold-start penalty measured in seconds, and a busy function could exhaust the subnet's IP addresses. The current model decouples the two: interfaces are created per unique subnet-and-security-group combination and shared across all of the function's environments, so scaling no longer creates interfaces one per sandbox and the cold-start penalty is largely gone.

What did not change is routing. A VPC-attached function has no path to the internet unless its subnet provides one. Put a function in a private subnet without a route and its calls to public AWS endpoints — including the ones its own SDK makes — fail by timing out rather than erroring fast, which means you discover the misconfiguration as a function that mysteriously runs to its full timeout and dies. The two fixes are a NAT gateway, which routes everything but bills per gigabyte processed, or VPC endpoints for the specific services you call, which avoid the data-processing charge but need one endpoint per service. Security groups still apply to the function itself, and subnet IP capacity is still worth planning even though it is far harder to exhaust than it was.

Memory is the only dial - CPU and cost follow it

You configure memory. You do not configure CPU. CPU allocation scales linearly with the memory setting, and so does network and I/O throughput. This is the least intuitive property of the platform and the one that most often makes functions simultaneously slow and expensive.

Because billing is memory multiplied by duration, raising memory can reduce cost. Double the memory on a CPU-bound function and, if the work can use the additional vCPU, duration roughly halves — the same bill for half the latency. Push further and you eventually cross the point where duration stops improving, because the work is no longer CPU-bound or the runtime cannot use more cores, and from there cost climbs for nothing. Every function therefore has a real cost-versus-latency curve with an optimum somewhere in the middle, and that optimum is almost never the minimum memory setting. Measuring it is a half-hour job that frequently pays for itself immediately.

At the bottom of the range a function receives a fraction of a vCPU, which means the runtime's own housekeeping competes with your handler for that fraction. Garbage collection, JIT compilation and TLS handshakes all get time-sliced against your business logic, so an under-provisioned function does not degrade gracefully — it degrades in bursts that correlate with GC.

The broader cost shape is a per-request charge plus duration billed in gigabyte-seconds, with init treated differently depending on whether you are on-demand or provisioned. The economics strongly favour spiky, low-duty-cycle workloads, because idle costs nothing. They turn against you at high sustained utilization, where a long-running instance is busy most of the time and its unit price is far lower.

Where Lambda is the wrong tool

Long-running work. There is a hard maximum invocation duration. Anything longer has to be decomposed — orchestrated through Step Functions, or written as a checkpointed loop that re-invokes itself — and if the work genuinely cannot be decomposed, that is a signal to use something else rather than an obstacle to engineer around.

Sustained high throughput. The per-invocation premium is irrelevant at ten percent duty cycle and punishing at ninety. A service under steady heavy load is renting the same compute at a much higher unit price, and there is a genuine crossover point that is a calculation rather than a matter of taste.

Hard latency floors. Even a warm invocation carries platform overhead, and you cannot guarantee the absence of cold starts without provisioned concurrency — which reintroduces exactly the always-on cost you adopted Lambda to avoid. If your p99.9 budget is single-digit milliseconds, a warm process behind a load balancer is the honest answer.

Heavy CPU or any GPU. There are no GPUs, and the memory ceiling bounds how much vCPU you can obtain. Heavy compute collides with the duration limit and the throughput economics at the same time.

Stateful, connection-oriented protocols. Anything that wants a long-lived attachment to a specific process — a WebSocket server holding session state in memory, leader election, an in-process cache treated as authoritative — is fighting the model. The environment is frozen between requests and can disappear without notice; nothing you must rely on can live inside it.

Where Lambda genuinely earns its keep is the inverse of that list: glue between AWS services, event handlers whose work is measured in hundreds of milliseconds, spiky or unpredictable traffic, scheduled jobs, and any workload where operating a fleet would cost more engineering attention than the work is worth. For workflow orchestration across many such functions see Step Functions; for the event routing layer in front of them see EventBridge.

Lambda is a sandbox that serves one request at a time, is initialized once, is frozen rather than destroyed between requests, and is scaled by cloning. Cold starts, surviving globals, per-shard head-of-line blocking and the account-wide concurrency pool are all consequences of that one model rather than separate features to memorize. Design to it: keep module scope to things that are safe to share between strangers, make every handler idempotent because at-least-once is the contract on two of the three invocation paths, size memory by measuring the cost-versus-latency curve instead of by minimizing, and reserve concurrency to protect whatever the function calls rather than to protect Lambda.