skip to content

In an AWS Lambda function, why do you create SDK clients and database connections outside the handler, and what kind of state must never be cached there?

level: middleimportance: should knowfreq 62%

answer

  1. two scopes, two lifetimes
  2. runs once per environment
  3. shared by every later caller
  4. cross-tenant leak, not a race
  5. /tmp is reused too

basics

~20 s

Code outside the handler runs once per execution environment and its objects survive into every later invocation that environment serves, so expensive clients are built once. Never cache per-request state there — user identity, request context, or anything you must not leak between callers.

solid answer

~50 s

Lambda runs your module's top-level code once during the Init phase and then keeps the environment alive, so anything you assign to a module-level variable is still there on the next invocation. That makes global scope the right home for work you want to amortise: SDK clients, connection pools, parsed configuration, a secret or parameter fetched once and reused. The flip side is that this memory is shared across every request that environment handles, so it must only hold data that is safe for *any* caller to see. Stashing the current user, a tenant id, a request-scoped correlation id, or accumulated results in a global is a real bug that surfaces as one customer seeing another's data. The same rule applies to `/tmp`, which is also reused. Treat globals as a per-environment cache with no cross-environment coherence: warm state, never request state, and never a place you rely on for correctness.

code

javascript · 14 lines
javascript
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

// Init scope: built once per execution environment, reused by every invocation.
const s3 = new S3Client({});
const BUCKET = process.env.BUCKET;

// BUG: request-scoped value in Init scope leaks to the next caller.
let lastUser = null;

export const handler = async (event) => {
  lastUser = event.userId ?? lastUser; // stale on the next invocation
  const out = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: event.key }));
  return { user: lastUser, body: await out.Body.transformToString() };
};

go deeper

for a junior

Know that code outside the handler runs once and that clients belong there. Be able to point at a snippet and say which lines run per environment and which run per request.

for a middle

Explain the reuse contract in both directions: what to hoist for performance, and why request-scoped data in a global leaks to the next caller. Mention that /tmp is reused as well.

for a senior

Demonstrate that you reason about this at concurrency: caches are per-environment with no invalidation path, connection pools multiply by the number of environments, and cached secrets can outlive a rotation.

for a principal

Own the guardrails — a review rule or lint check against mutable module-level state, and a platform-level answer for shared caching and connection management rather than each team inventing one.

## Two scopes, two lifetimes A Lambda module has two regions of code with very different lifetimes. **Initialization scope** — everything outside the handler — executes once, during the Init phase, when Lambda builds a new execution environment. **Handler scope** executes once per invocation. Because Lambda freezes rather than destroys the environment after a response, whatever initialization scope left in memory is still there when the next event arrives. ```python import boto3, os # Init scope: runs once per environment s3 = boto3.client("s3") BUCKET = os.environ["BUCKET"] def handler(event, context): # Invoke scope: runs once per request return s3.get_object(Bucket=BUCKET, Key=event["key"]) ``` That one property is the whole optimisation. Constructing an AWS SDK client is not free — it resolves configuration, sets up credential providers, and builds an HTTP connection pool. Doing it inside the handler repeats that on every request and, worse, throws away the warm TLS connections you already paid to establish. Hoisting it moves the cost into Init, where a warm environment pays it zero times. ## What genuinely belongs in Init scope - **SDK and HTTP clients**, so keep-alive connections are reused across invocations. - **Configuration** read from environment variables, and any parsing or validation of it. - **Secrets and parameters** fetched once from Secrets Manager or Parameter Store, so you make one API call per environment instead of one per request. Cache them with a TTL — an environment can live long enough to outlast a rotation. - **Expensive constants**: compiled regexes, loaded ML feature maps, schema objects, template compilations. ## What must never live there The hazard is that this memory is shared by every invocation that environment serves, and those invocations belong to *different callers*. Anything request-scoped stored globally leaks across request boundaries: ```javascript let currentUser; // BUG: survives into the next request export const handler = async (event) => { currentUser = event.user ?? currentUser; // stale value on the next invocation return fetchOrdersFor(currentUser); }; ``` Under low concurrency this passes every test — one environment, one request at a time, so the value looks right. In production, the second caller routed to that environment inherits the first caller's identity. This class of bug is severe (a cross-tenant data leak) and nearly invisible in local testing. The same applies to accumulators (an array you push results into and never clear grows until the environment OOMs), to mutable client state you reconfigure per request, and to files you write into `/tmp`, which is per-environment and also reused. A useful discipline: initialization scope is **immutable after Init**, except for caches whose contents are safe for any caller. ## Freeze, thaw, and unfinished work Freezing suspends the environment the moment your handler returns. Background work you started but did not await does not keep running — it is paused, and it may resume mid-way through some *later* invocation, producing baffling interleaving. In Node.js the historical trap is `context.callbackWaitsForEmptyEventLoop`, which defaults to `true`, so Lambda waits for the event loop to drain; setting it to `false` returns immediately and leaves pending work in limbo. Either way, the discipline is the same: await everything you care about before returning. ## Concurrency and the limits of caching There is no shared cache across environments. If 200 environments are live, an in-memory cache is populated 200 separate times and each copy can hold a different value. That makes global caching fine for slow-changing data (configuration, a signing key, a feature-flag snapshot) and wrong for anything requiring coherence. When invalidation matters, put the data in an external store and let each environment cache it briefly. Also note that because one environment handles one invocation at a time, code inside a single environment is not racing against a concurrent invocation in the same memory space. That is a genuine simplification — but it does not make globals safe, because the danger is *sequential* leakage between different callers, not simultaneous access. ## Connection pools A pooled database client in Init scope is right in spirit but has an AWS-specific catch: each environment holds its own pool, so peak connections scale with concurrency, not with request rate. A hundred environments with a pool of ten each is a thousand connections against a database sized for far fewer. Keep the per-environment pool size small — often one — and put a proxy in front when the backing database has a hard connection ceiling.

  • Why is a per-environment in-memory cache a poor fit for data that changes frequently?
    Each execution environment holds its own copy with no invalidation channel between them. With a hundred environments live you have a hundred independently stale caches, and a value changed in the source is picked up at unpredictable times. It works well for slow-changing data like configuration or a signing key; for anything requiring coherence, read from a shared store and cache only for a short TTL.
  • You cache a database connection in Init scope and concurrency grows to 300 environments. What breaks?
    Connections scale with concurrency, not with request rate. Three hundred environments each holding even a small pool can exhaust a relational database's connection limit, and every new environment adds handshake latency to a cold start. Keep the per-environment pool tiny, and front the database with something that multiplexes connections such as RDS Proxy so the database sees a bounded number.
  • What is the risk of fetching a secret once in Init scope and reusing it forever?
    An execution environment can live for a long time, so it can outlive a credential rotation and start failing with authentication errors long after the rotation succeeded. Cache the secret with a TTL and refresh on expiry, and treat an auth failure as a signal to re-fetch rather than assuming the cached value is still valid.

saying these in an interview costs you the question

  • Constructs a new SDK client inside the handler on every request
  • Stores the current user or tenant in a module-level variable
  • Assumes an in-memory cache is shared across all invocations
  • Treats /tmp as private scratch space per request
  • Starts async work and returns without awaiting it

context