How to Reduce AWS Lambda Cold Starts: Init Duration to SnapStart (2026)

Oct 20, 2026 · 14 min · Ajay Kumar

This guide is for engineers who own an AWS Lambda function with a latency problem, or a bill that grew after AWS started charging for initialisation. By the end you will have a cold start baseline from your own logs, a cost figure for it, and a ranked set of fixes applied in order of cost: runtime and architecture, package size, lazy loading, memory, SnapStart, then provisioned concurrency. Everything here was checked against the AWS Lambda developer guide and price list in October 2026, with AWS CLI v2 (I used 2.27.7), Node.js 24, Python 3.13 and Java 21/25 runtimes in mind. You need the AWS CLI configured for an account where you can update the function, and its log group in CloudWatch Logs.

Step 1: Measure cold starts with Init Duration

A cold start is the time Lambda spends creating a new execution environment: downloading code, starting extensions and the runtime, and running your code outside the handler. The execution environment docs put cold starts at "under 1% of invocations" typically, lasting from under 100 ms to over a second. Your numbers are the only ones that matter, and they are in the REPORT line Lambda writes for every invocation. A cold invocation has an extra Init Duration field:

REPORT RequestId: 527cb862-... Duration: 3016.78 ms Billed Duration: 3101 ms Memory Size: 128 MB Max Memory Used: 31 MB Init Duration: 84.00 ms

Run this CloudWatch Logs Insights query against the function's log group to get counts and percentiles per hour. It works on the default plain text log format, where Lambda exposes @initDuration as a discovered field.

# Last 24 hours of cold start stats for one function (plain text log format).
FN=my-function
QUERY_ID=$(aws logs start-query \
  --log-group-name "/aws/lambda/${FN}" \
  --start-time $(( $(date +%s) - 86400 )) \
  --end-time $(date +%s) \
  --query-string 'filter @type = "REPORT"
    | stats count(*) as invocations,
            count(@initDuration) as coldStarts,
            pct(@initDuration, 50) as initP50,
            pct(@initDuration, 99) as initP99,
            max(@initDuration) as initMax
      by bin(1h)' \
  --query queryId --output text)

sleep 5
aws logs get-query-results --query-id "$QUERY_ID" --output table

If you switched the function to JSON log format, the same data lives in platform.report records, with the init time at record.metrics.initDurationMs per the telemetry schema:

filter type = "platform.report"
| stats count(*) as invocations,
        count(record.metrics.initDurationMs) as coldStarts,
        pct(record.metrics.initDurationMs, 99) as initP99

Write down three numbers: cold start percentage, init p50 and init p99. Also note how much of your tail latency the cold starts explain. If p99 is driven by a slow database query, none of the steps below will help. SnapStart functions do not log Init Duration in REPORT; they log Restore Duration instead, which Step 7 covers.

Step 2: Work out what cold starts cost after the INIT billing change

Since 1 August 2025, AWS bills the INIT phase for on-demand invocations of ZIP-packaged functions on managed runtimes. Container images, custom runtimes and provisioned concurrency already paid for it. In practice, Billed Duration on a cold invocation now includes Init Duration. Price it with the us-east-1 price list: $0.0000166667 per GB-second on x86 and $0.0000133334 on arm64 (first tier).

# Monthly INIT cost = cold starts x init seconds x memory GB x price per GB-s
# Example: 10M invocations, 2% cold, 800 ms init, 1 GB, arm64
python3 -c "print(round(10_000_000*0.02 * 0.8 * 1.0 * 0.0000133334, 2))"
# 2.13

For most functions that is pocket change, and the real cost is latency. It stops being pocket change for multi-second Java or data science inits at high scale-out rates, and when it does, it is a direct argument for Steps 3 to 7.

Cold start fixes, cheapest first 1. Measure Init Duration p50 and p99 per hour Logs Insights query 2. Trim init arm64, bundle, lazy load imports, right-size memory 3. SnapStart Java 11+, .NET 8+, Python 3.12+ published versions 4. Provisioned strict p99 targets alias or version plus autoscaling 5. Change model Managed Instances (no cold starts) or Web Functions Still above your latency target? Move one box right. Stop at the first box that meets the target. Each box to the right adds a standing monthly cost. INIT phase billed for on-demand ZIP functions on managed runtimes since 1 August 2025. SnapStart: free for Java; Python and .NET pay $0.0000015046 per GB-s cached plus a restore fee. Provisioned concurrency: $0.0000041667 per GB-s (x86) for every configured environment, idle or not.
The order I apply cold start fixes, from free code changes to standing capacity. Prices are us-east-1 list prices from the AWS Lambda price list API, October 2026.

Step 3: Choose the runtime and switch to arm64

Runtime choice sets the floor. The runtimes page notes that interpreted languages like Python and Node.js start fastest for simple functions, while Java is "often slower to initialize". Two housekeeping items first: Node.js 20 was deprecated on 30 April 2026 and Python 3.10 is deprecated on 31 October 2026, so if you are on either, move to nodejs24.x or python3.13/python3.14 while you are here. Amazon Linux 2 reached end of life on 30 June 2026.

Then move to arm64. AWS's launch claim for Graviton was up to 34% better price performance, workload dependent, and the price list confirms the 20% lower GB-second rate. It does not reliably shorten init by itself, but it makes every billed init millisecond cheaper.

# Check current runtime, architecture and memory
aws lambda get-function-configuration --function-name my-function \
  --query '{Runtime:Runtime,Arch:Architectures,Memory:MemorySize,Pkg:PackageType}'

# Switch architecture: architecture is set with the code, not the config.
aws lambda update-function-code --function-name my-function \
  --zip-file fileb://fn.zip --architectures arm64

Rebuild any native dependencies (Python wheels, Node addons, Rust or Go binaries) for aarch64 first, or the function will fail on import.

Step 4: Shrink the deployment package

Package size and the amount of code your runtime parses at startup are the biggest levers you control. The docs say it directly: initialisation latency depends on "the size of the function package, in terms of imported libraries and dependencies, and Lambda layers". For Node.js, bundle and minify with esbuild so the runtime loads one file instead of walking node_modules, and import modular AWS SDK v3 clients instead of whole SDKs.

# Node.js 24 handler using DynamoDB via AWS SDK v3, bundled with esbuild.
npm install @aws-sdk/client-dynamodb@3.1153.0 @aws-sdk/lib-dynamodb@3.1153.0
npm install --save-dev esbuild@0.28.2

npx esbuild src/index.mjs --bundle --minify --platform=node \
  --target=node24 --format=esm --outfile=dist/index.mjs
cd dist && zip -q -9 ../fn.zip index.mjs && cd ..

When I ran exactly this on a small DynamoDB GetCommand handler, the runtime dependencies took about 17 MB in node_modules (the @aws-sdk and @smithy trees), and the output was:

  dist/index.mjs  523.4kb
⚡ Done in 52ms
# fn.zip: 156,517 bytes

For Python, the equivalent is ruthless dependency pruning: no test libraries, no boto3 vendored unless you pin a version the runtime lacks, and avoid pulling pandas or numpy into a function that does not need them. Check what you ship:

# Largest packages in a Python build directory
du -sh package/* | sort -rh | head -15

For container images, keep the image on an AWS base image and small; image functions already paid for INIT before August 2025, so nothing changed for them except that ZIP functions lost their price advantage on init.

Step 5: Lazy-load what only some requests need

Code at module level runs on every cold start, and it is billed. The static initialisation guidance is to keep reusable clients and connections outside the handler, but to load rarely used dependencies lazily. Both halves matter.

# handler.py (python3.13): keep hot-path clients global, load heavy paths lazily
import json
import os

import boto3

# Created once per environment and reused on warm invocations.
s3 = boto3.client("s3")
_pdf = None  # heavy optional dependency, loaded on first use only


def _get_pdf():
    global _pdf
    if _pdf is None:
        import pypdf  # imported only by the few requests that need it
        _pdf = pypdf
    return _pdf


def lambda_handler(event, context):
    if event.get("action") == "extract_pdf":
        reader = _get_pdf().PdfReader("/tmp/in.pdf")
        return {"pages": len(reader.pages)}
    obj = s3.get_object(Bucket=os.environ["BUCKET"], Key=event["key"])
    return json.loads(obj["Body"].read())

Do not fetch secrets or open database connections at import time if a timeout there can fail the whole init. On-demand init is limited to 10 seconds; past that, Lambda retries init on the first invocation with your function timeout, and you see INIT_REPORT ... Status: timeout in the logs.

Step 6: Tune memory size for init, not only for the handler

CPU scales with memory: per the memory docs, 1,769 MB equals one vCPU, and the range is 128 MB to 10,240 MB. A function at 128 MB gets a fraction of a core to parse code and start the runtime, so raising memory often cuts Init Duration and Duration enough to cost less overall. Measure rather than guess, with the open source AWS Lambda Power Tuning state machine, which the AWS docs recommend, or with a quick manual sweep:

for MB in 512 1024 1769 3008; do
  aws lambda update-function-configuration --function-name my-function \
    --memory-size $MB --query MemorySize --output text
  aws lambda wait function-updated --function-name my-function
  # A configuration change forces fresh environments, so the next call is cold.
  aws lambda invoke --function-name my-function --payload '{}' \
    --cli-binary-format raw-in-base64-out --log-type Tail /dev/null \
    --query LogResult --output text | base64 --decode | grep REPORT
done

One cold sample per size is noisy. Repeat each size a few times, then confirm with the Step 1 query over a day of real traffic.

Step 7: Enable SnapStart for Java, Python or .NET

SnapStart initialises your function when you publish a version, snapshots the Firecracker microVM, and restores new environments from that snapshot. The SnapStart docs list Java 11 and later, Python 3.12 and later, and .NET 8 and later, on ZIP and container images. Node.js, Ruby and OS-only runtimes are not supported, nor are provisioned concurrency, EFS or ephemeral storage above 512 MB. It only applies to published versions and aliases, never $LATEST.

aws lambda update-function-configuration --function-name my-function \
  --snap-start ApplyOn=PublishedVersions
aws lambda wait function-updated --function-name my-function

VERSION=$(aws lambda publish-version --function-name my-function \
  --query Version --output text)

aws lambda get-function-configuration --function-name "my-function:${VERSION}" \
  --query '{SnapStart:SnapStart,State:State}'
# Expect: "OptimizationStatus": "On" and "State": "Active"

# Point the alias your trigger uses at the new version.
aws lambda update-alias --function-name my-function --name live \
  --function-version "$VERSION"

Before you enable it, read the uniqueness rules: anything random generated during init (IDs, seeds, secrets) is frozen into the snapshot and shared by every restored environment. Generate those in the handler or in an after-restore hook. In Python the hooks come from the snapshot_restore_py library, included in the managed runtime:

from snapshot_restore_py import register_after_restore, register_before_snapshot
import boto3

s3 = boto3.client("s3")  # AWS SDK connections resume automatically after restore


@register_before_snapshot
def warm():
    # Runs once at publish time: preload models, templates, framework imports.
    import pydantic  # noqa: F401


@register_after_restore
def refresh():
    # Runs on every restore, must finish within the 10 second restore limit.
    pass

Measure cold starts on SnapStart functions as Restore Duration plus Duration; the monitoring page gives a full percentile query. Pricing: no extra charge for Java; Python and .NET pay a cache fee of $0.0000015046 per GB-second for each active version (minimum 3 hours) and $0.0001397998 per GB restored, per the us-east-1 price list. Delete old versions, because each cached snapshot keeps billing.

Step 8: Use provisioned concurrency for hard p99 targets

If the latency target is strict, or the runtime is Node.js where SnapStart does not exist, provisioned concurrency keeps initialised environments ready. The docs describe "double-digit millisecond" response times. It must target an alias or version, and you pay $0.0000041667 per GB-second on x86 ($0.0000033334 on arm64) for every configured environment, used or not, plus a lower duration rate.

aws lambda put-provisioned-concurrency-config --function-name my-function \
  --qualifier live --provisioned-concurrent-executions 10

aws application-autoscaling register-scalable-target --service-namespace lambda \
  --resource-id function:my-function:live --min-capacity 2 --max-capacity 50 \
  --scalable-dimension lambda:function:ProvisionedConcurrency

aws application-autoscaling put-scaling-policy --service-namespace lambda \
  --scalable-dimension lambda:function:ProvisionedConcurrency \
  --resource-id function:my-function:live --policy-name pc-70 \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration \
  '{"TargetValue":0.7,"PredefinedMetricSpecification":{"PredefinedMetricType":"LambdaProvisionedConcurrencyUtilization"}}'

Size it with the formula from the docs, concurrency equals requests per second times average duration in seconds, plus a 10% buffer. Watch ProvisionedConcurrencySpilloverInvocations: anything above zero means requests overflowed onto cold on-demand environments.

If you need provisioned concurrency around the clock at high utilisation, you are paying for capacity anyway. Price the same function on Lambda Managed Instances, which scale on CPU "without cold starts" at EC2 prices plus a 15% fee. I compare those options in Serverless in 2026.

Troubleshooting

Symptom Likely cause Fix
Cold starts continue after enabling provisioned concurrency Trigger invokes $LATEST or an unqualified ARN Point the event source, API integration or URL at the alias with provisioned concurrency
ProvisionedConcurrencySpilloverInvocations above zero Provisioned concurrency too low for bursts Raise min capacity; bursts shorter than 3 minutes may need the Maximum statistic or scheduled scaling
INIT_REPORT Init Duration: ... Status: timeout On-demand init exceeded 10 seconds Move slow work out of module scope (Step 5), or use SnapStart or provisioned concurrency, which allow longer init
SnapStartNotReadyException on invoke Version still Pending, or a Java snapshot expired after 14 days without invocations Wait for State Active, then invoke again
SnapStartTimeoutException Runtime load plus after-restore hooks exceeded 10 seconds Move work from after-restore hooks into before-snapshot hooks or the handler
Duplicate IDs or identical random values across environments Unique values generated during init and frozen in the snapshot Generate them in the handler or an after-restore hook
Billed Duration jumped since August 2025 with no code change INIT phase now billed for on-demand ZIP functions Expected; reduce init with Steps 3 to 6
Runtime.ImportModuleError or exec format error after moving to arm64 Native dependency built for x86_64 Rebuild wheels, addons or binaries for aarch64
No Init Duration in REPORT on a SnapStart function SnapStart logs Restore Duration; init is in INIT_REPORT at publish time Use the restore-aware query from the SnapStart monitoring page
Provisioned concurrency never scales down overnight No traffic means no utilisation metric, so alarms sit in INSUFFICIENT_DATA Add scheduled scaling for quiet hours

FAQ

Does Lambda charge for cold starts?

Yes. Since 1 August 2025 the INIT phase is billed for on-demand ZIP functions on managed runtimes, as it already was for container images, custom runtimes and provisioned concurrency. You can see it in the REPORT line, where Billed Duration includes Init Duration on cold invocations.

Does SnapStart work with Node.js?

No. SnapStart supports Java 11 and later, Python 3.12 and later, and .NET 8 and later. For Node.js, the options are smaller bundles, lazy loading, more memory and provisioned concurrency, or the new Lambda Web Functions, which serve many requests per environment.

Is SnapStart or provisioned concurrency better?

SnapStart is cheaper and usually good enough: it removes most of the init work with no idle capacity charge for Java. Provisioned concurrency gives the most predictable latency and works on every runtime, but you pay for every configured environment all the time. AWS recommends provisioned concurrency when SnapStart cannot meet a strict cold start requirement.

Does VPC attachment still cause slow cold starts?

Not the way it used to. Since September 2019, Lambda creates the network interface when you create or update the function rather than at invocation. Slow VPC cold starts today usually come from DNS or connection setup in your init code.

When a team asks me about cold starts, the first thing I check is whether they matter at all: the Step 1 query, and whether cold starts actually explain p99 or a slow dependency does. Most of the time the fix is in Steps 3 to 6, and it costs nothing but an afternoon. I only reach for provisioned concurrency for user-facing paths with a written latency target, and once it runs all day I price Managed Instances next to it. The same logic applies to my own Firecracker work on PandaStack (my company, a microVM cloud for agents), where snapshot restore beats keeping things warm, which I wrote up in What Scale to Zero Cost Me to Build. Official references: execution environment lifecycle, SnapStart, provisioned concurrency and Lambda pricing.

Related: I Measured Every Stage of My 179ms Firecracker Boot Path, Sandbox Creation Time Benchmarks Measure Different Things and Serverless in 2026: Lambda vs Cloud Run vs Workers, and When It Costs More.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related