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-levelawaitagainst 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 lingeringnodejs18.xfor security and V8 wins — re-measure after runtime bumps; never assume. - Avoid top-level
awaitagainst 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-mapsonly 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
- AWS Lambda Best Practices: Write Functions That Scale and Never Time Out
- LLM Coding Agents on AWS: Safe Tool Sandboxes with Lambda
- Node.js Clustering & Worker Threads: CPU Scaling Without Melting the Event Loop
- Event-Driven Architecture: Decoupled Systems With Message Queues
- Lambda Timeouts, Retries, and DLQs: Idempotent Failure Handling
- Lambda Powertools for Node: Structured Logs That Survive On-Call
Last updated on September 10, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.

Pingback: AI Coding in VPC: Private Bedrock Endpoints and Secret Hygiene - CheatCoders
Pingback: V8 Maglev vs TurboFan: When Tiering Ups Silently Hurt p99 - CheatCoders
Pingback: Node undici Dispatcher Pools: Kill Keep-Alive Storms Under Spikes - CheatCoders