Lambda Cold Starts on Node 20: Measure, Cut, and Keep Cutting

Lambda Cold Starts on Node 20: Measure, Cut, and Keep Cutting

Cold starts are where senior Node teams leak UX budget: a checkout API that “usually” answers in 80ms suddenly burns 1.8s of Init Duration after a quiet hour, and every dashboard still looks green because Duration averages hide Init. Node 20 on Lambda is fast enough that most cold-start pain is your import graph, sync I/O at module load, and VPC choices — not the runtime. This guide is the unfair advantage: measure Init vs Invoke, cut what you can prove, then decide whether provisioned concurrency is insurance or vanity.

⚡ TL;DR: Split CloudWatch into Init Duration (cold) and Duration (warm). On Node 20, prefer nodejs20.x, ESM-aware bundling (esbuild), zero top-level await against remote deps, and memory that actually speeds CPU-bound init (often 1024–1769MB for fat handlers). Target illustrative Init p99 under ~400–800ms outside VPC; inside VPC, fix Hyperplane/ENI path before throwing money at Provisioned Concurrency. Keep SnapStart out of the Node story (Java/Corretto today) — your levers are package size, lazy clients, and reserved concurrency for the noisy neighbors.

Separate Init Duration from Duration (or you will optimize the wrong thing)

Lambda reports cold start as Init Duration in the REPORT line. Warm invokes only show Duration. If you chart average Duration alone, you are optimizing the happy path while p99 users eat Init.

// scripts/parse-lambda-reports.ts — turn CloudWatch Logs Insights into a habit
// Example Insights query (paste in console):
/*
fields @timestamp, @message
| filter @type = "REPORT"
| parse @message /Init Duration: (?<init>[\d.]+) ms/
| parse @message /Duration: (?<dur>[\d.]+) ms/
| parse @message /Memory Size: (?<mem>\d+) MB/
| stats
    count(*) as n,
    pct(dur, 50) as p50_dur,
    pct(dur, 99) as p99_dur,
    pct(init, 50) as p50_init,
    pct(init, 99) as p99_init,
    avg(mem) as mem_mb
  by bin(1h)
*/

export type Report = {
  initMs?: number;
  durationMs: number;
  memoryMb: number;
  cold: boolean;
};

export function parseReportLine(line: string): Report | null {
  const dur = /Duration: ([\d.]+) ms/.exec(line);
  if (!dur) return null;
  const init = /Init Duration: ([\d.]+) ms/.exec(line);
  const mem = /Memory Size: (\d+) MB/.exec(line);
  return {
    durationMs: Number(dur[1]),
    initMs: init ? Number(init[1]) : undefined,
    memoryMb: mem ? Number(mem[1]) : 0,
    cold: Boolean(init),
  };
}

// ✅ Alert on cold_rate * p99_init, not mean Duration
// ❌ “p50 Duration is fine” while 8% of traffic is cold after deploys

Wire a custom metric from a subscription filter or EMF so on-call sees cold rate:

// emit at end of handler when process.env.AWS_LAMBDA_INITIALIZATION_TYPE === 'on-demand'
// and you detected first invoke in this execution environment (module-level flag)
import { Metrics, MetricUnit } from "@aws-lambda-powertools/metrics";

const metrics = new Metrics({ namespace: "CheatCoders/Lambda", serviceName: "checkout" });
let isFirstInvoke = true;

export const handler = async () => {
  if (isFirstInvoke) {
    metrics.addMetric("ColdInvoke", MetricUnit.Count, 1);
    isFirstInvoke = false;
  } else {
    metrics.addMetric("WarmInvoke", MetricUnit.Count, 1);
  }
  metrics.publishStoredMetrics();
  return { ok: true };
};

Illustrative SLOs to start with (label as planning, not “our prod”): cold rate < 5% in steady state for user-facing APIs; Init p99 < 500ms for non-VPC Node 20 handlers under ~15MB zipped; alert when cold rate spikes after deploy for >15 minutes.

Cut the import graph: esbuild, tree-shake, lazy AWS clients

Node 20 does not magically make import AWS from "aws-sdk" cheap. V2 SDK is a cold-start tax. Prefer modular AWS SDK v3 and construct clients inside the handler or behind lazy getters so unused code paths stay out of Init when tree-shaken — or at least out of your critical path mentally.

// ❌ Cold-start anti-pattern: eager everything at module scope
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { S3Client } from "@aws-sdk/client-s3";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import heavy from "./generated-openapi-types"; // 2MB of types that somehow shipped

const ddb = new DynamoDBClient({});
const s3 = new S3Client({});
const secrets = new SecretsManagerClient({});
// top-level await against Secrets Manager = every cold start pays network
const secret = await secrets.send(new GetSecretValueCommand({ SecretId: "prod/db" }));

// ✅ Measure-cut pattern: tiny surface, lazy clients, cached secret after first use
import type { DynamoDBClient as DDB } from "@aws-sdk/client-dynamodb";

let ddb: DDB | undefined;
function getDdb() {
  if (!ddb) {
    // dynamic import keeps optional paths lighter when bundling carefully
    const { DynamoDBClient } = require("@aws-sdk/client-dynamodb") as typeof import("@aws-sdk/client-dynamodb");
    ddb = new DynamoDBClient({
      maxAttempts: 2,
      // keep region explicit — implicit lookup is another Init footgun in some setups
      region: process.env.AWS_REGION,
    });
  }
  return ddb;
}

let cachedJwtSecret: string | undefined;
async function jwtSecret(): Promise<string> {
  if (cachedJwtSecret) return cachedJwtSecret;
  const { SecretsManagerClient, GetSecretValueCommand } = await import(
    "@aws-sdk/client-secrets-manager"
  );
  const sm = new SecretsManagerClient({});
  const out = await sm.send(new GetSecretValueCommand({ SecretId: process.env.JWT_SECRET_ARN! }));
  cachedJwtSecret = out.SecretString!;
  // Still costs first *invoke*, not necessarily Init — decide consciously
  return cachedJwtSecret;
}

Bundle with esbuild (or similar) for Lambda Node 20:

# illustrative build — aim for single-digit MB unzipped when possible
npx esbuild src/handler.ts \
  --bundle --platform=node --target=node20 \
  --format=cjs \
  --minify \
  --external:@aws-sdk/\* \
  --outfile=dist/handler.js

# If you externalize AWS SDK, lean on the Lambda Node 20 runtime’s included SDK
# or vendor a pinned subset — measure both; don’t assume.
zip -j function.zip dist/handler.js

✅ Mark AWS SDK as external or bundle a known subset — pick one strategy and measure Init.
❌ Ship node_modules trees with test fixtures, source maps, and three HTTP clients “just in case.”

Memory is CPU: right-size instead of guessing 128MB

On Lambda, more memory means more CPU. Init that is CPU-bound (parsing big JSON schemas, crypto, cold V8) often drops non-linearly between 512MB and 1769MB. Duration cost may stay flat or fall because wall-clock shrinks.

# Power Tuning mindset (illustrative): run the same payload across memory sizes
# Track Init Duration AND billed GB-s — cheapest is not always 128MB

for mb in 256 512 1024 1769 3008; do
  aws lambda update-function-configuration \
    --function-name checkout-api \
    --memory-size "$mb" >/dev/null
  # invoke N times after a forced cold (publish new version / update env)
  echo "memory=$mb — capture Init from REPORT"
done

Rule of thumb for Node 20 APIs: if Init is >800ms at 512MB and package is modest, try 1024–1769 before Provisioned Concurrency. If Init is dominated by VPC ENI/Hyperplane wait, memory will not save you — fix networking.

VPC, concurrency, and when Provisioned Concurrency earns its keep

Cold starts inside a VPC used to mean multi-second ENI attaches. Hyperplane made that largely a solved class of problem for many accounts — but misconfigured subnets, missing VPC endpoints, and NAT hairpinning still inflate first-byte time. Prefer VPC endpoints for STS, Secrets Manager, and DynamoDB when the function must stay private.

# illustrative — endpoints beat “add provisioned concurrency to hide NAT latency”
resource "aws_vpc_endpoint" "secretsmanager" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.${var.region}.secretsmanager"
  vpc_endpoint_type   = "Interface"
  private_dns_enabled = true
  subnet_ids          = var.private_subnet_ids
  security_group_ids  = [aws_security_group.vpce.id]
}

Provisioned Concurrency (PC) is insurance for known traffic cliffs (market open, flash sale, scheduled fan-out), not a substitute for a 40MB unzipped handler.

# Publish a version, then PC on the alias — pay for ready execution environments
aws lambda publish-version --function-name checkout-api
aws lambda put-provisioned-concurrency-config \
  --function-name checkout-api \
  --qualifier live \
  --provisioned-concurrent-executions 5

✅ PC on the alias that serves humans; leave async workers on-demand.
❌ PC on every microservice “for consistency.”

Also respect reserved concurrency: a noisy batch job without a reserve can starve your API’s account concurrency and look like cold starts when it is actually throttling + retries.

Keep cutting: deploy hygiene and Node 20 specifics

  • Prefer nodejs20.x (active) over lingering nodejs18.x for security and V8 wins — re-measure after runtime bumps; never assume.
  • Avoid top-level await against network. Module init should be CPU/local only.
  • Prefer ARM64 (arm64) when your deps support it — often better price/perf; still measure Init.
  • Set NODE_OPTIONS=--enable-source-maps only in non-prod if it bloats startup; keep prod lean.
  • After each deploy, watch cold rate for 30–60 minutes; canaries that only hit warm aliases lie.
// Minimal handler shape that survives review
import type { APIGatewayProxyHandlerV2 } from "aws-lambda";

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  // business work — clients via lazy getters
  return { statusCode: 200, body: JSON.stringify({ ok: true, path: event.rawPath }) };
};

Pair this with the broader Lambda performance playbook in AWS Lambda Best Practices: Write Functions That Scale and Never Time Out and the sandbox patterns in LLM Coding Agents on AWS: Safe Tool Sandboxes with Lambda when agent tools share the same cold-start budget.

Closing checklist

✅ Dos
– ✅ Chart Init Duration p99 and cold rate separately from Duration
– ✅ Bundle/tree-shake; modular AWS SDK v3; lazy clients
– ✅ Right-size memory for CPU during init; re-run after dependency changes
– ✅ Fix VPC endpoints/subnets before buying Provisioned Concurrency
– ✅ Canary after deploy with forced cold invokes, not only warm synthetic checks

❌ Don’ts
– ❌ Don’t optimize mean Duration while ignoring Init
– ❌ Don’t await Secrets Manager / SSM at module top level
– ❌ Don’t ship multi-dozen-MB zips with unused clients
– ❌ Don’t spray Provisioned Concurrency across async workers
– ❌ Don’t blame “Lambda is slow” when reserved concurrency is zero and a batch job is thundering

Related reading

Last updated on September 10, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

3 Comments

Leave a Reply