AWS Lambda Cold Starts on Node.js and Python: Measure, Cut, and Keep Cutting

Lambda Cold Starts on Node 20: Measure, Cut, and Keep Cutting
8 views
A Lambda-backed API answers quickly almost all the time, then a user waits an extra second after a quiet period or a deploy. Dashboards built on average Duration stay green, because the slow part, the cold start, is reported separately. Teams then reach for fixes they can’t verify: warmers, bigger memory everywhere, provisioned concurrency on every function. This guide takes the opposite approach: measure cold starts properly first, then cut what the measurements show, using techniques from the Lambda documentation. It covers Node.js and Python, which have different levers, and it merges three older posts on cold starts, Python warm strategies and “zero cost” fixes into one guide with the hype removed.
TL;DR
  • A cold start is the Init phase: extensions, runtime and your module-level code. It appears as Init Duration in the REPORT log line, it adds latency, and you are billed for it.
  • Track cold-start rate and Init p99 separately from Duration.
  • Biggest free wins: fewer and lighter imports, bundling (Node.js), SDK clients created once at module level, no network calls at import time.
  • More memory means more CPU (1,769 MB is the equivalent of one vCPU), which can shorten CPU-bound init.
  • Use SnapStart for Python 3.12+ (and Java, .NET) and provisioned concurrency when latency targets are strict. Both work only on versions or aliases.
  • If you’re still on nodejs20.x: it was deprecated on April 30, 2026. Move to nodejs22.x or nodejs24.x.

What a cold start actually is

Lambda runs your code in execution environments. When a request arrives and no idle environment exists (after a quiet period, after a deploy, or because traffic needs more concurrent environments), Lambda creates one. That Init phase has three steps: start extensions, bootstrap the runtime, and run your function’s static code (imports and anything outside the handler). Only then does the Invoke phase run your handler. A few documented facts shape every decision:
  • The Init phase is limited to 10 seconds for on-demand functions. If it doesn’t finish, Lambda retries Init on the first invocation with the function’s configured timeout.
  • Lambda keeps environments for reuse, but it terminates them every few hours for runtime updates and maintenance, even for functions invoked continuously. Some cold starts are unavoidable.
  • Code outside the handler runs once per environment and is reused across invocations. Code inside the handler runs every time.
  • Init time is charged and adds latency, so cutting it saves money as well as time.
Figure 1. The Init phase of a new execution environment, and the levers that shorten or avoid it. Diagram by CheatCoders.
Figure 1. The Init phase of a new execution environment, and the levers that shorten or avoid it. Diagram by CheatCoders.

Step 1: Measure cold-start rate and Init percentiles

Init Duration appears only on the first invocation in a new environment. CloudWatch Logs Insights discovers it as @initDuration, so one query shows how often cold starts happen and how long they take:
text
filter @type = "REPORT"
| stats count(*) as invocations,
        count(@initDuration) as cold_starts,
        pct(@initDuration, 50) as p50_init_ms,
        pct(@initDuration, 99) as p99_init_ms,
        pct(@duration, 99) as p99_duration_ms
  by bin(1h)
Divide cold_starts by invocations for the cold-start rate. Two numbers then tell you where to work: a high rate with a small Init means you need warm capacity (or should accept it), while a low rate with a large Init means your code is slow to load. To see cold starts in application metrics too, emit a metric on the first invocation in each environment. The AWS_LAMBDA_INITIALIZATION_TYPE environment variable tells you whether the environment came from on-demand, provisioned-concurrency or snap-start initialization.

Step 2: Cut Node.js init

Node.js cold starts are mostly about how much JavaScript must be loaded and parsed. The Lambda docs note that bundling with esbuild, rollup or similar tools improves performance because it reduces I/O operations. A lean handler:
typescript
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";

// Created once per execution environment, reused by every warm invocation.
// No network calls here: just construct the client.
const ddb = new DynamoDBClient({});
const TABLE = process.env.TABLE_NAME!;
const INIT_TYPE = process.env.AWS_LAMBDA_INITIALIZATION_TYPE;
let firstInvoke = true;

// Rarely used, heavy dependency: load it only on the path that needs it.
let renderPdf: ((html: string) => Promise<Buffer>) | undefined;

export const handler = async (event: { id: string; format?: string }) => {
  if (firstInvoke) {
    console.log(JSON.stringify({ metric: "cold_start", initType: INIT_TYPE }));
    firstInvoke = false;
  }

  const item = await ddb.send(
    new GetItemCommand({ TableName: TABLE, Key: { pk: { S: event.id } } })
  );

  if (event.format === "pdf") {
    renderPdf ??= (await import("./render-pdf.js")).renderPdf;
    const pdf = await renderPdf(JSON.stringify(item.Item));
    return { statusCode: 200, isBase64Encoded: true, body: pdf.toString("base64") };
  }
  return { statusCode: 200, body: JSON.stringify(item.Item ?? {}) };
};
Three habits matter more than any flag:
  • Import only what you use. Modular AWS SDK v3 clients (@aws-sdk/client-dynamodb) instead of whole-SDK imports, and no “just in case” libraries.
  • No top-level await on the network. Fetching a secret at import time makes every cold start wait for a network round trip. Fetch on first use and cache it, or use the Parameters and Secrets Lambda extension.
  • Lazy-load heavy, rare paths. A PDF renderer used by 1% of requests shouldn’t be parsed on every cold start.
bash
npx esbuild src/handler.ts \
  --bundle --platform=node --target=node22 --format=cjs \
  --minify --sourcemap=external \
  --outfile=dist/index.js

cd dist && zip -q ../function.zip index.js && cd ..
ls -lh function.zip
Should you bundle the AWS SDK or use the copy in the runtime? Every supported Node.js runtime includes a specific minor version of AWS SDK for JavaScript v3, not the latest, and the exact version depends on the runtime and Region. Bundling your own pins the version you tested; marking @aws-sdk/* as external (--external:@aws-sdk/*) makes the package smaller. Measure Init both ways and pick one deliberately.

Step 3: Cut Python init

Python cold starts are dominated by the import graph. Find out which imports are slow before changing anything; Python can report import timings itself:
bash
# Run locally with the same dependencies as the function
python -X importtime -c "import app" 2> imports.log
sort -t '|' -k2 -n imports.log | tail -15
Then keep module-level work to what every request needs:
python
import json
import os

import boto3

# Every request uses DynamoDB: create the client once, at import time.
_ddb = boto3.client("dynamodb")
_TABLE = os.environ["TABLE_NAME"]
_report_lib = None


def _reports():
    """Import the heavy reporting stack only when a report is requested."""
    global _report_lib
    if _report_lib is None:
        import reporting  # pulls in pandas; most requests never need it
        _report_lib = reporting
    return _report_lib


def handler(event, context):
    body = json.loads(event["body"])
    if body.get("action") == "report":
        return {"statusCode": 200, "body": _reports().build(body)}
    _ddb.put_item(TableName=_TABLE, Item={"pk": {"S": body["id"]}})
    return {"statusCode": 200, "body": "{}"}
Moving large dependencies into a Lambda layer doesn’t make them free to load: layer content is still extracted into the environment and imported at runtime. Layers help with sharing and packaging, not with import cost. If imports are still slow after trimming, Python 3.12 and later support SnapStart (Step 6).

Step 4: Right-size memory

Lambda allocates CPU in proportion to memory, from 128 MB to 10,240 MB. At 1,769 MB a function has the equivalent of one vCPU. CPU-bound init (parsing large bundles, compiling schemas, loading models) often gets faster with more memory, and because you pay for duration, a faster function can cost about the same. Test it rather than guessing:
bash
for mb in 512 1024 1769 3008; do
  aws lambda update-function-configuration \
    --function-name orders-api --memory-size "$mb" >/dev/null
  aws lambda wait function-updated --function-name orders-api
  # A configuration change means new execution environments, so the next calls are cold.
  for i in 1 2 3 4 5; do
    aws lambda invoke --function-name orders-api \
      --cli-binary-format raw-in-base64-out --payload '{"id":"probe"}' /dev/null >/dev/null
  done
  echo "memory=${mb}MB: read Init Duration from the REPORT lines in CloudWatch Logs"
done
Five cold samples per size is only a rough signal. For a fuller picture across many invocations, the open-source AWS Lambda Power Tuning tool automates this kind of test.

Step 5: Know what the VPC really costs

Old advice says VPC functions have multi-second cold starts. Today Lambda uses Hyperplane ENIs, created when you attach the function to a VPC with a given subnet and security-group combination, not on each cold start. While the ENI is being created, a new function stays Pending, which can take several minutes. If a VPC function sits idle for 14 days, Lambda reclaims the ENI, sets the function Inactive, and the next invocation fails while it returns to Pending. What still slows VPC functions down is usually the network path: calls through NAT to public endpoints, or DNS lookups for services you could reach through VPC endpoints.

Step 6: SnapStart or provisioned concurrency

When lean code isn’t enough, two features remove most of the Init phase from the request path:
  • SnapStart (Java 11+, Python 3.12+, .NET 8+; not Node.js) runs Init when you publish a version and restores new environments from a snapshot. Python pays caching and restore charges; Java has no extra charge. See our SnapStart guide for hooks and uniqueness rules.
  • Provisioned concurrency keeps a set number of environments initialized, ready to respond in double-digit milliseconds, for an extra charge. It’s configured on an alias or version, never $LATEST, and runs your init code at allocation time, so heavy module-level setup is fine there. It can’t be combined with SnapStart on the same version.
yaml
Resources:
  OrdersApi:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: nodejs22.x
      Handler: index.handler
      CodeUri: dist/
      MemorySize: 1024
      Architectures: [arm64]
      AutoPublishAlias: live
      ProvisionedConcurrencyConfig:
        ProvisionedConcurrentExecutions: 5
Callers must invoke the live alias; unqualified calls go to $LATEST and never use the provisioned environments. Put provisioned concurrency on the user-facing alias with predictable traffic, and leave async workers on-demand.

What about warmers?

Scheduled “keep warm” pings were popular before provisioned concurrency existed. A ping keeps the environment it reaches busy for a moment, but it doesn’t create the extra environments a traffic burst needs, and Lambda still recycles environments every few hours. Warmers also add invocations you must filter out of your metrics. Lean code plus SnapStart or provisioned concurrency is the supported path; if you still run a warmer, measure whether your cold-start rate actually dropped.

Worked example: a Node.js orders API

  1. The Step 1 query shows a low cold-start rate but a long p99 Init on orders-api, plus a jump in cold starts after each deploy.
  2. You build with esbuild’s --analyze flag to see what’s in the bundle. It includes a PDF library used by one rarely called route, and the code fetches a secret with top-level await.
  3. You lazy-load the PDF path, move the secret fetch to first use with caching, and bundle with esbuild. Init p99 drops in the next day’s query.
  4. The memory sweep shows Init improves up to 1,024 MB with little change above, so you settle there.
  5. Product still wants the first request after a deploy to be fast, so you add provisioned concurrency of 5 on the live alias and confirm with AWS_LAMBDA_INITIALIZATION_TYPE that those requests are served by provisioned-concurrency environments.

Troubleshooting

SymptomLikely causeFix
Provisioned concurrency configured, still cold startsCallers invoke $LATEST or another versionInvoke the alias that has provisioned concurrency
Init sometimes exceeds 10 s and the first request is very slowOn-demand Init timed out and was retried during invocationRemove network calls and heavy work from module load
Cold starts look fine but p99 latency is badThrottling and retries, not cold startsCheck Throttles; use reserved concurrency to protect critical functions
First call after two idle weeks fails (VPC)Hyperplane ENI reclaimed; function InactiveRetry after it returns to Active; add retries in callers
Bundle works locally, fails in LambdaSDK marked external but code expects a newer SDK than the runtime includesBundle the SDK or check the runtime’s included version
Memory increase didn’t helpInit is waiting on I/O, not CPURemove import-time network calls; check VPC routing

Production checklist

  • ☐ Dashboard shows cold-start rate and Init p50/p99 next to Duration
  • ☐ Runtime is supported (nodejs22.x/nodejs24.x, current Python)
  • ☐ No network calls at import time; clients created once at module level
  • ☐ Heavy, rare dependencies loaded lazily; Node.js code bundled
  • ☐ Memory chosen from a measured sweep
  • ☐ VPC functions use endpoints for AWS services they call
  • ☐ SnapStart or provisioned concurrency only where measurements justify it, invoked through an alias
  • ☐ Cold-start rate checked after every deploy

FAQ

Does arm64 reduce cold starts?

It can change performance and price, but the effect depends on your code and dependencies. Make sure native dependencies have arm64 builds, then measure Init on both architectures.

Should every function get provisioned concurrency?

No. It costs money whether or not requests arrive. Use it for user-facing functions with strict latency targets and fairly predictable traffic.

Why do I still see cold starts on a busy function?

Traffic growth needs new environments, deploys replace environments, and Lambda recycles environments every few hours for maintenance. A low, steady cold-start rate is normal.

Are container images slower to cold start than zip packages?

It depends on image size and contents. Measure your own Init Duration rather than assuming either way.

Official documentation

Related on CheatCoders: Lambda SnapStart · SSM Parameter Store config Last updated on October 6, 2026

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

3 Comments

Leave a comment

No account needed. Name and email are optional.