Lambda cold starts are the most discussed and most misunderstood aspect of serverless. Everyone worries about them. Few measure them. This post covers what actually causes cold starts, what mitigations work, and when you should skip Lambda entirely.
What Is a Cold Start
When Lambda receives a request and no warm execution environment is available, it must:
- Allocate a MicroVM (Firecracker)
- Download your deployment package
- Initialize the runtime (JVM, Node.js, Python, etc.)
- Run your initialization code (outside the handler)
- Invoke the handler
Steps 1-4 are the cold start. They happen before your code runs. Duration ranges from 100ms (small Node.js function) to several seconds (large Java function with heavy initialization).
Warm starts skip all of this. The execution environment is reused, and only your handler code runs.
What Actually Causes Slow Cold Starts
Deployment package size. Larger packages take longer to download and unpack. A 50MB Java JAR with all dependencies starts slower than a 5MB Node.js zip. Keep packages lean. Use Lambda layers for shared dependencies rather than bundling them into every function.
Runtime initialization. JVM cold starts are notoriously slow because the JVM itself takes time to initialize, then your application framework (Spring, etc.) adds more. Node.js and Python start faster because the runtime is lighter. This is a fundamental tradeoff, not a bug.
Initialization code. Everything outside your handler function runs on cold start: database connection pools, SDK clients, configuration loading. Heavy initialization (connecting to 5 databases, loading large ML models) directly adds to cold start time.
VPC configuration. Lambda functions in a VPC used to have significant cold start penalties because ENI attachment took time. AWS fixed this with improved VPC networking. Current VPC cold start overhead is minimal, but it is not zero.
Mitigations That Work
Keep Initialization Lean
Move heavy work out of initialization. Connect to databases lazily on first use, not at import time. Cache clients between invocations using global variables.
# Bad: connects on every cold start
import boto3
dynamodb = boto3.resource('dynamodb') # Runs on cold start
table = dynamodb.Table('my-table')
def handler(event, context):
return table.get_item(Key={'id': event['id']})
# Better: lazy initialization
import boto3
_table = None
def get_table():
global _table
if _table is None:
dynamodb = boto3.resource('dynamodb')
_table = dynamodb.Table('my-table')
return _table
def handler(event, context):
return get_table().get_item(Key={'id': event['id']})
The difference is minor for DynamoDB (fast to initialize) but significant for connection-heavy clients.
Provisioned Concurrency
Provisioned concurrency keeps a specified number of execution environments warm and ready. No cold starts for those invocations.
# SAM template
MyFunction:
Type: AWS::Serverless::Function
Properties:
ProvisionedConcurrencyConfig:
ProvisionedConcurrentExecutions: 10
You pay for provisioned concurrency whether it is used or not. It makes sense for latency-sensitive production workloads with predictable traffic. It does not make sense for spiky or unpredictable workloads (you will either over-provision or still get cold starts).
SnapStart (Java)
SnapStart takes a snapshot of the initialized execution environment and restores from it on cold start, skipping JVM and framework initialization. It can reduce Java cold starts from seconds to hundreds of milliseconds.
Limitations: only supports Java, does not work with all libraries (anything that does not handle snapshot/restore correctly breaks), and adds some complexity to deployment. If you are committed to Java on Lambda, use it. If you are choosing a runtime, pick Node.js or Python instead.
Smaller Packages
Use tree-shaking, remove unused dependencies, and consider Lambda layers. For Node.js, esbuild or similar bundlers dramatically reduce package size. For Python, avoid bundling large libraries you do not use.
When Cold Starts Do Not Matter
- Async/background processing: SQS-triggered workers, scheduled jobs, event handlers. A 2-second cold start on a 5-minute batch job is irrelevant.
- Low-traffic APIs: If your API gets 10 requests per hour, cold starts affect every request, but the absolute latency (500ms vs 100ms) rarely matters for internal tools or infrequent operations.
- Websites with CloudFront: If Lambda@Edge or CloudFront Functions handle the edge, origin Lambda cold starts are hidden behind caching.
When to Avoid Lambda Entirely
Sustained high throughput. If your function runs constantly at high concurrency, you are paying Lambda prices for what is effectively a always-on server. ECS Fargate or EC2 is cheaper at sustained load. The breakeven is roughly 40-60% utilization. Above that, containers win on cost.
Long-running processes. Lambda has a 15-minute timeout. Anything longer needs Step Functions orchestration, ECS, or EC2. Do not try to chain Lambdas to work around the timeout. Use the right tool.
Predictable latency requirements. If your SLA requires p99 under 200ms and you cannot tolerate occasional cold starts, Lambda adds risk. Provisioned concurrency helps but does not eliminate the operational complexity. A small ECS service with fixed capacity gives more predictable latency.
Heavy compute. Lambda CPU scales with memory allocation, but the price per compute unit is higher than EC2. For CPU-intensive workloads (video encoding, large data processing), the cost difference is significant.
The Decision Framework
- Is the workload spiky or intermittent? Yes: Lambda is ideal.
- Is sustained utilization above 50%? Yes: consider Fargate or EC2.
- Does p99 latency matter more than cost? Yes: test cold start impact, use provisioned concurrency if needed.
- Does the task exceed 15 minutes? Yes: not Lambda.
- Is the team already container-fluent? If yes, the operational advantage of Lambda shrinks.
Lambda is excellent for event-driven, spiky, short-duration workloads. It is a poor fit for sustained, predictable, long-running compute. The cold start discussion is a distraction from this more fundamental fit assessment.
Tradeoff Deep-Dive: Paying for Warmth
Provisioned concurrency converts a latency problem into a cost and complexity problem, and the exchange rate is not always favorable. Ten provisioned concurrent executions cost roughly the same whether they serve traffic or sit idle, so the real question is utilization. A function with steady daytime traffic and near-zero nights is the worst candidate: you pay for 24 hours of warmth to fix cold starts during 10. Application Auto Scaling on provisioned concurrency helps, but it reacts to metrics with a delay of minutes, which means the first wave of a traffic spike still cold-starts while the scaler catches up.
The subtler tradeoff is architectural coupling. Provisioned concurrency ties your latency SLA to a capacity plan, which is exactly the operational burden serverless promised to remove. Teams end up building the same capacity dashboards and scaling runbooks they would have built for containers, except now the knobs are less familiar. If you find yourself tuning provisioned concurrency per function per hour of day, step back and ask whether a small always-on service would be simpler to reason about.
SnapStart has a different tradeoff profile: it is free (no idle cost) but constrained. Snapshot and restore semantics break libraries that assume fresh process state: random number generators seeded at snapshot time, cached DNS resolutions, connection pools opened before the snapshot, temporary files. The failure mode is insidious because the function works in testing and fails intermittently in production on stale state. The mitigation is discipline: keep initialization deterministic and side-effect free, and use the before-checkpoint hook to close anything that should not survive the snapshot. If your init code cannot meet that bar, SnapStart will cost more debugging time than it saves in latency.
The honest interview answer: cold start optimization has diminishing returns. Going from 3 seconds to 300ms matters for user-facing APIs. Going from 300ms to 100ms rarely justifies the engineering time unless the function sits on a critical path with a tight SLA. Measure InitDuration from the REPORT logs, set a budget, optimize until you are under it, then stop.
System Architecture
+----------------+ +-------------------+
| Event source |---->| Lambda function |
| API GW SQS S3 | | warm pool and |
| and cron | | cold start pool |
+----------------+ +--------+----------+
|
v
+------------------+
| VPC ENI to RDS |
| and DynamoDB |
+------------------+
Event sources invoke the function; invocations land in either the provisioned (warm) pool or the on-demand pool that pays the cold-start cost. Both pools attach to the VPC through elastic network interfaces to reach RDS, DynamoDB, or downstream APIs. CloudWatch captures InitDuration on every cold start, which is the metric the whole optimization effort should be driven by.
Scalability
At 10x traffic, the account-level concurrency quota (default 1000 per region) becomes the ceiling, and burst limits (500 to 3000 initial burst depending on region) throttle sudden spikes before sustained capacity matters. SQS event source mappings scale their pollers, but each poller consumes concurrency, so the queue drains only as fast as the quota allows.
At 100x, reserved concurrency per function fragments the account quota and starves other functions (a noisy neighbor problem in reverse). Provisioned concurrency auto-scaling lags behind real traffic. Subnet IP exhaustion blocks scale-out of VPC-attached functions. And the classic failure: thousands of concurrent executions collapse the downstream database because RDS max_connections was sized for a connection-pooled service, not a fan-out of thousands.
The scaling strategy: buffer with SQS or EventBridge using batch windows and reserved concurrency caps, so downstream systems see controlled throughput instead of raw fan-out. Put RDS Proxy in front of relational databases for connection multiplexing. Shed load at API Gateway with throttling and usage plans. Split workloads across regions for true 100x. And load-test the quota and the downstream systems, not just the function.
Security Considerations
IAM: scope the execution role to the exact tables, queues, and secrets the function needs; no wildcard actions. Event sources authenticate through resource policies; function URLs sit behind IAM auth or a WAF, never public without authentication unless the endpoint is genuinely public. Network: security groups allow only required egress (Lambda needs no inbound). Encryption: environment variables encrypted with a customer-managed KMS key; secrets fetched from Secrets Manager at init, never baked into the deployment package.
Realistic attack vectors: event injection, where a crafted S3 event or API payload drives unexpected code paths (validate and schema-check every event); an over-broad execution role enabling privilege escalation if the function is compromised; dependency confusion or compromised layers (pin layer versions and scan them like any other dependency); SnapStart snapshots capturing secrets or tokens present in memory at snapshot time (keep credentials out of init-time globals; fetch lazily inside the handler or after restore).
Production Checklist
Signals: InitDuration p50 and p99 from REPORT logs; cold start rate (percentage of invocations with InitDuration); throttle count; concurrent executions versus quota; provisioned concurrency utilization and spillover; error rate; downstream latency.
Alert thresholds: any sustained throttling (throttle count above zero for five minutes is an incident, not a warning); cold start p99 exceeding the latency budget; spillover invocations above 5% of provisioned capacity; concurrent executions above 80% of account quota.
Failure modes seen in practice: quota exhaustion during a traffic spike causing mass throttling (runbook: SQS buffering absorbs it; raise the quota through support as the medium-term fix); provisioned concurrency set too low for a flash sale so spillover cold-starts defeat the purpose (auto-scale with target tracking and pre-warm before known events); SnapStart restore failures after a library upgrade (roll back the deployment; the snapshot is tied to the published version); RDS connection exhaustion from unthrottled fan-out (RDS Proxy plus reserved concurrency caps); VPC subnet IP exhaustion blocking new execution environments (monitor available IPs per subnet and size subnets for peak concurrency).
Runbook notes: alias routing gives instant rollback to the previous version; keep the previous published version around for SnapStart functions; document the quota-increase process before you need it; game-day a throttle storm with a load test against a canary.
Optimize cold starts when they affect user experience. Otherwise, focus on whether serverless is the right model at all.