HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 592 min read

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

  1. INIT — download the zip, boot the runtime, run module-level code (imports, client construction). 100ms → several seconds.
  2. INVOKE — your handler body. Billed.
  3. 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?