Serverless Cold Starts & Provisioned Concurrency
the warm-up ping myth
Cloud infrastructurecloud infrastructure / serverless
The myth
"I hit my Lambda with a cron ping every 5 minutes so it stays warm. Cold starts: solved."
Actually
Your ping keeps ONE execution environment alive. Cold starts are about CONCURRENCY, not time.
Lambda runs exactly one request per sandbox at a time. 100 simultaneous requests + 1 warm env = 99 cold starts. The ping does nothing during a burst — which is the only moment cold starts actually hurt you.
The right mental model — 3 phases
- INIT — download the zip, boot the runtime, run module-level code (imports, client construction). 100ms → several seconds.
- INVOKE — your handler body. Billed.
- FROZEN — the env is kept alive ~5–15 min and reused.
Cold starts ≈ number of NEW environments created ≈ how fast your peak concurrency grows. Not your request count.
Real numbers
- Node "hello world" init: ~150–250ms
- Python + boto3 + pandas imports: ~1.2s
- Java/Spring Boot: 3–6s
- VPC-attached: used to add ~10s. Since Hyperplane ENIs it's <100ms. "Never put Lambda in a VPC" is outdated advice.
What actually moves the needle
- Put expensive setup at module scope so the frozen env reuses it. Bonus: during INIT you get full CPU (~2 vCPU) regardless of your memory setting.
- Raise memory. CPU scales with it — 512MB → 1769MB often cuts total duration enough to be CHEAPER, not more expensive.
- Shrink the bundle. A 60MB zip pays download cost on every cold start; 3MB doesn't.
- Provisioned concurrency = N envs pre-INITed. You pay hourly whether traffic comes or not.
- Java/.NET: SnapStart snapshots the post-init state and restores it.
The one-line difference
// ✅ built once, reused for the life of the env
const db = new Client(cfg); await db.connect();
export const handler = async (e) => db.query(...);
// ❌ built per request — adds latency to EVERY call, not just cold ones
export const handler = async (e) => {
const db = new Client(cfg); await db.connect();
...
};
Gotchas that bite intermediates
- Module scope persists across invocations of the same env. Cache config and clients there — never per-user data. Request-scoped state in a global is a cross-tenant data leak.
- Work you didn't await keeps running... until the env FREEZES mid-flight and resumes minutes later on the next request. That's why you see log lines from an hour ago. Await everything before returning.
- Provisioned concurrency isn't a ceiling. Traffic above it spills to on-demand, with normal cold starts.
- p50 latency hides all of this. Cold starts live in p99.
Your exercise
Run this in CloudWatch Logs Insights on a real function:
filter @type = "REPORT"
| stats count() as invocations,
sum(ispresent(@initDuration)) as coldStarts,
pct(@initDuration, 99) as p99Init
What % of your invocations were cold, and what's the p99 init? Then move one client construction from inside the handler to module scope and re-run it tomorrow.
Question to think about: if your function gets exactly 1 request per second and each takes 2 seconds, how many execution environments does Lambda need — and how many cold starts do you pay?