Anatomy of a Cold Start
Before optimizing anything, it is essential to understand the anatomy of a cold start. A Lambda cold start has three distinct phases:
- Infrastructure init, AWS provisions a microVM (Firecracker), downloads the deployment package from S3, and sets up the execution environment. There is zero control over this phase.
- Runtime init, The language runtime boots. For Node.js, V8 starts and parses the code. For Java, the JVM loads.
- Application init, The handler module loads, static initializers run, connections are established.
Most people conflate all three and then wonder why their "optimization" didn't move the needle. Only phases 2 and 3 can be affected directly.
Here is how to extract clean measurements from CloudWatch Logs Insights:
filter @type = "REPORT"
| parse @log /\/aws\/lambda\/(?<functionName>[^\/]+)/
| stats
avg(@initDuration) as avg_init,
pct(@initDuration, 50) as p50_init,
pct(@initDuration, 99) as p99_init,
count(*) as cold_starts
by functionName
| sort avg_init desc
This returns the Init Duration which covers phases 2 and 3 but not phase 1. Phase 1 is typically 100-400ms and there is nothing that can be done about it except using provisioned concurrency.
Provisioned concurrency: the expensive guarantee
Provisioned concurrency is the brute-force solution. It tells AWS "keep N environments warm at all times" and they comply. Cold starts vanish for those N concurrent executions.
# serverless.yml
functions:
checkout:
handler: src/checkout.handler
runtime: nodejs20.x
memorySize: 1024
provisionedConcurrency: 50
events:
- http:
path: /checkout
method: post
The math is straightforward but sobering. At 1024MB memory in us-east-1, provisioned concurrency costs roughly $0.0000041667 per GB-second. For 50 provisioned instances running 24/7:
50 instances * 1 GB * 86400 sec/day * 30 days * $0.0000041667/GB-sec = ~$540/month
That is $540/month just to keep functions warm, before any invocation costs. For a checkout function handling around 200 concurrent requests during peak, provisioning 200 instances would cost $2,160/month. A smarter approach is needed.
Application Auto Scaling for provisioned concurrency
The commonly overlooked technique is combining provisioned concurrency with Application Auto Scaling. A baseline is set and scaling handles the rest:
aws application-autoscaling register-scalable-target \
--service-namespace lambda \
--resource-id function:checkout-prod:checkout-prod-alias \
--scalable-dimension lambda:function:ProvisionedConcurrency \
--min-capacity 10 \
--max-capacity 200
aws application-autoscaling put-scaling-policy \
--service-namespace lambda \
--resource-id function:checkout-prod:checkout-prod-alias \
--scalable-dimension lambda:function:ProvisionedConcurrency \
--policy-name checkout-scaling \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 0.7,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "LambdaProvisionedConcurrencyUtilization"
},
"ScaleInCooldown": 300,
"ScaleOutCooldown": 60
}'
Target 70% utilization. Scale out fast (60s cooldown), scale in slow (300s). This approach can reduce cost to roughly $180/month while covering 95% of traffic spikes without cold starts. The remaining 5%, sudden, unpredictable bursts, still hit cold starts. That is where runtime optimizations matter.
SnapStart: Java's answer (finally)
For Java on Lambda, SnapStart is transformational. It takes a Firecracker microVM snapshot after the initialization phase completes, then restores from that snapshot instead of booting the JVM from scratch.
Migrating a Spring Boot 3.2 function from standard Java 21 runtime to SnapStart yields significant results:
| Metric | Before SnapStart | After SnapStart |
|---|---|---|
| p50 cold start | 4,200ms | 620ms |
| p99 cold start | 8,100ms | 1,100ms |
| Memory at init | 512MB | 512MB |
That is an 85% reduction at p50. But there are caveats that AWS blog posts conveniently gloss over.
The uniqueness problem
SnapStart restores from a frozen snapshot. That means anything initialized before the snapshot, random number generators, UUIDs, connection tokens, will be identical across all restored instances. AWS provides runtime hooks to handle this:
import software.amazon.lambda.snapstart.SnapshotRestore;
public class CheckoutHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
private SecureRandom secureRandom;
private HikariDataSource dataSource;
public CheckoutHandler() {
// This runs BEFORE the snapshot
dataSource = initializeDataSource();
// Register a restore hook
SnapshotRestore.registerRestoreHook(() -> {
// This runs AFTER restore, on every cold start
secureRandom = new SecureRandom();
secureRandom.nextBytes(new byte[32]); // Force re-seeding
// Re-establish database connections
if (dataSource != null) {
dataSource.getHikariPoolMXBean().softEvictConnections();
}
});
}
// ...
}
CRaC compatibility
Under the hood, SnapStart uses the OpenJDK CRaC (Coordinated Restore at Checkpoint) API. Writing restore hooks against the CRaC API directly ensures they work on both SnapStart and standalone CRaC deployments:
import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;
public class ConnectionManager implements Resource {
public ConnectionManager() {
Core.getGlobalContext().register(this);
}
@Override
public void beforeCheckpoint(Context<? extends Resource> context) {
// Close connections before snapshot
connectionPool.close();
}
@Override
public void afterRestore(Context<? extends Resource> context) {
// Re-establish after restore
connectionPool = createPool();
}
}
LLRT: when the JIT is abandoned entirely
LLRT (Low Latency Runtime) is Amazon's experimental JavaScript runtime built on QuickJS instead of V8. The key insight: V8 spends significant cold-start time on JIT compilation warmup. QuickJS is a pure interpreter, no JIT, no compilation tiers, no warmup. The trade-off is raw throughput for startup speed.
Benchmarking a moderately complex API function (JSON parsing, DynamoDB calls, response transformation) across runtimes reveals the following:
Standard Node.js 20:
Cold start: 340ms
Warm invocation: 12ms
LLRT v1.2:
Cold start: 48ms
Warm invocation: 28ms
Cold start drops by 86%, but warm invocations are 2.3x slower. For functions that initialize often but run briefly (API gateways, webhooks, event processors), LLRT is a clear win. For compute-heavy functions that stay warm, it is a loss.
Deploying LLRT requires a custom runtime layer:
FROM public.ecr.aws/awsguru/llrt:v1.2-arm64 as llrt
FROM public.ecr.aws/lambda/provided:al2023-arm64
COPY --from=llrt /usr/local/bin/llrt /var/runtime/bootstrap
COPY dist/ /var/task/
CMD ["index.handler"]
One critical gotcha: LLRT doesn't support the full Node.js API. No fs module (use the /tmp workaround), limited crypto (no createCipheriv), no native modules. The compatibility matrix should be checked before committing.
V8 snapshots: the DIY approach
V8 has a built-in snapshot mechanism that serializes the heap state after initialization. Cloudflare Workers uses this extensively, it is why Workers cold starts are essentially zero. The same technique can be used with custom runtimes.
The idea: run initialization code, serialize the V8 heap to a binary blob, then on cold start, deserialize the blob instead of re-executing initialization. Here is the conceptual flow using v8.startupSnapshot:
// snapshot-builder.mjs, run at build time
const v8 = require('node:v8');
v8.startupSnapshot.setDeserializeMainFunction((data) => {
// This runs on restore
const { handler } = require('./dist/handler');
module.exports = { handler };
});
// Pre-initialize expensive objects
const config = require('./config');
const routes = require('./routes');
const validators = require('./validators');
// These objects are now in the snapshot
v8.startupSnapshot.addSerializeCallback(() => {
console.log('Serializing V8 heap snapshot...');
});
Build the snapshot:
node --snapshot-blob=snapshot.blob --build-snapshot snapshot-builder.mjs
Then start with it:
node --snapshot-blob=snapshot.blob index.mjs
In testing, this cut the Node.js initialization phase from 180ms to 35ms for a function with heavy module loading. Combined with the LLRT approach or standard Node.js runtime, it is a powerful technique. The downside is build complexity and the fact that not everything serializes cleanly (Promises, open handles, etc.).
The real methodology: how to benchmark properly
Every article on cold starts shows a single number. That number is meaningless without context. Here is a robust benchmarking methodology:
#!/usr/bin/env python3
"""Cold start benchmark harness"""
import boto3
import time
import json
import statistics
from concurrent.futures import ThreadPoolExecutor
lambda_client = boto3.client('lambda', region_name='us-east-1')
def force_cold_start(function_name: str) -> dict:
"""Update an env var to force new execution environments"""
lambda_client.update_function_configuration(
FunctionName=function_name,
Environment={'Variables': {'BENCH_TS': str(time.time_ns())}}
)
# Wait for update to propagate
waiter = lambda_client.get_waiter('function_updated_v2')
waiter.wait(FunctionName=function_name)
time.sleep(2) # Additional buffer
# Invoke
start = time.perf_counter_ns()
response = lambda_client.invoke(
FunctionName=function_name,
Payload=json.dumps({"benchmark": True}),
LogType='Tail'
)
end = time.perf_counter_ns()
# Parse REPORT line from logs
import base64
log_result = base64.b64decode(response['LogResult']).decode()
init_duration = None
for line in log_result.split('\n'):
if 'Init Duration' in line:
init_duration = float(line.split('Init Duration: ')[1].split(' ms')[0])
return {
'e2e_ms': (end - start) / 1_000_000,
'init_duration_ms': init_duration,
}
def benchmark(function_name: str, iterations: int = 30):
results = []
for i in range(iterations):
result = force_cold_start(function_name)
results.append(result)
print(f" [{i+1}/{iterations}] init={result['init_duration_ms']:.1f}ms e2e={result['e2e_ms']:.1f}ms")
init_durations = [r['init_duration_ms'] for r in results if r['init_duration_ms']]
print(f"\n--- {function_name} ({len(init_durations)} samples) ---")
print(f" p50: {statistics.median(init_durations):.1f}ms")
print(f" p90: {sorted(init_durations)[int(len(init_durations)*0.9)]:.1f}ms")
print(f" p99: {sorted(init_durations)[int(len(init_durations)*0.99)]:.1f}ms")
print(f" mean: {statistics.mean(init_durations):.1f}ms")
print(f" std: {statistics.stdev(init_durations):.1f}ms")
if __name__ == '__main__':
benchmark('checkout-prod', iterations=30)
Key points: 30 iterations minimum, force cold starts by updating environment variables, measure Init Duration from the REPORT line (not end-to-end latency which includes network), and always report percentiles, means are misleading for bimodal distributions.
The decision matrix
After thorough benchmarking, the following decision framework emerges:
Use provisioned concurrency when: guaranteed latency SLAs are required, cost is justified by business impact, and traffic is somewhat predictable.
Use SnapStart when: the runtime is Java/Kotlin, the uniqueness constraints are manageable, and initialization is JVM-dominated.
Use LLRT when: the runtime is Node.js, functions are short-lived and IO-bound, and full Node.js API compatibility is not required.
Use V8 snapshots when: there is heavy module initialization, investment in build pipeline complexity is acceptable, and fine-grained control is needed.
Combine approaches: Provisioned concurrency + SnapStart. LLRT + minimal dependencies. V8 snapshots + tree shaking. The techniques are complementary, not mutually exclusive.
As a practical example, consider a checkout function that benefits from provisioned concurrency (auto-scaled, 10-200) plus aggressive dependency tree-shaking that cuts the bundle from 12MB to 1.8MB. Cold starts drop from 1,247ms to 180ms, and provisioned concurrency catches the predictable load. Unpredictable spikes hit a 180ms cold start instead of 1,247ms, a significant improvement for latency-sensitive workloads.