A Lambda function looks like a single function call: an event goes in, a result comes out. Underneath, it runs in an execution environment, a small isolated sandbox with your code, a runtime and some memory, and that environment has a lifetime much longer than any one call. Most confusing Lambda behaviour (state leaking between requests, logs that show up in the wrong invocation, connections that suddenly break) comes from not picturing that environment.
Init, invoke, shutdown
Each environment goes through three phases.
Init happens when Lambda creates a new environment, usually because a request arrived and no idle environment was available. It starts any extensions, starts the runtime, and runs your code outside the handler: imports, SDK client construction, reading config. This is the “cold start”. The init phase has a 10-second limit, and its duration is reported separately in the logs as Init Duration.
Invoke runs your handler for one event. While it runs, the environment handles nothing else; a second simultaneous request goes to a different environment. When the handler returns (or throws, or times out), Lambda freezes the environment. CPU stops. Timers stop. Sockets stay open from your side but receive nothing.
The next request may land on this same frozen environment. Lambda thaws it and calls the handler again, skipping init. This is a “warm” invocation, and it’s why you put expensive setup at module level.
Shutdown happens when Lambda decides to retire the environment, after it’s been idle for a while or for its own maintenance reasons. You can’t predict when, and you get at most a brief signal (only if an extension is registered). So never rely on shutdown to flush data.
A REPORT log line ties this together. A cold invocation looks something like this (illustrative values):
REPORT RequestId: 3f6c9a2e-8b41-4d0f-9d4e-1c2b7a5e6f10 Duration: 41.27 ms Billed Duration: 42 ms
Memory Size: 512 MB Max Memory Used: 88 MB Init Duration: 312.55 msA warm invocation on the same environment has no Init Duration field at all.
What survives between invocations, and what doesn’t
Because the environment is reused, anything at module scope persists across warm invocations: variables, SDK clients, database connections, files in /tmp (512 MB by default, configurable up to 10 GB). That’s a feature when you use it on purpose:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
const ddb = new DynamoDBClient({}); // created once per environment, reused when warm
let invocations = 0; // also survives, which is a trap if you treat it as per-request
export const handler = async (event) => {
invocations += 1;
console.log(JSON.stringify({ invocations, requestId: event.requestContext?.requestId }));
// ...use ddb
return { statusCode: 200, body: 'ok' };
};The counter will read 1, 2, 3 on one environment and 1 on another that started in parallel. That’s harmless for a counter, but the same pattern bites with a module-level currentUser or a cache keyed by something that differs per tenant: request B can see request A’s data if both land on the same environment. Keep per-request state inside the handler.
And nothing persists reliably. Two environments don’t share memory, and any environment can disappear. A module-level cache is a cache, not a store.
Connections need care too. A database connection opened during init can be closed by the server while the environment sits frozen for 20 minutes. The next invocation tries to use it and gets ECONNRESET or “server closed the connection unexpectedly”. Clients that validate or reconnect on checkout handle this; hand-rolled single connections usually don’t.
Work after return is frozen, not finished
Here’s the bug that confuses people in Node. The handler kicks off a write and returns without awaiting it:
export const handler = async (event) => {
sendAnalytics(event); // not awaited
return { statusCode: 200 };
};As soon as the returned promise resolves, Lambda considers the invocation complete and freezes the environment. The analytics request may be half-sent. If the environment is reused a minute later, the pending work resumes during that other invocation, and its logs appear under the wrong request ID. If the environment is never reused, the work never completes. Either await everything that matters, or hand it off to something durable (put the event on a queue, which is a single fast awaited call).
The same applies to Python threads started in a handler and to anything scheduled with timers.
Timeouts: yours and the caller’s
The function timeout is configurable up to 900 seconds (15 minutes); the default is 3 seconds, which surprises people the first time a handler calls a slow API. When the timeout hits, Lambda stops the invocation, and the error is Task timed out after 3.00 seconds. The environment may be reset, so whatever was in progress is lost.
The caller often has a shorter fuse. API Gateway’s integration timeout defaults to 29 seconds, so a function configured for 5 minutes behind a REST API still gives the client a 504 at 29 seconds while the function keeps running and billing. A synchronous caller also limits payload size (6 MB for request and response). Anything long-running belongs on an asynchronous path: accept the request, put a job on a queue or start a Step Functions execution, and return 202 with a job ID.
Set your downstream timeouts shorter than the function timeout. If the function has 10 seconds and the HTTP client has none, a hung dependency burns all 10 seconds and the runtime kills you mid-operation. With a 3-second client timeout, your code gets the error and can decide what to do.
Concurrency is duration times rate
Each concurrent request needs its own environment, so the concurrency you need is roughly the request rate multiplied by the average duration:
200 req/s x 0.25s = 50 concurrent executions
200 req/s x 2.0s = 400 concurrent executions
1500 req/s x 0.8s = 1200 concurrent executions(Computed with a two-line Python loop; it’s Little’s law, nothing Lambda-specific.) The second row is the interesting one: the same traffic needs eight times the concurrency if a dependency slows from 250 ms to 2 seconds. Slow downstreams don’t just add latency, they multiply environments, and each of those may open a database connection.
Concurrency is capped per account per Region (1,000 by default, raisable through a quota request), and the pool is shared by every function in that account and Region. Past the cap, synchronous callers get 429 TooManyRequestsException; async events are queued and retried. Two controls shape this:
- Reserved concurrency guarantees a function a slice of the pool and also caps it at that number. Setting it to 0 is an emergency off switch.
- Provisioned concurrency keeps a number of environments initialised in advance, so those requests skip init. It costs money whether or not they’re used.
Memory is the other dial. You set it from 128 MB to 10,240 MB, and CPU scales in proportion, so a CPU-bound function at 128 MB can be slower and more expensive than at 1,024 MB because it runs much longer.
Interview practice: the leaking tenant ID
A multi-tenant API on Lambda occasionally returns another customer’s data. The code sets let tenant; at the top of the module and assigns tenant = getTenant(event) in the handler, then calls helpers that read tenant. Tests pass. What’s wrong, and why only occasionally?
Answer
tenant lives at module scope, so it belongs to the execution environment, not the request. Within one environment invocations are sequential, so a straightforward flow usually reads the right value. It goes wrong when something reads tenant after the handler has moved on: an un-awaited promise from request A that resumes during request B (after the freeze and thaw), a cached lookup keyed without the tenant, or a helper that runs before the assignment on an error path. Those only happen on warm environments with specific timing, which is why it’s intermittent and why local tests, which don’t reuse environments the same way, pass.
The fix is to make tenant context per-request: pass it as an argument, or create a context object inside the handler. Then audit for un-awaited work and module-level caches, and key any cache by tenant. I’d also mention that each environment handles one request at a time, so this isn’t a classic race between threads; it’s state outliving the request it was meant for.