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:

  1. 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.
  2. Runtime init, The language runtime boots. For Node.js, V8 starts and parses the code. For Java, the JVM loads.
  3. 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.