Diagnosing and fixing Lambda cold starts that matter
“Just use provisioned concurrency” is the answer I hear most often when someone complains about Lambda cold starts, and it’s usually wrong — it fixes the symptom, costs money every hour whether or not you’re invoked, and skips the part where you find out why your function is slow to initialize in the first place. Most cold start problems are fixable for free: a leaner deployment package, a runtime that doesn’t need to JIT-warm, or removing a VPC attachment that was never actually necessary. This is the diagnostic process I use before reaching for the checkbook.
Measure it before you touch anything
Every Lambda invocation writes a REPORT line to CloudWatch Logs. On a cold
start, it includes an Init Duration field that the warm-start version
doesn’t have:
REPORT RequestId: 8f3e... Duration: 412.33 ms Billed Duration: 413 ms
Memory Size: 512 MB Max Memory Used: 98 MB Init Duration: 621.47 ms
That Init Duration is the number to chase — it’s time spent on the
execution environment bootstrapping the runtime, running module-level code
outside your handler, and (if configured) resolving your VPC ENI. It is not
included in billed duration for most runtimes, so it won’t show up as a cost
spike, only as added latency your caller feels. Don’t confuse it with
Duration, which is your handler’s own execution time and is what you’re
billed for.
To pull this across many invocations instead of eyeballing one log line, use CloudWatch Logs Insights:
fields @timestamp, @initDuration, @duration
| filter ispresent(@initDuration)
| stats count(*) as coldStarts,
avg(@initDuration) as avgInit,
pct(@initDuration, 95) as p95Init
by bin(1h)
The filter ispresent(@initDuration) line is what isolates cold starts —
warm invocations don’t emit that field at all, so this query effectively
gives you a cold-start rate and latency distribution per hour, for free, with
no X-Ray required.
X-Ray is worth turning on (Tracing: Active in the function config) when you
need to see where inside init time is going — module imports, SDK client
construction, secrets/config fetched at startup — rather than just how long
it took. In the trace timeline, the Initialization segment sits before your
first subsegment; if it’s dominated by something like a boto3 client build
or a config file fetched from Secrets Manager at import time, that’s your
target, not the runtime itself.
Runtime choice moves the floor, not just the average
Interpreted runtimes (Python, Node.js, Ruby) have low inherent init overhead — a few hundred milliseconds — because there’s no compilation step, just interpreter startup and module loading. Compiled/JIT runtimes (Java, C# on .NET, and to a lesser extent Go, which compiles to a static binary ahead of time) trade that off: Go’s cold start is often the fastest of all because there’s no runtime to boot at all, while JVM- and CLR-based functions pay for class loading and JIT warm-up on every cold start, frequently 1-3 seconds for anything beyond a trivial handler.
If you’re stuck on Java for ecosystem reasons, Lambda SnapStart is the
single biggest lever available: it takes a pre-initialized, encrypted
snapshot of your execution environment’s memory and disk state after your
static initializers and any registered beforeCheckpoint hooks run, then
resumes from that snapshot on cold start instead of re-running init from
scratch. In practice this takes Java functions from multi-second cold starts
down to sub-200ms for many workloads. It’s opt-in per function
(SnapStart: ApplyOn: PublishedVersions), only applies to published
versions (not $LATEST), and anything non-deterministic in your static
init — random values, UUIDs, timestamps, opened network connections — needs
to be regenerated in a beforeCheckpoint/afterRestore hook, or you’ll ship
the same “random” value to every restored environment. That’s the gotcha that
bites people first: a cached DB connection captured in the snapshot resumes
in a stale, sometimes already-closed state on the other side.
Package size is the free win everyone skips
Init duration scales with how much code Lambda has to unzip and load before
your handler is reachable, and it’s rarely your own code that’s the problem —
it’s the dependency tree. A Node function that pulls in the entire AWS SDK v2
(aws-sdk) when it calls one S3 method drags in tens of megabytes it never
touches at runtime.
# See what's actually contributing to package size
du -sh node_modules/* | sort -rh | head -10
# Node: import only the client you use (SDK v3 is modular by design)
npm uninstall aws-sdk
npm install @aws-sdk/client-s3
For Python, the equivalent is trimming requirements.txt to what’s imported
at module scope and pushing anything only used inside rarely-hit code paths
to a lazy import inside the function body — module-level imports run during
init, so a heavy library imported “just in case” costs every cold start, not
just the invocations that use it. For any runtime, moving large,
rarely-changing dependencies into a Lambda Layer doesn’t reduce
unzip-and-load time by itself, but it does let you avoid re-uploading (and
Lambda re-validating) a multi-hundred-MB deployment package on every code
change, which matters more for deploy latency than cold starts.
Container-image Lambdas deserve a specific warning here: they’re pulled from ECR and have historically had noticeably worse cold starts than zip packages at larger image sizes, though Lambda’s own image-caching layer has narrowed that gap significantly since launch. If you’re on container images purely out of habit and your image is small, a zip package with a layer is very likely faster to cold-start.
VPC attachment: mostly a solved problem, still worth checking
Before 2019, attaching a Lambda to a VPC meant provisioning an ENI per concurrent execution environment, which could add 10 seconds or more to a cold start. AWS’s Hyperplane-based networking model eliminated most of that by sharing ENIs across functions in the same VPC/subnet/security-group combination, and current VPC-attached cold starts are typically within tens to a couple hundred milliseconds of non-VPC ones. If you’re still carrying a workaround from that era — a warm-up cron job, an oversized provisioned-concurrency pool sized for the old ENI cost — it’s worth re-measuring with the Logs Insights query above before assuming you still need it. The overhead that remains is small but non-zero, so don’t attach a function to a VPC it doesn’t need just because a sibling function does; scope VPC config per function, not per stack.
When provisioned concurrency actually earns its cost
Provisioned concurrency pre-initializes a pool of execution environments and keeps them warm, billed hourly whether invoked or not — it doesn’t reduce init duration, it just makes sure fewer invocations ever hit it. It’s worth the always-on cost when:
- You have a synchronous, latency-sensitive caller (API Gateway, an ALB, or a user-facing request path) where p99 latency is a product requirement, not a nice-to-have.
- Traffic is spiky rather than steady — a steady high-volume function naturally stays warm from its own invocation rate and rarely cold-starts regardless.
- You’ve already trimmed package size and picked the leanest viable runtime, and the remaining init duration is still unacceptable — provisioned concurrency should be the last lever, not the first.
It’s overkill for async, batch, or event-driven functions (S3/SQS/EventBridge triggers) where an extra few hundred milliseconds on an occasional invocation is invisible to anyone. Application Auto Scaling can scale provisioned concurrency on a schedule (business-hours-only) or target-tracking policy, so if you do need it, don’t just set a flat number sized for peak — that’s paying peak-capacity prices around the clock for a curve that isn’t flat.
Rollout: measure, trim, then buy
Start with the Logs Insights query above running against production traffic
for a week to get a real cold-start rate and p95 Init Duration baseline —
don’t optimize against a guess. Fix package bloat and runtime choice first;
both are free and often cut init duration by half or more on their own. Only
reach for provisioned concurrency once you’ve re-measured after those changes
and still have a latency-sensitive path that misses its SLA — and even then,
size it with Application Auto Scaling against your real traffic curve, not a
number that felt safe.
Join the discussion
Comments for this post live on social — reply to the thread.
Related posts
Cloud roundup: macOS Screen Sharing bug now under attack
A patched macOS Screen Sharing flaw is being exploited to plant crypto miners, a Windows Defender bypass has no fix yet, and EC2 gets built-in app health checks.
Cutting NAT gateway costs with VPC endpoints that actually help
How gateway and interface VPC endpoints replace NAT gateway traffic for AWS API calls, what they cost instead, and which traffic still has to go through NAT.
Cloud roundup: S3 finally names the policy that denied you
AWS S3 access-denied errors now name the exact policy ARN, Client VPN gets a scriptable CLI, and OpenAI ships authorized offensive-security models on Bedrock.