{"schemaVersion":1,"id":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies","title":"Serverless Cold Starts: A Comprehensive Optimization Playbook","description":"Provisioned concurrency, SnapStart, LLRT, V8 snapshots, a thorough benchmark comparison revealing which cold start optimization strategies actually deliver measurable results.","language":"en","datePublished":"2025-10-03","dateModified":null,"contentFormat":"text/markdown","content":"Serverless\n\n# Serverless Cold Starts: A Comprehensive Optimization Playbook\n\n2025-10-038 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/serverless-cold-starts-optimization-strategies>)\n\n## Anatomy of a Cold Start\n\nBefore optimizing anything, it is essential to understand the anatomy of a cold start. A Lambda cold start has three distinct phases:\n\n1. **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.\n2. **Runtime init**, The language runtime boots. For Node.js, V8 starts and parses the code. For Java, the JVM loads.\n3. **Application init**, The handler module loads, static initializers run, connections are established.\n\nMost people conflate all three and then wonder why their \"optimization\" didn't move the needle. Only phases 2 and 3 can be affected directly.\n\nHere is how to extract clean measurements from CloudWatch Logs Insights:\n\n```sql\nfilter @type = \"REPORT\"\n| parse @log /\\/aws\\/lambda\\/(?<functionName>[^\\/]+)/\n| stats\n avg(@initDuration) as avg_init,\n pct(@initDuration, 50) as p50_init,\n pct(@initDuration, 99) as p99_init,\n count(*) as cold_starts\n by functionName\n| sort avg_init desc\n```\n\nThis 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.\n\n## Provisioned concurrency: the expensive guarantee\n\nProvisioned concurrency is the brute-force solution. It tells [AWS](<https://aws.com/>) \"keep N environments warm at all times\" and they comply. Cold starts vanish for those N concurrent executions.\n\n```yaml\n# serverless.yml\nfunctions:\n checkout:\n handler: src/checkout.handler\n runtime: nodejs20.x\n memorySize: 1024\n provisionedConcurrency: 50\n events:\n - http:\n path: /checkout\n method: post\n```\n\nThe 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:\n\n```\n50 instances * 1 GB * 86400 sec/day * 30 days * $0.0000041667/GB-sec = ~$540/month\n```\n\nThat 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.\n\n### Application Auto Scaling for provisioned concurrency\n\nThe commonly overlooked technique is combining provisioned concurrency with Application Auto Scaling. A baseline is set and scaling handles the rest:\n\n```bash\naws application-autoscaling register-scalable-target \\\n --service-namespace lambda \\\n --resource-id function:checkout-prod:checkout-prod-alias \\\n --scalable-dimension lambda:function:ProvisionedConcurrency \\\n --min-capacity 10 \\\n --max-capacity 200\n\naws application-autoscaling put-scaling-policy \\\n --service-namespace lambda \\\n --resource-id function:checkout-prod:checkout-prod-alias \\\n --scalable-dimension lambda:function:ProvisionedConcurrency \\\n --policy-name checkout-scaling \\\n --policy-type TargetTrackingScaling \\\n --target-tracking-scaling-policy-configuration '{\n \"TargetValue\": 0.7,\n \"PredefinedMetricSpecification\": {\n \"PredefinedMetricType\": \"LambdaProvisionedConcurrencyUtilization\"\n },\n \"ScaleInCooldown\": 300,\n \"ScaleOutCooldown\": 60\n }'\n```\n\nTarget 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.\n\n## SnapStart: Java's answer (finally)\n\nFor 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.\n\nMigrating a Spring Boot 3.2 function from standard Java 21 runtime to SnapStart yields significant results:\n\n| Metric | Before SnapStart | After SnapStart |\n| --- | --- | --- |\n| p50 cold start | 4,200ms | 620ms |\n| p99 cold start | 8,100ms | 1,100ms |\n| Memory at init | 512MB | 512MB |\n\nThat is an 85% reduction at p50. But there are caveats that AWS blog posts conveniently gloss over.\n\n### The uniqueness problem\n\nSnapStart 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:\n\n```java\nimport software.amazon.lambda.snapstart.SnapshotRestore;\n\npublic class CheckoutHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {\n\n private SecureRandom secureRandom;\n private HikariDataSource dataSource;\n\n public CheckoutHandler() {\n // This runs BEFORE the snapshot\n dataSource = initializeDataSource();\n\n // Register a restore hook\n SnapshotRestore.registerRestoreHook(() -> {\n // This runs AFTER restore, on every cold start\n secureRandom = new SecureRandom();\n secureRandom.nextBytes(new byte[32]); // Force re-seeding\n\n // Re-establish database connections\n if (dataSource != null) {\n dataSource.getHikariPoolMXBean().softEvictConnections();\n }\n });\n }\n\n // ...\n}\n```\n\n### CRaC compatibility\n\nUnder 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:\n\n```java\nimport org.crac.Context;\nimport org.crac.Core;\nimport org.crac.Resource;\n\npublic class ConnectionManager implements Resource {\n\n public ConnectionManager() {\n Core.getGlobalContext().register(this);\n }\n\n @Override\n public void beforeCheckpoint(Context<? extends Resource> context) {\n // Close connections before snapshot\n connectionPool.close();\n }\n\n @Override\n public void afterRestore(Context<? extends Resource> context) {\n // Re-establish after restore\n connectionPool = createPool();\n }\n}\n```\n\n## LLRT: when the JIT is abandoned entirely\n\nLLRT (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.\n\nBenchmarking a moderately complex API function (JSON parsing, DynamoDB calls, response transformation) across runtimes reveals the following:\n\n```\nStandard Node.js 20:\n Cold start: 340ms\n Warm invocation: 12ms\n\nLLRT v1.2:\n Cold start: 48ms\n Warm invocation: 28ms\n```\n\nCold 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.\n\nDeploying LLRT requires a custom runtime layer:\n\n```dockerfile\nFROM public.ecr.aws/awsguru/llrt:v1.2-arm64 as llrt\n\nFROM public.ecr.aws/lambda/provided:al2023-arm64\n\nCOPY --from=llrt /usr/local/bin/llrt /var/runtime/bootstrap\nCOPY dist/ /var/task/\n\nCMD [\"index.handler\"]\n```\n\nOne 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.\n\n## V8 snapshots: the DIY approach\n\nV8 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.\n\nThe 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 `:\n\n```javascript\n// snapshot-builder.mjs, run at build time\nconst v8 = require('node:v8');\n\nv8.startupSnapshot.setDeserializeMainFunction((data) => {\n // This runs on restore\n const { handler } = require('./dist/handler');\n module.exports = { handler };\n});\n\n// Pre-initialize expensive objects\nconst config = require('./config');\nconst routes = require('./routes');\nconst validators = require('./validators');\n\n// These objects are now in the snapshot\nv8.startupSnapshot.addSerializeCallback(() => {\n console.log('Serializing V8 heap snapshot...');\n});\n```\n\nBuild the snapshot:\n\n```bash\nnode --snapshot-blob=snapshot.blob --build-snapshot snapshot-builder.mjs\n```\n\nThen start with it:\n\n```bash\nnode --snapshot-blob=snapshot.blob index.mjs\n```\n\nIn 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.).\n\n## The real methodology: how to benchmark properly\n\nEvery article on cold starts shows a single number. That number is meaningless without context. Here is a robust benchmarking methodology:\n\n```python\n#!/usr/bin/env python3\n\"\"\"Cold start benchmark harness\"\"\"\n\nimport boto3\nimport time\nimport json\nimport statistics\nfrom concurrent.futures import ThreadPoolExecutor\n\nlambda_client = boto3.client('lambda', region_name='us-east-1')\n\ndef force_cold_start(function_name: str) -> dict:\n \"\"\"Update an env var to force new execution environments\"\"\"\n lambda_client.update_function_configuration(\n FunctionName=function_name,\n Environment={'Variables': {'BENCH_TS': str(time.time_ns())}}\n )\n # Wait for update to propagate\n waiter = lambda_client.get_waiter('function_updated_v2')\n waiter.wait(FunctionName=function_name)\n time.sleep(2) # Additional buffer\n\n # Invoke\n start = time.perf_counter_ns()\n response = lambda_client.invoke(\n FunctionName=function_name,\n Payload=json.dumps({\"benchmark\": True}),\n LogType='Tail'\n )\n end = time.perf_counter_ns()\n\n # Parse REPORT line from logs\n import base64\n log_result = base64.b64decode(response['LogResult']).decode()\n init_duration = None\n for line in log_result.split('\\n'):\n if 'Init Duration' in line:\n init_duration = float(line.split('Init Duration: ')[1].split(' ms')[0])\n\n return {\n 'e2e_ms': (end - start) / 1_000_000,\n 'init_duration_ms': init_duration,\n }\n\ndef benchmark(function_name: str, iterations: int = 30):\n results = []\n for i in range(iterations):\n result = force_cold_start(function_name)\n results.append(result)\n print(f\" [{i+1}/{iterations}] init={result['init_duration_ms']:.1f}ms e2e={result['e2e_ms']:.1f}ms\")\n\n init_durations = [r['init_duration_ms'] for r in results if r['init_duration_ms']]\n print(f\"\\n--- {function_name} ({len(init_durations)} samples) ---\")\n print(f\" p50: {statistics.median(init_durations):.1f}ms\")\n print(f\" p90: {sorted(init_durations)[int(len(init_durations)*0.9)]:.1f}ms\")\n print(f\" p99: {sorted(init_durations)[int(len(init_durations)*0.99)]:.1f}ms\")\n print(f\" mean: {statistics.mean(init_durations):.1f}ms\")\n print(f\" std: {statistics.stdev(init_durations):.1f}ms\")\n\nif __name__ == '__main__':\n benchmark('checkout-prod', iterations=30)\n```\n\nKey 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.\n\n## The decision matrix\n\nAfter thorough benchmarking, the following decision framework emerges:\n\n**Use provisioned concurrency when**: guaranteed latency SLAs are required, cost is justified by business impact, and traffic is somewhat predictable.\n\n**Use SnapStart when**: the runtime is Java/Kotlin, the uniqueness constraints are manageable, and initialization is JVM-dominated.\n\n**Use LLRT when**: the runtime is Node.js, functions are short-lived and IO-bound, and full Node.js API compatibility is not required.\n\n**Use V8 snapshots when**: there is heavy module initialization, investment in build pipeline complexity is acceptable, and fine-grained control is needed.\n\n**Combine approaches**: Provisioned concurrency + SnapStart. LLRT + minimal dependencies. V8 snapshots + tree shaking. The techniques are complementary, not mutually exclusive.\n\nAs 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.\n\n## Frequently asked questions\n\n### What is a serverless cold start?\n\nA cold start is the initialization time a serverless function incurs when no warm execution environment is available. It includes downloading the deployment package, starting the runtime, and initializing the application code.\n\n### Does provisioned concurrency eliminate cold starts completely?\n\nYes, provisioned concurrency keeps environments pre-initialized, so requests never hit initialization latency. However, there is a cost for idle capacity, and if traffic exceeds the provisioned count, cold starts return for the overflow.\n\n### Is LLRT production-ready?\n\nLLRT is stable for most Node.js workloads but does not support the full Node.js API surface. It drops the JIT compiler entirely in favor of fast startup, so long-running compute-heavy functions may perform worse than standard Node.js runtimes.\n\n## Tips\n\n1. **Tip:** Never benchmark cold starts from a local machine. Use ` X-Ray ` traces or ` REPORT ` lines from CloudWatch, the ` Init Duration ` field is the only ground truth available.\n2. **Tip:** On Java, enable ` SnapStart ` AND trim the classpath. SnapStart alone saves ~70%, but a bloated classpath still serializes hundreds of megabytes into the snapshot.\n3. **Tip:** Set ` JAVA_TOOL_OPTIONS=-XX:+TieredCompilation -XX:TieredStopAtLevel=1 ` even with SnapStart, the JIT still kicks in after restore, and tier-1 compilation is sufficient for most Lambda workloads.\n\n- serverless\n- cold-starts\n- aws-lambda\n- provisioned-concurrency\n- snapstart\n- llrt\n- v8-snapshots\n- performance\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Serverless Cold Starts: A Comprehensive Optimization Playbook","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#title"},{"level":2,"text":"Anatomy of a Cold Start","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#anatomy-of-a-cold-start"},{"level":2,"text":"Provisioned concurrency: the expensive guarantee","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#provisioned-concurrency-the-expensive-guarantee"},{"level":3,"text":"Application Auto Scaling for provisioned concurrency","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#application-auto-scaling-for-provisioned-concurrency"},{"level":2,"text":"SnapStart: Java's answer (finally)","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#snapstart-javas-answer-finally"},{"level":3,"text":"The uniqueness problem","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#the-uniqueness-problem"},{"level":3,"text":"CRaC compatibility","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#crac-compatibility"},{"level":2,"text":"LLRT: when the JIT is abandoned entirely","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#llrt-when-the-jit-is-abandoned-entirely"},{"level":2,"text":"V8 snapshots: the DIY approach","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#v8-snapshots-the-diy-approach"},{"level":2,"text":"The real methodology: how to benchmark properly","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#the-real-methodology-how-to-benchmark-properly"},{"level":2,"text":"The decision matrix","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#the-decision-matrix"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#questions"},{"level":3,"text":"What is a serverless cold start?","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#question-1"},{"level":3,"text":"Does provisioned concurrency eliminate cold starts completely?","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#question-2"},{"level":3,"text":"Is LLRT production-ready?","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#article-tips"}],"sections":[{"heading":"Serverless Cold Starts: A Comprehensive Optimization Playbook","level":1,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies","content":"Serverless"},{"heading":"Serverless Cold Starts: A Comprehensive Optimization Playbook","level":1,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#title","content":"2025-10-038 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/serverless-cold-starts-optimization-strategies>)"},{"heading":"Anatomy of a Cold Start","level":2,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#anatomy-of-a-cold-start","content":"Before optimizing anything, it is essential to understand the anatomy of a cold start. A Lambda cold start has three distinct phases:\n\n1. **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.\n2. **Runtime init**, The language runtime boots. For Node.js, V8 starts and parses the code. For Java, the JVM loads.\n3. **Application init**, The handler module loads, static initializers run, connections are established.\n\nMost people conflate all three and then wonder why their \"optimization\" didn't move the needle. Only phases 2 and 3 can be affected directly.\n\nHere is how to extract clean measurements from CloudWatch Logs Insights:\n\n```sql\nfilter @type = \"REPORT\"\n| parse @log /\\/aws\\/lambda\\/(?<functionName>[^\\/]+)/\n| stats\n avg(@initDuration) as avg_init,\n pct(@initDuration, 50) as p50_init,\n pct(@initDuration, 99) as p99_init,\n count(*) as cold_starts\n by functionName\n| sort avg_init desc\n```\n\nThis 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."},{"heading":"Provisioned concurrency: the expensive guarantee","level":2,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#provisioned-concurrency-the-expensive-guarantee","content":"Provisioned concurrency is the brute-force solution. It tells [AWS](<https://aws.com/>) \"keep N environments warm at all times\" and they comply. Cold starts vanish for those N concurrent executions.\n\n```yaml\n# serverless.yml\nfunctions:\n checkout:\n handler: src/checkout.handler\n runtime: nodejs20.x\n memorySize: 1024\n provisionedConcurrency: 50\n events:\n - http:\n path: /checkout\n method: post\n```\n\nThe 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:\n\n```\n50 instances * 1 GB * 86400 sec/day * 30 days * $0.0000041667/GB-sec = ~$540/month\n```\n\nThat 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."},{"heading":"Application Auto Scaling for provisioned concurrency","level":3,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#application-auto-scaling-for-provisioned-concurrency","content":"The commonly overlooked technique is combining provisioned concurrency with Application Auto Scaling. A baseline is set and scaling handles the rest:\n\n```bash\naws application-autoscaling register-scalable-target \\\n --service-namespace lambda \\\n --resource-id function:checkout-prod:checkout-prod-alias \\\n --scalable-dimension lambda:function:ProvisionedConcurrency \\\n --min-capacity 10 \\\n --max-capacity 200\n\naws application-autoscaling put-scaling-policy \\\n --service-namespace lambda \\\n --resource-id function:checkout-prod:checkout-prod-alias \\\n --scalable-dimension lambda:function:ProvisionedConcurrency \\\n --policy-name checkout-scaling \\\n --policy-type TargetTrackingScaling \\\n --target-tracking-scaling-policy-configuration '{\n \"TargetValue\": 0.7,\n \"PredefinedMetricSpecification\": {\n \"PredefinedMetricType\": \"LambdaProvisionedConcurrencyUtilization\"\n },\n \"ScaleInCooldown\": 300,\n \"ScaleOutCooldown\": 60\n }'\n```\n\nTarget 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."},{"heading":"SnapStart: Java's answer (finally)","level":2,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#snapstart-javas-answer-finally","content":"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.\n\nMigrating a Spring Boot 3.2 function from standard Java 21 runtime to SnapStart yields significant results:\n\n| Metric | Before SnapStart | After SnapStart |\n| --- | --- | --- |\n| p50 cold start | 4,200ms | 620ms |\n| p99 cold start | 8,100ms | 1,100ms |\n| Memory at init | 512MB | 512MB |\n\nThat is an 85% reduction at p50. But there are caveats that AWS blog posts conveniently gloss over."},{"heading":"The uniqueness problem","level":3,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#the-uniqueness-problem","content":"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:\n\n```java\nimport software.amazon.lambda.snapstart.SnapshotRestore;\n\npublic class CheckoutHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {\n\n private SecureRandom secureRandom;\n private HikariDataSource dataSource;\n\n public CheckoutHandler() {\n // This runs BEFORE the snapshot\n dataSource = initializeDataSource();\n\n // Register a restore hook\n SnapshotRestore.registerRestoreHook(() -> {\n // This runs AFTER restore, on every cold start\n secureRandom = new SecureRandom();\n secureRandom.nextBytes(new byte[32]); // Force re-seeding\n\n // Re-establish database connections\n if (dataSource != null) {\n dataSource.getHikariPoolMXBean().softEvictConnections();\n }\n });\n }\n\n // ...\n}\n```"},{"heading":"CRaC compatibility","level":3,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#crac-compatibility","content":"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:\n\n```java\nimport org.crac.Context;\nimport org.crac.Core;\nimport org.crac.Resource;\n\npublic class ConnectionManager implements Resource {\n\n public ConnectionManager() {\n Core.getGlobalContext().register(this);\n }\n\n @Override\n public void beforeCheckpoint(Context<? extends Resource> context) {\n // Close connections before snapshot\n connectionPool.close();\n }\n\n @Override\n public void afterRestore(Context<? extends Resource> context) {\n // Re-establish after restore\n connectionPool = createPool();\n }\n}\n```"},{"heading":"LLRT: when the JIT is abandoned entirely","level":2,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#llrt-when-the-jit-is-abandoned-entirely","content":"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.\n\nBenchmarking a moderately complex API function (JSON parsing, DynamoDB calls, response transformation) across runtimes reveals the following:\n\n```\nStandard Node.js 20:\n Cold start: 340ms\n Warm invocation: 12ms\n\nLLRT v1.2:\n Cold start: 48ms\n Warm invocation: 28ms\n```\n\nCold 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.\n\nDeploying LLRT requires a custom runtime layer:\n\n```dockerfile\nFROM public.ecr.aws/awsguru/llrt:v1.2-arm64 as llrt\n\nFROM public.ecr.aws/lambda/provided:al2023-arm64\n\nCOPY --from=llrt /usr/local/bin/llrt /var/runtime/bootstrap\nCOPY dist/ /var/task/\n\nCMD [\"index.handler\"]\n```\n\nOne 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."},{"heading":"V8 snapshots: the DIY approach","level":2,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#v8-snapshots-the-diy-approach","content":"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.\n\nThe 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 `:\n\n```javascript\n// snapshot-builder.mjs, run at build time\nconst v8 = require('node:v8');\n\nv8.startupSnapshot.setDeserializeMainFunction((data) => {\n // This runs on restore\n const { handler } = require('./dist/handler');\n module.exports = { handler };\n});\n\n// Pre-initialize expensive objects\nconst config = require('./config');\nconst routes = require('./routes');\nconst validators = require('./validators');\n\n// These objects are now in the snapshot\nv8.startupSnapshot.addSerializeCallback(() => {\n console.log('Serializing V8 heap snapshot...');\n});\n```\n\nBuild the snapshot:\n\n```bash\nnode --snapshot-blob=snapshot.blob --build-snapshot snapshot-builder.mjs\n```\n\nThen start with it:\n\n```bash\nnode --snapshot-blob=snapshot.blob index.mjs\n```\n\nIn 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.)."},{"heading":"The real methodology: how to benchmark properly","level":2,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#the-real-methodology-how-to-benchmark-properly","content":"Every article on cold starts shows a single number. That number is meaningless without context. Here is a robust benchmarking methodology:\n\n```python\n#!/usr/bin/env python3\n\"\"\"Cold start benchmark harness\"\"\"\n\nimport boto3\nimport time\nimport json\nimport statistics\nfrom concurrent.futures import ThreadPoolExecutor\n\nlambda_client = boto3.client('lambda', region_name='us-east-1')\n\ndef force_cold_start(function_name: str) -> dict:\n \"\"\"Update an env var to force new execution environments\"\"\"\n lambda_client.update_function_configuration(\n FunctionName=function_name,\n Environment={'Variables': {'BENCH_TS': str(time.time_ns())}}\n )\n # Wait for update to propagate\n waiter = lambda_client.get_waiter('function_updated_v2')\n waiter.wait(FunctionName=function_name)\n time.sleep(2) # Additional buffer\n\n # Invoke\n start = time.perf_counter_ns()\n response = lambda_client.invoke(\n FunctionName=function_name,\n Payload=json.dumps({\"benchmark\": True}),\n LogType='Tail'\n )\n end = time.perf_counter_ns()\n\n # Parse REPORT line from logs\n import base64\n log_result = base64.b64decode(response['LogResult']).decode()\n init_duration = None\n for line in log_result.split('\\n'):\n if 'Init Duration' in line:\n init_duration = float(line.split('Init Duration: ')[1].split(' ms')[0])\n\n return {\n 'e2e_ms': (end - start) / 1_000_000,\n 'init_duration_ms': init_duration,\n }\n\ndef benchmark(function_name: str, iterations: int = 30):\n results = []\n for i in range(iterations):\n result = force_cold_start(function_name)\n results.append(result)\n print(f\" [{i+1}/{iterations}] init={result['init_duration_ms']:.1f}ms e2e={result['e2e_ms']:.1f}ms\")\n\n init_durations = [r['init_duration_ms'] for r in results if r['init_duration_ms']]\n print(f\"\\n--- {function_name} ({len(init_durations)} samples) ---\")\n print(f\" p50: {statistics.median(init_durations):.1f}ms\")\n print(f\" p90: {sorted(init_durations)[int(len(init_durations)*0.9)]:.1f}ms\")\n print(f\" p99: {sorted(init_durations)[int(len(init_durations)*0.99)]:.1f}ms\")\n print(f\" mean: {statistics.mean(init_durations):.1f}ms\")\n print(f\" std: {statistics.stdev(init_durations):.1f}ms\")\n\nif __name__ == '__main__':\n benchmark('checkout-prod', iterations=30)\n```\n\nKey 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."},{"heading":"The decision matrix","level":2,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#the-decision-matrix","content":"After thorough benchmarking, the following decision framework emerges:\n\n**Use provisioned concurrency when**: guaranteed latency SLAs are required, cost is justified by business impact, and traffic is somewhat predictable.\n\n**Use SnapStart when**: the runtime is Java/Kotlin, the uniqueness constraints are manageable, and initialization is JVM-dominated.\n\n**Use LLRT when**: the runtime is Node.js, functions are short-lived and IO-bound, and full Node.js API compatibility is not required.\n\n**Use V8 snapshots when**: there is heavy module initialization, investment in build pipeline complexity is acceptable, and fine-grained control is needed.\n\n**Combine approaches**: Provisioned concurrency + SnapStart. LLRT + minimal dependencies. V8 snapshots + tree shaking. The techniques are complementary, not mutually exclusive.\n\nAs 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."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#questions","content":""},{"heading":"What is a serverless cold start?","level":3,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#question-1","content":"A cold start is the initialization time a serverless function incurs when no warm execution environment is available. It includes downloading the deployment package, starting the runtime, and initializing the application code."},{"heading":"Does provisioned concurrency eliminate cold starts completely?","level":3,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#question-2","content":"Yes, provisioned concurrency keeps environments pre-initialized, so requests never hit initialization latency. However, there is a cost for idle capacity, and if traffic exceeds the provisioned count, cold starts return for the overflow."},{"heading":"Is LLRT production-ready?","level":3,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#question-3","content":"LLRT is stable for most Node.js workloads but does not support the full Node.js API surface. It drops the JIT compiler entirely in favor of fast startup, so long-running compute-heavy functions may perform worse than standard Node.js runtimes."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#article-tips","content":"1. **Tip:** Never benchmark cold starts from a local machine. Use ` X-Ray ` traces or ` REPORT ` lines from CloudWatch, the ` Init Duration ` field is the only ground truth available.\n2. **Tip:** On Java, enable ` SnapStart ` AND trim the classpath. SnapStart alone saves ~70%, but a bloated classpath still serializes hundreds of megabytes into the snapshot.\n3. **Tip:** Set ` JAVA_TOOL_OPTIONS=-XX:+TieredCompilation -XX:TieredStopAtLevel=1 ` even with SnapStart, the JIT still kicks in after restore, and tier-1 compilation is sufficient for most Lambda workloads.\n\n- serverless\n- cold-starts\n- aws-lambda\n- provisioned-concurrency\n- snapstart\n- llrt\n- v8-snapshots\n- performance\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/serverless-cold-starts-optimization-strategies"},{"text":"AWS","url":"https://aws.com/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies"},"headline":"Serverless Cold Starts: A Comprehensive Optimization Playbook","description":"Provisioned concurrency, SnapStart, LLRT, V8 snapshots, a thorough benchmark comparison revealing which cold start optimization strategies actually deliver measurable results.","datePublished":"2025-10-03","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Serverless","keywords":"serverless, cold-starts, aws-lambda, provisioned-concurrency, snapstart, llrt, v8-snapshots, performance","wordCount":1484,"timeRequired":"PT8M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/serverless-cold-starts-optimization-strategies"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Serverless Cold Starts: A Comprehensive Optimization Playbook","item":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is a serverless cold start?","acceptedAnswer":{"@type":"Answer","text":"A cold start is the initialization time a serverless function incurs when no warm execution environment is available. It includes downloading the deployment package, starting the runtime, and initializing the application code."}},{"@type":"Question","name":"Does provisioned concurrency eliminate cold starts completely?","acceptedAnswer":{"@type":"Answer","text":"Yes, provisioned concurrency keeps environments pre-initialized, so requests never hit initialization latency. However, there is a cost for idle capacity, and if traffic exceeds the provisioned count, cold starts return for the overflow."}},{"@type":"Question","name":"Is LLRT production-ready?","acceptedAnswer":{"@type":"Answer","text":"LLRT is stable for most Node.js workloads but does not support the full Node.js API surface. It drops the JIT compiler entirely in favor of fast startup, so long-running compute-heavy functions may perform worse than standard Node.js runtimes."}}]}]}],"contentHash":"71eb27bebfcb5c9233bd25bee45b91ebd8dbb65218115b44e9a9508d1cf77312","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/serverless-cold-starts-optimization-strategies","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":67477,"contentBytes":14467}}
