JSTGTECH
← Back to blog

Diagnosing and fixing Lambda cold starts that matter

6 min read

“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.

Related posts