Node.js Serverless on AWS Lambda · Lambda Fundamentals for Node.js
Cold Starts and Init Code
Where code runs on Lambda (init versus handler), why clients belong outside the handler, and how to keep cold starts short.
Scenario
Example app: a product API on Lambda reads from a database through a client library and loads a 2 MB pricing configuration file. Traffic is spiky: quiet at night, bursts during campaigns.
What you see in production
p50 latency is 40 ms, but p99 is 1.8 s. The slow requests cluster at the start of each traffic burst. The database also reports far more new connections than requests should need, and occasionally refuses connections during bursts.
The naive code
Everything happens inside the handler, on every request:
export const handler = async (event) => {
const db = await createDbClient(process.env.DB_URL); // new TLS connection per request
const pricing = JSON.parse(await readFile("./pricing.json", "utf8"));
const product = await db.getProduct(event.pathParameters.id);
return json(200, applyPricing(product, pricing));
};
Reproduce it
The simulation runs ten requests over two execution environments, once creating the client per request and once at init:
const COST = { newEnvironment: 250, createClient: 120, loadConfig: 60, query: 15 }; // illustrative ms
function simulate(setupInHandler) {
const environments = new Map();
let connections = 0;
const latencies = [];
const requests = ["env-A", "env-A", "env-B", "env-A", "env-B", "env-A", "env-B", "env-A", "env-B", "env-A"];
for (const env of requests) {
let ms = 0;
if (!environments.has(env)) { // cold start: runtime + module init
ms += COST.newEnvironment;
if (!setupInHandler) { ms += COST.createClient + COST.loadConfig; connections++; }
environments.set(env, true);
}
if (setupInHandler) { ms += COST.createClient + COST.loadConfig; connections++; }
ms += COST.query;
latencies.push(ms);
}
latencies.sort((a, b) => a - b);
return { p50: latencies[4], max: latencies[9], connections };
}
console.log("setup in handler:", simulate(true));
console.log("setup at init: ", simulate(false));
Output
setup in handler: { p50: 195, max: 445, connections: 10 }
setup at init: { p50: 15, max: 445, connections: 2 }Root cause
Lambda runs your function in execution environments that are reused across many invocations. Code at module scope (outside the handler) runs once per environment, during the init phase; the handler runs per request. A cold start happens when a new environment must be created: download code, start the runtime, run init code. Work placed in the handler is repeated on every request, so it is paid constantly instead of once; per-request clients also open a new connection every time, which multiplies connections to the database.
The fix
Initialise expensive, reusable things at module scope and keep the handler lean:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import pricing from "./pricing.json" with { type: "json" }; // bundled and parsed once at init
const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); // reused across invocations
export const handler = async (event) => {
const product = await getProduct(db, event.pathParameters.id);
return json(200, applyPricing(product, pricing));
};
To reduce cold starts themselves: keep bundles small (next lesson), avoid heavy work at init that only some requests need (load it lazily and cache it), choose a memory size that gives enough CPU for init, and for strict latency targets use provisioned concurrency, which keeps environments initialised ahead of traffic. AWS bills the init phase too, so slow init costs money as well as latency.
Trade-offs
- State at module scope is per environment: never store per-request or per-user data there, and treat caches as possibly stale.
- Provisioned concurrency removes cold starts for the provisioned amount but is paid for whether or not it is used.
- Eager initialisation helps the common path but slows every cold start; lazy initialisation with caching suits rarely used dependencies.
Detect it in production
- The
REPORTlog line includesInit Durationon cold starts; graph how often it appears and how long it takes. - Compare p50 and p99 latency, and correlate p99 spikes with traffic bursts (scale-out creates new environments).
- Database connection counts that track request rate rather than concurrency.
Key points
- Module scope runs once per execution environment; the handler runs per request.
- Create clients and load configuration at init, not in the handler.
- Cold starts appear when scaling out; measure
Init Duration. - Provisioned concurrency for strict latency requirements.
Further reading: Lambda execution environment lifecycle · Best practices for working with Lambda functions
Practice questions
What is the difference between code at module scope and code inside the handler on Lambda?
Answer
Module-scope code runs once when an execution environment is initialised (the init phase) and its results are reused by every later invocation in that environment. Handler code runs on every invocation. Put reusable setup such as SDK clients at module scope.
When does a cold start happen, and how can you see it in logs?
Answer
When Lambda has to create a new execution environment: the first request, after a period of inactivity, after a deployment, or when concurrency increases. The REPORT log line of that invocation includes an Init Duration value.
Why is storing user data in a module-scope variable dangerous?
Answer
Execution environments are reused across requests from different users, so module-scope state leaks between requests. Only store things that are safe to share, such as clients and configuration, and treat caches as shared and possibly stale.
Open in the app to mark lessons complete, take notes and track your progress.