Lambda cold start anatomy and the levers

What it is

A cold start is the work between a request arriving and your handler's first line executing, when no warm execution environment is available. It has four phases and only two of them are yours:

1. DOWNLOAD          fetch the deployment package or container image
                     ~50-300 ms (zip), ~200 ms-2 s (container, first pull)
2. INIT (runtime)    start the language runtime: JVM, Python interpreter, Node
                     ~50 ms (Go, Rust) to ~400 ms (JVM)
3. INIT (yours)      module-level code: imports, SDK clients, config, DI container
                     ~10 ms to ~8 s        <- THE LEVER
4. INVOKE            your handler runs
                     the only phase a warm start pays

Phase 3 is where the variance lives, and it is entirely under your control. A Spring Boot application spending six seconds building an application context has a six-second cold start regardless of anything AWS does.

What it is confused with: cold starts affecting every request. In steady state they do not. The distribution matters more than the number:

Steady traffic, 100 req/s, ~15 concurrent environments:
  cold starts:              ~0.1-0.5% of invocations
  effect on p50:            none
  effect on p99:            none
  effect on p99.9:          entirely determined by cold start duration

Spiky traffic, 0 to 500 req/s in 10 seconds:
  cold starts:              ~500 at once
  effect:                   the whole spike is cold

Cold starts are a p99.9 problem in steady state and a p50 problem during a spike, and which one you have decides whether to care.

The problem it solves

Understanding the anatomy stops two expensive mistakes.

Optimising the wrong phase. A team moves from a container image to a zip to cut download time and saves 150 ms, while their Spring context takes 6 seconds. The measurement that matters is phase 3, and it is visible in the Init Duration field of the report line, which most teams have never looked at.

Buying provisioned concurrency instead of fixing initialisation. Provisioned concurrency keeps environments warm and costs money continuously, whether or not they are used. It is the right answer for predictable latency-critical traffic and the wrong answer for a slow init you have not investigated, because you are paying to hide a fixable problem.

The economics:

Provisioned concurrency, 100 environments, 1 GB:
  ~$0.000004646 per GB-second x 1 GB x 100 x 2,592,000 s/month
  ≈ $1,204/month, before any invocation cost

Fixing a 6 s init to 400 ms:
  ~2 days of engineering
  ≈ $0/month thereafter

Mechanics

Reading the report line

REPORT RequestId: 8841...  Duration: 42.11 ms  Billed Duration: 43 ms
       Memory Size: 1024 MB  Max Memory Used: 187 MB  Init Duration: 5842.19 ms
                                                      ^^^^^^^^^^^^^^^^^^^^^^^^^

Init Duration appears only on cold starts and is phase 2 plus phase 3. It is not included in Billed Duration for standard functions (AWS absorbs init billing up to a limit), which is why it is easy to ignore: it costs latency and not money.

Log Insights query to find where init time actually goes:
  filter @type = "REPORT"
  | stats count() as invocations,
          count(@initDuration) as coldStarts,
          pct(@initDuration, 50) as p50Init,
          pct(@initDuration, 99) as p99Init
    by bin(5m)

coldStarts / invocations is the number that tells you whether this matters at all.

The runtime spread

Runtime init (phase 2), approximate:
  Rust / Go (provided.al2023):   ~10-30 ms
  Node.js 20:                    ~90-150 ms
  Python 3.12:                   ~100-180 ms
  .NET 8 (native AOT):           ~80-120 ms
  .NET 8 (JIT):                  ~250-400 ms
  Java 21 (JVM):                 ~300-500 ms
  Java 21 (SnapStart):           ~150-250 ms restore

The runtime difference is real and it is usually not the dominant term. A Python function importing boto3, pandas and numpy spends 2 to 4 seconds in phase 3, which dwarfs the 150 ms runtime start.

Phase 3: the actual lever

# BAD: module level, so every cold start pays for all of it.
import boto3
import pandas as pd                      # ~1.4 s to import
from mycompany.internal import BigThing  # pulls in a dependency tree

s3 = boto3.client('s3')                  # ~300 ms: SDK client construction
dynamodb = boto3.resource('dynamodb')    # ~200 ms
config = load_config_from_ssm()          # ~400 ms: a NETWORK CALL at init
model = load_model_from_s3()             # ~3 s

def handler(event, context):
    ...

Two distinct problems here, and they need opposite treatments.

Things needed on every invocation should be at module level, because module-level state persists across warm invocations. An SDK client constructed inside the handler is reconstructed on every request, which is far worse in aggregate than a slow cold start.

Things needed rarely, or not at all for some paths, should be lazy:

import boto3
import os

# Cheap and needed always: keep at module level, reused by warm invocations.
s3 = boto3.client('s3')

# Expensive and conditional: defer.
_model = None
def get_model():
    global _model
    if _model is None:
        _model = load_model_from_s3()     # paid once, on the first request
    return _model                         # that actually needs it

# Config from the environment, not from a network call at init.
TABLE_NAME = os.environ['TABLE_NAME']

def handler(event, context):
    if event.get('needs_inference'):
        return get_model().predict(event['data'])
    return quick_path(event)

A network call during init is the worst pattern, because it adds latency and a failure mode: an SSM or Secrets Manager timeout during init fails the invocation, and it fails during exactly the traffic spike that caused the cold start.

Import cost is measurable and usually surprising:

# python -X importtime -c "import handler" 2>&1 | sort -k2 -rn | head
import time: self [us] | cumulative | imported package
             1394821   |    1394821 |   pandas
              412093   |     412093 |   boto3
               84021   |      84021 |   requests

pandas at 1.4 seconds is a common finding in functions that use it for one small transformation, and replacing it with plain Python or polars is often a second saved.

Java: SnapStart and the class-loading problem

// The JVM's cold start is dominated by class loading and framework init,
// not by JVM startup itself.
Spring Boot:      ~4-8 s init
Micronaut:        ~700 ms-1.5 s      (compile-time DI, no runtime reflection)
Quarkus (JVM):    ~800 ms-1.5 s
Quarkus (native): ~40-80 ms          (GraalVM ahead-of-time)
plain Java:       ~400-600 ms

SnapStart takes a Firecracker snapshot after init and restores from it:

Without SnapStart:   download -> JVM start -> Spring context -> handler
                     6,200 ms
With SnapStart:      restore snapshot -> handler
                     220 ms

Two things to know about it:

The snapshot is taken at publish time, so anything captured in it is frozen. Random seeds, cached credentials and unique identifiers generated during init are identical in every restored environment, which is a genuine correctness hazard:

public class Handler implements Resource {
    // WRONG: seeded once, snapshotted, every environment produces the
    // same sequence.
    private static final Random random = new Random();

    // Right: re-seed after restore.
    @Override public void afterRestore(Context ctx) {
        random.setSeed(SecureRandom.getInstanceStrong().nextLong());
        refreshCredentials();
        reconnectDatabasePools();
    }
}

Network connections do not survive a snapshot. A database pool established during init is restored with dead sockets, so afterRestore must rebuild them. This is the SnapStart failure that surfaces as intermittent connection errors after a deploy.

Memory is a CPU dial

Lambda CPU is allocated PROPORTIONALLY to memory:
  128 MB   ~0.08 vCPU
  1,769 MB  1.00 vCPU     <- the point where you get a full core
  3,008 MB  ~1.7 vCPU
  10,240 MB ~6 vCPU

Raising memory speeds up init, because init is usually CPU-bound. And because you pay GB-seconds, a function that runs twice as fast at twice the memory costs the same:

512 MB, 800 ms:    0.5 GB x 0.8 s = 0.40 GB-s
1,024 MB, 380 ms:  1.0 GB x 0.38 s = 0.38 GB-s    <- FASTER AND CHEAPER
2,048 MB, 340 ms:  2.0 GB x 0.34 s = 0.68 GB-s    <- past the knee

The 1,769 MB threshold is where a single-threaded function stops gaining, because that is one full vCPU. Below it you are CPU-starved; above it only multi-threaded work benefits. AWS Lambda Power Tuning automates finding the knee and it is a twenty-minute exercise that frequently pays for itself.

Provisioned concurrency and SnapStart, compared

                        Provisioned Concurrency    SnapStart (Java)
cold start eliminated?  yes, up to the configured  no: ~200 ms restore
                        count
cost                    continuous, per env        free
scaling beyond it       cold starts resume         all invocations get restore
correctness hazards     none                       frozen state, dead connections
runtime support         all                        Java, Python, .NET

Provisioned concurrency is a floor, not a ceiling. Traffic above the configured concurrency gets normal cold starts, so it protects a baseline and not a spike, which is the opposite of what people often assume.

A worked example: 8.4 seconds to 240 milliseconds

An order-processing API on Lambda behind API Gateway. Java 17, Spring Boot 3, called by a mobile app.

Baseline:

p50 latency:              84 ms
p99 latency:              310 ms
p99.9 latency:            8,940 ms       <- cold starts
cold start rate:          0.8% of invocations
Init Duration p50:        8,412 ms
memory:                   512 MB
provisioned concurrency:  none
user complaints:          "the app hangs sometimes on first open"

The complaint pattern was the diagnostic: first open of the day, after lunch, on Monday mornings. That is idle-timeout expiry causing environments to be reclaimed, so the first user of a period pays the full cold start.

Step 1: find where the 8.4 seconds goes.

// Instrument init directly; the report line gives a total, not a breakdown.
static {
    long t0 = System.currentTimeMillis();
    // ... existing init ...
    System.out.println("INIT_PHASE spring_context " + (System.currentTimeMillis()-t0));
}
JVM start:                     410 ms
Spring context:              5,890 ms
  component scan:            2,140 ms       <- scanning 340 classes
  DataSource + Hikari pool:  1,820 ms       <- opening 10 DB connections AT INIT
  Jackson ObjectMapper:        290 ms
  AWS SDK clients (4):       1,240 ms
Secrets Manager call:        1,640 ms       <- a NETWORK CALL during init
Config validation:             470 ms
                             ─────────
                             8,410 ms

Three findings, and only one of them is about Java.

Step 2: remove the network call from init.

// Before: Secrets Manager at init, 1,640 ms, and a failure mode.
// After: the secret injected as an environment variable by the deployment,
// or fetched lazily on first use with a cached value.
private static volatile DbCredentials creds;
private static DbCredentials credentials() {
    if (creds == null) {
        synchronized (Handler.class) {
            if (creds == null) creds = fetchFromSecretsManager();
        }
    }
    return creds;
}
Init Duration:  8,412 ms -> 6,770 ms

And it removed a failure mode: a Secrets Manager throttle during a traffic spike had been failing cold starts, which is the worst possible time.

Step 3: connection pool sizing, which was wrong in an interesting way.

// Before: a 10-connection pool opened eagerly at init.
// A Lambda environment handles ONE request at a time.
hikari.setMaximumPoolSize(2);        // 1 in use, 1 spare
hikari.setMinimumIdle(0);            // do not open eagerly
hikari.setConnectionTimeout(2000);
Init Duration:  6,770 ms -> 5,180 ms

A Lambda execution environment serves one request at a time, so a 10-connection pool is 9 connections of pure init cost, and at 500 concurrent environments it is 5,000 database connections. This is one of the most common Lambda-plus-RDS mistakes and it causes connection exhaustion as well as slow starts. RDS Proxy exists for exactly this.

Step 4: replace Spring Boot's runtime DI.

They evaluated three paths:

                          Init Duration   effort      notes
Spring Boot (baseline)     5,180 ms       -
Spring + lazy init         3,940 ms       1 day       spring.main.lazy-initialization
Micronaut                    980 ms       3 weeks     compile-time DI, a rewrite
Quarkus native (GraalVM)      74 ms       5 weeks     reflection config pain
SnapStart (keep Spring)      240 ms       3 days      <- chosen

SnapStart won on effort per millisecond. Keeping Spring Boot and taking a snapshot after init gave 96 percent of the native-image benefit for a fraction of the work.

public class Handler implements RequestHandler<...>, Resource {
    public Handler() { Core.getGlobalContext().register(this); }

    @Override public void beforeCheckpoint(Context ctx) {
        hikari.close();                              // no live sockets in the snapshot
    }

    @Override public void afterRestore(Context ctx) {
        hikari = buildPool();                        // fresh connections
        secureRandom = SecureRandom.getInstanceStrong();   // re-seed
        creds = null;                                 // force a refetch
    }
}

The beforeCheckpoint connection close is not optional. Their first SnapStart deployment skipped it, and every restored environment had a pool of dead sockets:

symptom after deploy:  intermittent "connection reset by peer" on the first
                       request to each new environment, ~4% of requests
cause:                 TCP connections snapshotted and restored into a
                       different network namespace at a different time

Step 5: memory tuning.

AWS Lambda Power Tuning results:
  512 MB:   init 240 ms, invoke 84 ms,  cost 0.063 GB-s
  1,024 MB: init 148 ms, invoke 41 ms,  cost 0.061 GB-s   <- knee
  1,769 MB: init 121 ms, invoke 38 ms,  cost 0.095 GB-s
  3,008 MB: init 118 ms, invoke 37 ms,  cost 0.158 GB-s

1,024 MB was faster and marginally cheaper than 512 MB, which is the counterintuitive result that Power Tuning exists to find.

Step 6: provisioned concurrency, sized from traffic rather than uniformly.

p50 concurrent executions:   12
p99 concurrent executions:   47
provisioned concurrency:     15      (covers p50 plus headroom)
cost:                        ~$180/month

Fifteen rather than forty-seven, because with a 240 ms cold start the tail above the provisioned level was acceptable. Provisioned concurrency sized to p99 is usually over-buying; sized to p50 it covers the steady state and lets the spike take a now-cheap cold start.

Final:

                          before      after
Init Duration p50         8,412 ms    148 ms      (-98%)
p99.9 latency             8,940 ms    390 ms
p50 latency               84 ms       41 ms       (memory increase)
cold start rate           0.8%        0.3%        (provisioned concurrency)
DB connections at 500
  concurrent envs         5,000       1,000
memory                    512 MB      1,024 MB
monthly cost              $412        $624        (+$212: PC and memory)
user complaints           weekly      none

The cost went up by $212 a month and the p99.9 went down by 96 percent, which was an easy trade. And provisioned concurrency was the last lever rather than the first: applied to the original 8.4-second init it would have needed far more environments to hide a problem that was fixable.

The transferable ordering: fix init, then tune memory, then buy provisioned concurrency. Reversing it means paying continuously to hide something that two days of work removes, and the worked example's first three steps (removing a network call, right-sizing a pool, and SnapStart) took init from 8.4 seconds to 240 ms before any money was spent.

Production evidence

AWS's own Lambda documentation describes the init phases and notes that init duration is not billed for standard functions up to a limit, which is why it is easy to overlook.

SnapStart (announced 2022 for Java, later extended to Python and .NET) uses Firecracker microVM snapshots, and AWS's documentation is explicit about the two hazards: state captured in the snapshot is identical across environments, and network connections do not survive. The Resource interface with beforeCheckpoint/afterRestore exists for exactly that.

AWS Lambda Power Tuning (a Step Functions state machine published by AWS) exists because the memory-versus-cost curve has a knee that is not obvious, and the frequent finding that more memory is both faster and cheaper is the reason it is worth running.

RDS Proxy was built substantially for the Lambda connection-exhaustion problem: each execution environment holding its own pool means concurrency times pool size connections to the database, which exhausts a typical RDS instance quickly.

The AWS Lambda Powertools libraries (Python, Java, TypeScript, .NET) include guidance and utilities for the module-level-versus-lazy split, which reflects that the initialisation pattern is the dominant lever in practice.

GraalVM native images for Quarkus and Micronaut reach tens of milliseconds of cold start, and the cost is build complexity and reflection configuration. The published comparisons consistently show native as fastest and SnapStart as far cheaper in engineering effort for existing Spring applications.

The debate

Do cold starts actually matter? For steady traffic, they are a p99.9 concern and often not worth engineering effort. For spiky traffic, an entire spike can be cold and it is a p50 concern. The question to ask first is the cold start rate: at 0.1 percent with a 200 ms init, this is not a project. At 0.8 percent with an 8-second init and a mobile app that opens after idle periods, it is a user-visible complaint pattern.

Provisioned concurrency or fixing init? Fix init first, essentially always. Provisioned concurrency costs money continuously and only covers the configured level, so traffic above it gets normal cold starts. It is a floor, not a ceiling, and buying it to hide a slow init means paying forever for something two days of work removes. Buy it afterwards, sized to p50 concurrency rather than p99.

Is Java viable on Lambda? With SnapStart, yes, and it changed the answer materially: 240 ms restore for a Spring Boot application that took 6 seconds to initialise. Without it, Spring Boot on Lambda is a poor fit and Micronaut or Quarkus are the sensible choices. SnapStart's hazards are real (frozen randomness, dead connections) and are handled by implementing afterRestore properly, which is a few dozen lines rather than a rewrite.

Container images or zip? Zip has lower cold start for small packages; container images cache layers well and the difference has narrowed substantially. The deciding factor is usually the build and deployment story rather than latency: if your organisation builds containers for everything else, the consistency is worth more than 100 ms. Above the 250 MB zip limit, containers are the only option.

Should you use Lambda at all for latency-critical paths? This is the honest framing. Lambda's economics are excellent for spiky and low-volume workloads and poor for sustained high throughput (see serverless vs containers). For a latency-critical high-volume API, a container behind a load balancer avoids the cold start question entirely, and the right answer to "how do we eliminate cold starts" is sometimes "do not use a platform that has them."

What about keeping functions warm with scheduled pings? It was common practice and it is now the wrong answer: it does not scale with concurrency (a ping warms one environment), it costs invocations, and provisioned concurrency does the job properly. Warming pings are a 2018 workaround that persists in codebases, and finding one is a signal that the configuration has not been revisited.

Follow-up Q&A

"What actually happens during a cold start?"

Four phases: downloading the package or image, starting the language runtime, running your module-level initialisation, and then the handler. The Init Duration field in the report line covers phases 2 and 3, and it is not billed for standard functions, which is why it goes unnoticed. Phase 3 is where the variance is and it is entirely yours: a Spring Boot context taking six seconds dominates everything else, and a Python function importing pandas spends 1.4 seconds before doing anything.

"How do you reduce init time?"

Measure the breakdown first, because the report line gives a total. Then: remove network calls from init entirely, because they add latency and a failure mode that fires during the spike that caused the cold start. Lazy-load anything not needed on every path. Right-size connection pools, since an execution environment serves one request at a time and a ten-connection pool is nine connections of pure cost. And for Java, SnapStart, which took one Spring Boot function from 5.2 seconds to 240 ms for three days of work against three weeks for a Micronaut rewrite.

"What are SnapStart's hazards?"

Two. Anything captured in the snapshot is identical across every restored environment, so a Random seeded during init produces the same sequence everywhere, and cached credentials are shared and stale. And network connections do not survive: a database pool built during init is restored with dead sockets, which surfaces as intermittent connection resets on the first request to each new environment. Both are handled by implementing beforeCheckpoint to close connections and afterRestore to rebuild them and re-seed randomness.

"Why does raising memory sometimes reduce cost?"

Because CPU is allocated proportionally to memory, and you pay GB-seconds. A function that is CPU-bound at 512 MB may run in less than half the time at 1,024 MB, so the GB-second product falls. In one measurement 1,024 MB was both faster and marginally cheaper than 512. The knee is around 1,769 MB, which is one full vCPU, and beyond that only multi-threaded work benefits. AWS Lambda Power Tuning finds the knee in about twenty minutes.

"When would you use provisioned concurrency?"

After fixing init, not instead of it, and sized to p50 concurrency rather than p99. It costs money continuously whether used or not, and it only covers the configured level, so traffic above it gets normal cold starts. That makes it a floor rather than a ceiling. Buying it to hide an 8-second init means paying forever for something two days of work removes, and once init is 240 ms the tail above the provisioned level is acceptable.

"How many database connections does a Lambda function need?"

One or two. An execution environment handles exactly one request at a time, so a ten-connection pool is nine connections of init cost and, at 500 concurrent environments, 5,000 connections to a database that probably permits a few hundred. This is the most common Lambda-plus-RDS failure and it is why RDS Proxy exists.

"Should you use warming pings?"

No. A ping warms one environment, so it does not scale with concurrency, and it costs invocations. Provisioned concurrency does the job properly. Warming pings are a workaround from before provisioned concurrency existed and finding one in a codebase usually means the configuration has not been revisited in years.

Common misconceptions

"Cold starts affect every request." In steady state they are a fraction of a percent and a p99.9 concern. They become a p50 concern during a spike, when the whole spike is cold, and which situation you are in decides whether it is worth engineering effort.

"The runtime choice dominates." The runtime start is 10 to 500 ms; your module-level initialisation is 10 ms to 8 seconds. A Python function importing pandas has a slower cold start than a plain Java one.

"Provisioned concurrency eliminates cold starts." It eliminates them up to the configured count. Traffic above that level gets normal cold starts, so it protects a baseline rather than a spike.

"More memory costs more." CPU scales with memory and you pay GB-seconds, so a CPU-bound function is frequently cheaper at higher memory because it finishes proportionally faster.

"SnapStart is free performance." It freezes anything captured during init, so randomness is shared across environments and network connections are restored dead. Both need explicit handling in beforeCheckpoint and afterRestore.

Interview delivery note

Say this verbatim: "The report line's Init Duration splits into runtime start and your module-level code, and the second is where the variance is: a Spring context at six seconds dwarfs a 400 ms JVM start. So I fix init first, tune memory second, and buy provisioned concurrency last, because provisioned concurrency costs money continuously to hide something two days of work often removes." The anatomy plus a committed ordering with its reason.

The senior-versus-staff separator is the connection pool. A senior engineer optimises imports and lazy-loads. A staff engineer notices that a Lambda execution environment serves exactly one request at a time, so a ten-connection pool is nine connections of pure init cost and 5,000 connections to the database at 500 concurrent environments, which is both a cold start problem and a database availability problem from the same misconfiguration. Connecting the two is the move.

The second signal is knowing provisioned concurrency is a floor rather than a ceiling. Sizing it to p50 concurrency rather than p99, on the reasoning that the tail can take a now-fast cold start, shows you are treating it as a cost decision rather than a magic setting.

Further reading

  • AWS Lambda documentation on the execution environment lifecycle and the init phases.
  • The SnapStart documentation, particularly the runtime hooks and the guidance on stale state and network connections.
  • AWS Lambda Power Tuning (the open-source Step Functions state machine), for finding the memory knee empirically.
  • AWS Lambda Powertools, for the module-level versus lazy initialisation patterns per runtime.