AWS Lambda Cold Starts in 2026: SnapStart, Provisioned Concurrency, and What Actually Works

If you’ve run Lambda in production for more than a week, you know the drill. Everything looks great in load tests — until a real user hits a function that’s been idle for five minutes and your P99 spikes to three seconds. Your manager opens CloudWatch, sees the spike, and now you’re explaining what a "cold start" is at 11 PM.

Cold starts aren’t new. They’ve been Lambda’s dirty laundry since 2014. What has changed is how AWS lets you fight them — and the tradeoffs have gotten subtle enough that a lot of teams are using the wrong tool for their workload.

This article cuts through the docs and the re:Invent hype. We’ll look at what actually causes cold starts under the hood, when Provisioned Concurrency makes sense vs. when it’s throwing money away, and whether SnapStart is finally mature enough to bet your Java services on.

What Actually Happens During a Cold Start

Lambda runs your code in a micro-VM managed by Firecracker. When AWS has no warm execution environment ready for your function, it has to boot one. That sequence looks like this:

  1. Firecracker allocates the micro-VM and network interface
  2. The Lambda runtime (JVM, Node runtime, Python interpreter, etc.) boots inside it
  3. Your deployment package is downloaded and extracted from S3 (or a container image layer is pulled)
  4. Your initialization code runs — everything outside the handler function
  5. Finally, your handler is invoked

Steps 1–4 are the cold start. Steps 1–2 are AWS’s problem. Steps 3–4 are yours.

The runtime makes an enormous difference. A Python 3.12 or Node.js 22 function with a small deployment package typically cold-starts in 100–300ms. A Java 21 function that initializes Spring Boot? You’re looking at 3–10 seconds, sometimes more, depending on what you’re wiring up.

Container image functions add another layer. A 500MB image pulling from ECR on first invocation can take 2–4 seconds on top of the normal runtime init. AWS caches layers aggressively after the first pull, but that first time hurts.

The metric you should actually track: Init Duration in CloudWatch Logs Insights. This is only emitted on cold start invocations. Set up a query like this:

fields @timestamp, @requestId, @initDuration, @duration
| filter ispresent(@initDuration)
| stats 
    count() as cold_start_count,
    avg(@initDuration) as avg_init_ms,
    pct(@initDuration, 95) as p95_init_ms,
    max(@initDuration) as max_init_ms
  by bin(1h)

Run this before you start tuning anything. You need baseline numbers, not vibes.

Provisioned Concurrency: The Right Tool, Used Wrong

Provisioned Concurrency (PC) keeps a set number of execution environments pre-initialized and warm. When a request comes in, it skips steps 1–4 entirely. The invocation goes straight to your handler.

The mechanism is simple. The misconception is in how teams apply it.

How It’s Usually Misconfigured

Most tutorials tell you to set PC to some fixed number and call it done. That works until your traffic pattern changes — and it always does. Fixed PC means you’re either paying for idle warm environments during off-hours, or you’ve set it too low and you still get cold starts when traffic bursts past your provisioned count.

Here’s the thing: PC does not eliminate cold starts. It eliminates them up to your provisioned count. The moment concurrency exceeds that count, Lambda spins up additional on-demand environments the old-fashioned way. In a traffic spike, you get a mix of hot PC invocations and cold on-demand invocations. Your P99 still spikes. Your median looks great; your P95 lies to you.

Application Auto Scaling: The Correct Setup

The right pattern is Auto Scaling your provisioned concurrency against a utilization metric. AWS exposes LambdaProvisionedConcurrencyUtilization for exactly this purpose.

# cloudformation-lambda-pc.yaml
AWSTemplateFormatVersion: '2010-09-09'

Resources:
  MyFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: my-api-handler
      Runtime: java21
      Handler: com.example.Handler::handleRequest
      MemorySize: 1024
      Timeout: 30
      Code:
        S3Bucket: !Ref DeploymentBucket
        S3Key: function.jar

  MyFunctionAlias:
    Type: AWS::Lambda::Alias
    Properties:
      FunctionName: !Ref MyFunction
      FunctionVersion: !GetAtt MyFunction.Version
      Name: live

  # Baseline: keep 5 warm environments at all times
  ProvisionedConcurrency:
    Type: AWS::Lambda::ProvisionedConcurrencyConfig
    Properties:
      FunctionName: !Ref MyFunction
      Qualifier: !GetAtt MyFunctionAlias.AliasName
      ProvisionedConcurrentExecutions: 5

  # Register the alias as a scalable target
  ScalableTarget:
    Type: AWS::ApplicationAutoScaling::ScalableTarget
    Properties:
      MaxCapacity: 50
      MinCapacity: 5
      ResourceId: !Sub "function:${MyFunction}:live"
      ScalableDimension: lambda:function:ProvisionedConcurrency
      ServiceNamespace: lambda
      RoleARN: !GetAtt AutoScalingRole.Arn

  # Scale up when utilization hits 70%
  ScalingPolicy:
    Type: AWS::ApplicationAutoScaling::ScalingPolicy
    Properties:
      PolicyName: pc-utilization-policy
      PolicyType: TargetTrackingScaling
      ScalingTargetId: !Ref ScalableTarget
      TargetTrackingScalingPolicyConfiguration:
        TargetValue: 70.0
        PredefinedMetricSpecification:
          PredefinedMetricType: LambdaProvisionedConcurrencyUtilization

  AutoScalingRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: application-autoscaling.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSApplicationAutoscalingLambdaConcurrencyPolicy

Targeting 70% utilization means you have a 30% buffer before you start hitting on-demand cold starts. Auto Scaling will react when you sustain pressure, but there’s still a lag — typically 1–3 minutes before new provisioned environments are ready. For sharp traffic spikes, that buffer matters.

Gotcha: Auto Scaling PC works on aliases, not on $LATEST. If you’re deploying new function versions without using aliases, this entire setup doesn’t apply to you. Fix your deployment model before fighting cold starts.

The Cost Equation

Provisioned Concurrency is billed even when your function isn’t executing. You pay for the allocated GB-seconds whether or not requests are coming in. At 1GB memory, one provisioned environment costs roughly $0.0000046 per second it’s sitting there. Multiply by your baseline PC count and do the math against your actual traffic pattern.

For a function that’s genuinely busy 24/7, PC is almost always worth it. For a function that’s idle 14 hours a day, scheduled scaling or SnapStart will almost always be cheaper.

# Quick cost estimate script
MEMORY_GB=1
PC_COUNT=10
IDLE_HOURS_PER_DAY=10
DAYS=30

# PC cost during idle hours (no invocations, just reservation)
IDLE_COST=$(echo "$MEMORY_GB * $PC_COUNT * $IDLE_HOURS_PER_DAY * 3600 * 0.0000046 * $DAYS" | bc -l)
echo "Idle PC cost over 30 days: \$$IDLE_COST"

SnapStart: The Java Story in 2026

SnapStart launched for Java 11 in late 2022. By 2024 it had expanded to Java 17 and 21. In 2026 it’s GA, battle-tested, and genuinely production-grade for most use cases — with a handful of sharp edges you need to know.

What SnapStart Actually Does

When you publish a new Lambda function version with SnapStart enabled, AWS runs your initialization code once. Then it takes a snapshot of the memory and disk state of that execution environment — essentially a checkpoint of the JVM after your Spring context (or Quarkus, or Micronaut) has finished warming up.

When a cold start happens, instead of booting a fresh JVM and running your initialization code, Lambda restores from that snapshot. The JVM memory is already populated. Your beans are already wired. You skip from zero to "handler ready" in milliseconds.

In practice, SnapStart reduces Java cold starts from 3–10 seconds down to 200–800ms. That’s not "warm" performance, but it’s in a range where real users don’t notice.

Enabling SnapStart

It’s a one-liner in your function config, but there are prerequisites:

# serverless-framework example (serverless.yml)
functions:
  api:
    handler: com.example.StreamHandler
    runtime: java21
    memorySize: 1024
    snapStart: true   # enables SnapStart; requires published versions
    # SnapStart only works with published versions, not $LATEST
    # Use versioning + aliases in your deployment
    environment:
      JAVA_TOOL_OPTIONS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

For CloudFormation:

MyFunction:
  Type: AWS::Lambda::Function
  Properties:
    FunctionName: my-java-api
    Runtime: java21
    Handler: com.example.Handler::handleRequest
    SnapStart:
      ApplyOn: PublishedVersions
    # ... rest of config

The Snapshot Restore Hook

AWS gives you two lifecycle hooks: beforeCheckpoint and afterRestore. Use them.

beforeCheckpoint runs before the snapshot is taken. This is where you pre-warm caches, establish connection pools, and do anything that’s expensive to set up but safe to snapshot.

afterRestore runs after a snapshot is restored, before your handler is called. This is where you must re-establish anything that can’t survive a snapshot: network connections, open file handles, seeded random number generators, time-sensitive state.

import com.amazonaws.services.lambda.runtime.events.SnapStartEvent;
import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;

@Component
public class SnapStartLifecycle implements Resource {

    private final DataSource dataSource;
    private final SomeCache cache;

    public SnapStartLifecycle(DataSource dataSource, SomeCache cache) {
        this.dataSource = dataSource;
        this.cache = cache;
        // Register with CRaC (Coordinated Restore at Checkpoint)
        Core.getGlobalContext().register(this);
    }

    @Override
    public void beforeCheckpoint(Context<? extends Resource> context) throws Exception {
        // Pre-warm: establish initial DB connection so it's in the snapshot
        try (var conn = dataSource.getConnection()) {
            conn.isValid(1);
        }
        // Load hot data into cache
        cache.prewarm();
    }

    @Override
    public void afterRestore(Context<? extends Resource> context) throws Exception {
        // Reconnect: TCP connections don't survive snapshots
        dataSource.evictIdleConnections();
        // Reinitialize anything time-sensitive
        resetTimers();
    }
}

Gotcha: If you use SecureRandom anywhere in your initialization path, read this carefully. SecureRandom seeds itself from /dev/urandom at construction time. A snapshotted SecureRandom instance will have the same seed across all restored environments. Every concurrent invocation restored from the same snapshot will generate identical "random" values. This is a real security issue for anything generating tokens, IVs, or nonces.

The fix is to call SecureRandom.getInstance("NativePRNGNonBlocking") in your afterRestore hook and replace any singleton instances. Or better yet, never store SecureRandom as a field that gets snapshotted.

SnapStart Limitations (The Honest List)

  • Java only. Python, Node, .NET — not supported. There are strong architectural reasons for this (JVM heap snapshot is well-defined; Python interpreter state is not). Don’t hold your breath.
  • x86_64 only as of 2026. ARM/Graviton functions don’t support SnapStart. Given that arm64 Lambda is typically 20% cheaper and often faster for interpreted runtimes, this is a real tradeoff for Java workloads.
  • No $LATEST support. You must publish versions. Every deployment requires a new published version, and your aliases must point to it. If your CI/CD pipeline doesn’t handle this, fix that first.
  • VPC cold starts are still slower. SnapStart cuts the initialization time but VPC attachment (ENI provisioning) adds latency separately. AWS has improved this significantly with hyperplane ENIs, but it’s still not zero.
  • Snapshot freshness. AWS may use a snapshot that’s hours old. If your initialization code fetches config from Parameter Store or Secrets Manager, that data is baked into the snapshot. It won’t reflect changes until you publish a new version. Design your initialization code accordingly.

Combining SnapStart and Provisioned Concurrency

Yes, you can use both. SnapStart makes each provisioned environment cheaper to create (faster restore from snapshot), and PC keeps a set of already-restored environments ready. For Java APIs with strict P99 SLAs, this combination makes sense.

The cost calculus changes though. With SnapStart reducing cold start pain to 200–500ms, the business case for large PC counts weakens unless your SLA is under 500ms end-to-end. Run the numbers for your actual traffic.

What to Use When

Small Node/Python functions, traffic is bursty or unpredictable: Don’t do anything special. Optimize your package size, keep initialization code lean, and accept occasional cold starts. The cost of PC often doesn’t justify the benefit for sub-300ms cold starts.

Java APIs, latency-sensitive, you control the runtime: Enable SnapStart. Add the CRaC lifecycle hooks from day one. This is the minimum viable production setup for Java Lambda in 2026.

Any runtime, predictable traffic pattern, SLA under 300ms: Provisioned Concurrency with Auto Scaling. Set your minimum based on off-peak traffic, let Auto Scaling handle the rest. Schedule scaling changes if you have predictable daily patterns (spike at 9 AM, quiet at 2 AM).

Java APIs with SnapStart + strict P99 SLA: Both. Use SnapStart to make cold starts tolerable, and PC to eliminate them for your baseline concurrency. This is the highest-cost option and genuinely justifies the spend only when cold starts appear in your SLA breach post-mortems.

Container image functions: Minimize your image size aggressively. Use multi-stage builds. Separate your application layer from your base layer to maximize ECR cache hits. Consider whether Lambda is the right choice at all — if your image is over 1GB, you might be better off on Fargate or ECS.

Production Checklist

Before you call any Lambda deployment "production-ready":

# 1. Check your cold start baseline
aws logs start-query \
  --log-group-name "/aws/lambda/your-function" \
  --start-time $(date -d '7 days ago' +%s) \
  --end-time $(date +%s) \
  --query-string 'fields @initDuration | filter ispresent(@initDuration) | stats pct(@initDuration, 95) as p95, pct(@initDuration, 99) as p99'

# 2. Check what % of invocations are cold starts
aws logs start-query \
  --log-group-name "/aws/lambda/your-function" \
  --start-time $(date -d '7 days ago' +%s) \
  --end-time $(date +%s) \
  --query-string 'fields ispresent(@initDuration) as is_cold | stats sum(is_cold)/count() * 100 as cold_start_pct'

If your cold start percentage is under 1% and your P99 initDuration is acceptable, you don’t have a cold start problem. Don’t pay to solve a problem you don’t have.

Gotcha: Lambda functions inside a VPC that haven’t been invoked for a while will recycle their ENIs. The first invocation after a long idle period goes through full ENI provisioning even if you have Provisioned Concurrency. AWS fixed the worst of this with hyperplane ENIs in 2023, but if you’re on a legacy VPC config that predates that, migrate to hyperplane ENIs — it’s not automatic for old functions.

Gotcha: Provisioned Concurrency doesn’t protect you from deployment cold starts. When you deploy a new version and shift traffic via alias weights, Lambda has to provision new environments for the new version. During a weighted canary deploy (say, 10% → 100%), there’s a window where your new version is taking real traffic but most of its environments are still cold. Deploy during low-traffic windows or pre-warm explicitly using a warmup request pattern.

The Honest Summary

Cold starts in 2026 are a solved problem for most workloads — but "solved" means choosing the right tool, not throwing Provisioned Concurrency at everything.

For Python and Node functions, lean code and small packages get you most of the way there. For Java, SnapStart is no longer experimental — it’s what you should default to. For anything with a hard P99 SLA, Provisioned Concurrency with Auto Scaling is the right answer, not a fixed number someone set and forgot.

The real mistake teams make isn’t picking the wrong mitigation — it’s not measuring first. Get your @initDuration numbers, calculate your actual cold start rate, and then decide. Most Lambda functions in most workloads have cold start rates under 2%, and their P99 init times are well within acceptable bounds. Don’t optimize for a problem your users aren’t experiencing.

When the numbers do tell you there’s a problem, now you know exactly what to reach for.

👁 Views: 202,849 · Unique visitors: 57,406