8 views
A Lambda-backed API answers quickly almost all the time, then a user waits an extra second after a quiet period or a deploy. Dashboards built on average Duration stay green, because the slow part, the cold start, is reported separately. Teams then reach for fixes they can’t verify: warmers, bigger memory everywhere, provisioned concurrency on every function.
This guide takes the opposite approach: measure cold starts properly first, then cut what the measurements show, using techniques from the Lambda documentation. It covers Node.js and Python, which have different levers, and it merges three older posts on cold starts, Python warm strategies and “zero cost” fixes into one guide with the hype removed.
TL;DR
- A cold start is the Init phase: extensions, runtime and your module-level code. It appears as
Init Durationin theREPORTlog line, it adds latency, and you are billed for it.- Track cold-start rate and Init p99 separately from
Duration.- Biggest free wins: fewer and lighter imports, bundling (Node.js), SDK clients created once at module level, no network calls at import time.
- More memory means more CPU (1,769 MB is the equivalent of one vCPU), which can shorten CPU-bound init.
- Use SnapStart for Python 3.12+ (and Java, .NET) and provisioned concurrency when latency targets are strict. Both work only on versions or aliases.
- If you’re still on
nodejs20.x: it was deprecated on April 30, 2026. Move tonodejs22.xornodejs24.x.
What a cold start actually is
Lambda runs your code in execution environments. When a request arrives and no idle environment exists (after a quiet period, after a deploy, or because traffic needs more concurrent environments), Lambda creates one. That Init phase has three steps: start extensions, bootstrap the runtime, and run your function’s static code (imports and anything outside the handler). Only then does the Invoke phase run your handler. A few documented facts shape every decision:- The Init phase is limited to 10 seconds for on-demand functions. If it doesn’t finish, Lambda retries Init on the first invocation with the function’s configured timeout.
- Lambda keeps environments for reuse, but it terminates them every few hours for runtime updates and maintenance, even for functions invoked continuously. Some cold starts are unavoidable.
- Code outside the handler runs once per environment and is reused across invocations. Code inside the handler runs every time.
- Init time is charged and adds latency, so cutting it saves money as well as time.
Step 1: Measure cold-start rate and Init percentiles
Init Duration appears only on the first invocation in a new environment. CloudWatch Logs Insights discovers it as @initDuration, so one query shows how often cold starts happen and how long they take:
text
Divide filter @type = "REPORT"
| stats count(*) as invocations,
count(@initDuration) as cold_starts,
pct(@initDuration, 50) as p50_init_ms,
pct(@initDuration, 99) as p99_init_ms,
pct(@duration, 99) as p99_duration_ms
by bin(1h)cold_starts by invocations for the cold-start rate. Two numbers then tell you where to work: a high rate with a small Init means you need warm capacity (or should accept it), while a low rate with a large Init means your code is slow to load. To see cold starts in application metrics too, emit a metric on the first invocation in each environment. The AWS_LAMBDA_INITIALIZATION_TYPE environment variable tells you whether the environment came from on-demand, provisioned-concurrency or snap-start initialization.
Step 2: Cut Node.js init
Node.js cold starts are mostly about how much JavaScript must be loaded and parsed. The Lambda docs note that bundling with esbuild, rollup or similar tools improves performance because it reduces I/O operations. A lean handler:typescript
Three habits matter more than any flag:
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
// Created once per execution environment, reused by every warm invocation.
// No network calls here: just construct the client.
const ddb = new DynamoDBClient({});
const TABLE = process.env.TABLE_NAME!;
const INIT_TYPE = process.env.AWS_LAMBDA_INITIALIZATION_TYPE;
let firstInvoke = true;
// Rarely used, heavy dependency: load it only on the path that needs it.
let renderPdf: ((html: string) => Promise<Buffer>) | undefined;
export const handler = async (event: { id: string; format?: string }) => {
if (firstInvoke) {
console.log(JSON.stringify({ metric: "cold_start", initType: INIT_TYPE }));
firstInvoke = false;
}
const item = await ddb.send(
new GetItemCommand({ TableName: TABLE, Key: { pk: { S: event.id } } })
);
if (event.format === "pdf") {
renderPdf ??= (await import("./render-pdf.js")).renderPdf;
const pdf = await renderPdf(JSON.stringify(item.Item));
return { statusCode: 200, isBase64Encoded: true, body: pdf.toString("base64") };
}
return { statusCode: 200, body: JSON.stringify(item.Item ?? {}) };
};- Import only what you use. Modular AWS SDK v3 clients (
@aws-sdk/client-dynamodb) instead of whole-SDK imports, and no “just in case” libraries. - No top-level
awaiton the network. Fetching a secret at import time makes every cold start wait for a network round trip. Fetch on first use and cache it, or use the Parameters and Secrets Lambda extension. - Lazy-load heavy, rare paths. A PDF renderer used by 1% of requests shouldn’t be parsed on every cold start.
bash
Should you bundle the AWS SDK or use the copy in the runtime? Every supported Node.js runtime includes a specific minor version of AWS SDK for JavaScript v3, not the latest, and the exact version depends on the runtime and Region. Bundling your own pins the version you tested; marking npx esbuild src/handler.ts \
--bundle --platform=node --target=node22 --format=cjs \
--minify --sourcemap=external \
--outfile=dist/index.js
cd dist && zip -q ../function.zip index.js && cd ..
ls -lh function.zip@aws-sdk/* as external (--external:@aws-sdk/*) makes the package smaller. Measure Init both ways and pick one deliberately.
Step 3: Cut Python init
Python cold starts are dominated by the import graph. Find out which imports are slow before changing anything; Python can report import timings itself:bash
Then keep module-level work to what every request needs:
# Run locally with the same dependencies as the function
python -X importtime -c "import app" 2> imports.log
sort -t '|' -k2 -n imports.log | tail -15python
Moving large dependencies into a Lambda layer doesn’t make them free to load: layer content is still extracted into the environment and imported at runtime. Layers help with sharing and packaging, not with import cost. If imports are still slow after trimming, Python 3.12 and later support SnapStart (Step 6).
import json
import os
import boto3
# Every request uses DynamoDB: create the client once, at import time.
_ddb = boto3.client("dynamodb")
_TABLE = os.environ["TABLE_NAME"]
_report_lib = None
def _reports():
"""Import the heavy reporting stack only when a report is requested."""
global _report_lib
if _report_lib is None:
import reporting # pulls in pandas; most requests never need it
_report_lib = reporting
return _report_lib
def handler(event, context):
body = json.loads(event["body"])
if body.get("action") == "report":
return {"statusCode": 200, "body": _reports().build(body)}
_ddb.put_item(TableName=_TABLE, Item={"pk": {"S": body["id"]}})
return {"statusCode": 200, "body": "{}"}Step 4: Right-size memory
Lambda allocates CPU in proportion to memory, from 128 MB to 10,240 MB. At 1,769 MB a function has the equivalent of one vCPU. CPU-bound init (parsing large bundles, compiling schemas, loading models) often gets faster with more memory, and because you pay for duration, a faster function can cost about the same. Test it rather than guessing:bash
Five cold samples per size is only a rough signal. For a fuller picture across many invocations, the open-source AWS Lambda Power Tuning tool automates this kind of test.
for mb in 512 1024 1769 3008; do
aws lambda update-function-configuration \
--function-name orders-api --memory-size "$mb" >/dev/null
aws lambda wait function-updated --function-name orders-api
# A configuration change means new execution environments, so the next calls are cold.
for i in 1 2 3 4 5; do
aws lambda invoke --function-name orders-api \
--cli-binary-format raw-in-base64-out --payload '{"id":"probe"}' /dev/null >/dev/null
done
echo "memory=${mb}MB: read Init Duration from the REPORT lines in CloudWatch Logs"
doneStep 5: Know what the VPC really costs
Old advice says VPC functions have multi-second cold starts. Today Lambda uses Hyperplane ENIs, created when you attach the function to a VPC with a given subnet and security-group combination, not on each cold start. While the ENI is being created, a new function staysPending, which can take several minutes. If a VPC function sits idle for 14 days, Lambda reclaims the ENI, sets the function Inactive, and the next invocation fails while it returns to Pending. What still slows VPC functions down is usually the network path: calls through NAT to public endpoints, or DNS lookups for services you could reach through VPC endpoints.
Step 6: SnapStart or provisioned concurrency
When lean code isn’t enough, two features remove most of the Init phase from the request path:- SnapStart (Java 11+, Python 3.12+, .NET 8+; not Node.js) runs Init when you publish a version and restores new environments from a snapshot. Python pays caching and restore charges; Java has no extra charge. See our SnapStart guide for hooks and uniqueness rules.
- Provisioned concurrency keeps a set number of environments initialized, ready to respond in double-digit milliseconds, for an extra charge. It’s configured on an alias or version, never
$LATEST, and runs your init code at allocation time, so heavy module-level setup is fine there. It can’t be combined with SnapStart on the same version.
yaml
Callers must invoke the Resources:
OrdersApi:
Type: AWS::Serverless::Function
Properties:
Runtime: nodejs22.x
Handler: index.handler
CodeUri: dist/
MemorySize: 1024
Architectures: [arm64]
AutoPublishAlias: live
ProvisionedConcurrencyConfig:
ProvisionedConcurrentExecutions: 5live alias; unqualified calls go to $LATEST and never use the provisioned environments. Put provisioned concurrency on the user-facing alias with predictable traffic, and leave async workers on-demand.
What about warmers?
Scheduled “keep warm” pings were popular before provisioned concurrency existed. A ping keeps the environment it reaches busy for a moment, but it doesn’t create the extra environments a traffic burst needs, and Lambda still recycles environments every few hours. Warmers also add invocations you must filter out of your metrics. Lean code plus SnapStart or provisioned concurrency is the supported path; if you still run a warmer, measure whether your cold-start rate actually dropped.Worked example: a Node.js orders API
- The Step 1 query shows a low cold-start rate but a long p99 Init on
orders-api, plus a jump in cold starts after each deploy. - You build with esbuild’s
--analyzeflag to see what’s in the bundle. It includes a PDF library used by one rarely called route, and the code fetches a secret with top-levelawait. - You lazy-load the PDF path, move the secret fetch to first use with caching, and bundle with esbuild. Init p99 drops in the next day’s query.
- The memory sweep shows Init improves up to 1,024 MB with little change above, so you settle there.
- Product still wants the first request after a deploy to be fast, so you add provisioned concurrency of 5 on the
livealias and confirm withAWS_LAMBDA_INITIALIZATION_TYPEthat those requests are served byprovisioned-concurrencyenvironments.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Provisioned concurrency configured, still cold starts | Callers invoke $LATEST or another version | Invoke the alias that has provisioned concurrency |
| Init sometimes exceeds 10 s and the first request is very slow | On-demand Init timed out and was retried during invocation | Remove network calls and heavy work from module load |
| Cold starts look fine but p99 latency is bad | Throttling and retries, not cold starts | Check Throttles; use reserved concurrency to protect critical functions |
| First call after two idle weeks fails (VPC) | Hyperplane ENI reclaimed; function Inactive | Retry after it returns to Active; add retries in callers |
| Bundle works locally, fails in Lambda | SDK marked external but code expects a newer SDK than the runtime includes | Bundle the SDK or check the runtime’s included version |
| Memory increase didn’t help | Init is waiting on I/O, not CPU | Remove import-time network calls; check VPC routing |
Production checklist
- ☐ Dashboard shows cold-start rate and Init p50/p99 next to Duration
- ☐ Runtime is supported (
nodejs22.x/nodejs24.x, current Python) - ☐ No network calls at import time; clients created once at module level
- ☐ Heavy, rare dependencies loaded lazily; Node.js code bundled
- ☐ Memory chosen from a measured sweep
- ☐ VPC functions use endpoints for AWS services they call
- ☐ SnapStart or provisioned concurrency only where measurements justify it, invoked through an alias
- ☐ Cold-start rate checked after every deploy
FAQ
Does arm64 reduce cold starts?
It can change performance and price, but the effect depends on your code and dependencies. Make sure native dependencies have arm64 builds, then measure Init on both architectures.Should every function get provisioned concurrency?
No. It costs money whether or not requests arrive. Use it for user-facing functions with strict latency targets and fairly predictable traffic.Why do I still see cold starts on a busy function?
Traffic growth needs new environments, deploys replace environments, and Lambda recycles environments every few hours for maintenance. A low, steady cold-start rate is normal.Are container images slower to cold start than zip packages?
It depends on image size and contents. Measure your own Init Duration rather than assuming either way.Official documentation
- Lambda execution environment lifecycle (Init phase, 10-second limit, billing)
- Best practices for working with Lambda functions
- Configure Lambda function memory
- Deploy Node.js Lambda functions with .zip archives (bundling) and runtime-included SDK versions
- Giving Lambda functions access to resources in a VPC (Hyperplane ENIs)
- Configuring provisioned concurrency
- Lambda SnapStart
- Lambda runtimes (deprecation dates)
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Newly added
- AWS Systems Manager Parameter Store: Hierarchical Runtime Config for Coding Agents
- Amazon Cognito Identity Pools: Federate Coding-Agent Tool Callers Without Long-Lived Keys
- Amazon Athena for Coding Agents: Query Tool Audit Logs and Eval Traces on S3 Without a Warehouse
- AWS Batch for Coding Agents: Parallel Overnight PR Reviews and Long-Running Jobs Without Lambda Timeouts
- Amazon Bedrock Prompt Management: Version and A/B Test Coding-Agent System Prompts
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
Already subscribed? or open the subscribe page.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.

Pingback: AI Coding in VPC: Private Bedrock Endpoints and Secret Hygiene - CheatCoders
Pingback: V8 Maglev vs TurboFan: When Tiering Ups Silently Hurt p99 - CheatCoders
Pingback: Node undici Dispatcher Pools: Kill Keep-Alive Storms Under Spikes - CheatCoders