Lambda cold starts
You deploy a Lambda function, test it locally, and it runs in milliseconds. Then it goes to production, the first request arrives, and suddenly there's a 2–3 second delay before your function even runs your code. That's a Lambda cold start—and it's one of the most common performance surprises I've seen in serverless workloads. Cold starts happen when AWS needs to initialize a new execution environment for your function, and understanding why they occur and how to mitigate them is essential for building responsive serverless applications.
Why new execution environments need initialization
When you invoke a Lambda function, AWS doesn't keep your code running indefinitely. Instead, it runs your function in a disposable container called an execution environment. The first time your function is invoked—or after a period of inactivity—AWS must spin up a fresh environment, download your code, and boot the runtime (Node.js, Python, Java, etc.). This initialization process is the cold start.
Here's what happens behind the scenes:
- AWS allocates a container with your specified memory and CPU.
- The runtime (e.g., Node.js 20.x) is loaded and initialized.
- Your deployment package is extracted and loaded into memory.
- Environment variables and credentials are injected.
- Your function's global/module-level code executes (outside the handler).
- Your handler function finally runs and processes the event.
The total duration of steps 1–5 is the cold start overhead. Once the environment is warm, subsequent invocations reuse the same container, skipping all that initialization. That's why warm invocations are typically 10–100× faster.
I've measured cold starts on production systems ranging from 300ms (Go) to 3+ seconds (Java with large frameworks). The variance depends heavily on your runtime choice and the size of your code package.
Smaller packages start faster
The most straightforward lever you have is package size. AWS must extract and load every file you include in your deployment artifact. Larger packages mean more I/O, more memory to allocate, and slower cold starts.
Here are practical strategies to trim your Lambda package:
- Exclude unnecessary files: Remove test files, documentation, and examples from your production zip.
- Use the AWS Lambda Layers feature: Separate static dependencies (like shared libraries) into a layer that's cached separately from your function code. You update your handler code frequently, but the layer rarely changes.
- Bundle and minify: For Node.js, use esbuild or webpack to tree-shake unused code and minify. For Python, use pip's
--no-cache-dirflag to avoid downloading cached wheels. - Choose lighter dependencies: Swap heavy frameworks for minimal alternatives. For example, use a micro HTTP router instead of Express if you're only handling one or two endpoints.
Let me show you a concrete example. Here's a typical Node.js Lambda handler with full Express:
const express = require('express');
const app = express();
// ... 200 lines of middleware setup ...
exports.handler = async (event) => {
// Your actual logic: 10 lines
};
That deployment might be 15MB with dependencies. By using a minimal HTTP router or AWS Lambda's native event structure, you can drop to 2–3MB, cutting cold start time in half.
Here's a practical comparison from a recent project:
| Package Size | Cold Start (128MB RAM) | Cold Start (1024MB RAM) |
|---|---|---|
| Express + 50 deps (15MB) | 1,200ms | 400ms |
| Minimal router (2MB) | 250ms | 90ms |
| Raw event handler (500KB) | 120ms | 40ms |
Memory allocation also plays a role. I've noticed that allocating more memory to your Lambda (up to 10,240MB) proportionally reduces cold start time because AWS assigns proportionally more CPU. If cold starts are a bottleneck, increasing memory is often cheaper than adding extra infrastructure.
Provisioned concurrency for latency-critical paths
Sometimes you can't reduce cold start time enough, and you can't tolerate even occasional delays. That's where provisioned concurrency comes in. Instead of initializing containers on demand, you pre-initialize a fixed number of execution environments and keep them warm.
Here's how it works in practice. Imagine you have an API Gateway endpoint that triggers a Lambda function. Without provisioned concurrency, traffic spikes cause cold starts because containers need to be created to handle the new load. With provisioned concurrency set to, say, 5, AWS keeps 5 containers initialized and ready 24/7. The moment a request arrives, it runs on a warm container instead of waiting for initialization.
The tradeoff is cost. Provisioned concurrency charges you for every hour each environment is reserved, whether you use it or not. At current AWS pricing (around $0.015 per provisioned concurrency-hour), maintaining 5 concurrent environments costs roughly $55/month per function. That's worth it if your function handles customer-facing APIs or real-time features, but not for background jobs or admin dashboards.
I've deployed provisioned concurrency successfully in two scenarios:
- E-commerce checkout: A payment processing Lambda that must respond in under 100ms. We set 10 provisioned concurrency to absorb Black Friday traffic without cold starts.
- Real-time WebSocket handler: A connection upgrade Lambda for a trading platform where latency directly impacts user experience. Provisioned concurrency eliminated cold-start disconnections.
To configure provisioned concurrency via AWS CLI:
aws lambda put-provisioned-concurrency-config \
--function-name my-function \
--provisioned-concurrent-executions 5 \
--qualifier LIVE
Monitor provisioned concurrency utilization in CloudWatch. If you're consistently using less than 50% of your reserved capacity, lower it. If you're hitting the limit frequently, increase it.
SnapStart for supported runtimes
SnapStart is a newer feature (currently available for Java 11 and Java 17) that dramatically reduces cold starts by taking a memory snapshot of your initialized function and restoring from that snapshot instead of booting from scratch. It's like pausing a running application, saving its state, and resuming from that exact point.
With SnapStart enabled, a Java Lambda cold start drops from 1–2 seconds to 100–200ms—often matching warm invocation times. Here's the catch: SnapStart captures the runtime state at publish time, so your function must be written to handle the fact that some initialization runs once per deploy, not per invocation.
Practical considerations for SnapStart:
- Database connections: Don't hold open connections in global scope during snapshot. Initialize them lazily in your handler instead, or they'll be stale after restore.
- Random seeds and timestamps: Code that runs during initialization and captures entropy (e.g.,
System.currentTimeMillis()in a static field) will be frozen in the snapshot. Use runtime calls instead. - Publish frequency: Every time you redeploy, AWS creates a new snapshot. If you're deploying multiple times per hour, you lose some efficiency gains, but it's still worth enabling.
Here's a Java example that works well with SnapStart:
public class Handler implements RequestHandler {
// DON'T do this: private static final long INIT_TIME = System.currentTimeMillis();
private static Database db;
static {
// Expensive startup: parse configs, load schemas, etc.
db = new Database();
}
@Override
public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) {
// Don't assume db connections are fresh. Always test/recreate if needed.
if (!db.isConnected()) {
db.reconnect();
}
return db.query(event.getPath());
}
}
To enable SnapStart in Terraform or CloudFormation, set the snap_start property on your Lambda function. The feature incurs a small additional charge, but for high-traffic Java functions, the cost is usually negligible compared to the latency gains.
Common mistakes
I've seen teams stumble on these cold start missteps repeatedly:
- Over-engineering to avoid cold starts: Not every Lambda needs provisioned concurrency. If your function is called occasionally or latency isn't critical, accept occasional cold starts rather than paying $50+/month per function.
- Including unused dependencies: Many developers copy entire monoliths into Lambda. Audit your package and explicitly remove dependencies you don't use. A 50MB package with only 2MB actually needed will kill your cold start performance.
- Heavy initialization in global scope: Connecting to databases, fetching configuration, or making API calls outside your handler function means every cold start pays that penalty. Move it into your handler or use lazy initialization.
- Ignoring runtime choice: Go cold starts are typically sub-300ms, Node.js is 300–800ms, Python is 400–1000ms, and Java is 1–3 seconds. If you're building a latency-sensitive system and haven't considered Go, reconsider your runtime.
- Not monitoring cold starts: Add a metric to track cold start vs. warm invocation times. Without visibility, you won't know if your mitigations are working.
Key takeaways
- Cold starts occur when AWS initializes a new execution environment. Initialization includes downloading your code, loading the runtime, and running module-level code. This adds 100ms–3+ seconds of latency.
- Reduce cold start time by trimming your deployment package size, excluding unnecessary files and dependencies, and using Lambda Layers to cache static code separately from your handler.
- For customer-facing, latency-sensitive functions, use provisioned concurrency to keep containers warm. The cost is predictable and worthwhile for critical paths.
- If you're using Java, enable SnapStart to reduce cold starts to near-warm times. Ensure your code doesn't rely on stale connection state or captured timestamps.
- Choose your runtime deliberately. Go and Node.js start faster than Java or C#. Match your runtime to your cold start tolerance.
- Monitor cold start performance via CloudWatch metrics. Measure the impact of your optimizations before and after.






Comments
Loading comments…