Where Serverless Cold Start Time Actually Goes
Everyone blames the platform. Most of a cold start is usually your own initialisation code, and you can measure exactly which part.
Disclosure: I run an infrastructure company that competes in an adjacent space. The measurements and techniques here apply regardless of provider.
The short answer
A cold start has four phases and only the first is the platform's:
| Phase | Typical | Who owns it |
|---|---|---|
| Sandbox provisioning | 50 to 200ms | Platform |
| Runtime bootstrap | 20 to 100ms | Platform |
| Your code loading, imports | 100ms to 3s | You |
| Your init code, connections | 50ms to 2s | You |
The last two are usually the majority and they are entirely under your control. Measure before optimising:
const t0 = performance.now();
import { thing } from "./heavy-module.js";
console.log("import took", performance.now() - t0);
Measure the phases separately
Module loading and initialisation get conflated. Time them apart:
// index.js
const bootStart = performance.now();
import { S3Client } from "@aws-sdk/client-s3";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const importsDone = performance.now();
const sql = postgres(process.env.DATABASE_URL, { max: 1 });
const db = drizzle(sql);
const s3 = new S3Client({});
const initDone = performance.now();
console.log(JSON.stringify({
imports_ms: importsDone - bootStart,
init_ms: initDone - importsDone,
}));
export async function handler(event) {
const t = performance.now();
const result = await doWork(event);
console.log(JSON.stringify({ handler_ms: performance.now() - t }));
return result;
}
Log it as JSON so you can query it. Then look at the distribution across cold starts, not one sample, because the variance is large.
On AWS Lambda, Init Duration in the REPORT line gives you everything before the handler runs. If that number is 1800ms and your handler takes 40ms, you know exactly where to work.
Imports are usually the biggest chunk
The single most common cause of a slow cold start is pulling in far more code than the function uses.
Import specific modules, not the whole SDK:
// loads a very large surface
import AWS from "aws-sdk";
// loads one client
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
AWS SDK v3 is modular specifically for this reason, and v2 in a Lambda is a meaningful cold start cost.
Avoid barrel files. Importing one utility from ./utils/index.js pulls the entire directory's dependency graph. This is the same problem that slows TypeScript builds and it costs you at runtime too.
Bundle and tree shake. A bundled function loads one file instead of resolving hundreds of modules from disk. Filesystem operations in a cold sandbox are slow, and module resolution is filesystem heavy.
esbuild src/handler.ts --bundle --platform=node --target=node22 \
--format=esm --minify --outfile=dist/handler.mjs
I have seen this alone take a cold start from 1.4 seconds to 300ms on a function with a large dependency tree. It is usually the highest return change available.
Check what you are actually shipping:
esbuild src/handler.ts --bundle --analyze --outfile=/dev/null
That prints a size breakdown by module. The surprises are usually a date library, a validation library pulled in by something else, or a logging framework with plugins.
Lazy load what is conditionally used:
export async function handler(event) {
if (event.type === "pdf") {
const { generatePdf } = await import("./pdf.js"); // only on this path
return generatePdf(event);
}
return handleNormal(event);
}
A PDF library that loads on every invocation for a code path taken one percent of the time is pure cold start tax.
Initialisation outside the handler
Move connection setup and client creation to module scope so it happens once per sandbox rather than once per invocation:
// module scope: runs once per cold start
const db = createPool({ max: 1 });
export async function handler(event) {
return db.query("SELECT ...");
}
Two important caveats.
max: 1 on the pool. Each concurrent invocation is a separate sandbox with its own pool. A pool of 10 across 200 concurrent invocations is 2000 connections, which exhausts Postgres immediately. Use a connection pooler in front of the database and keep the per-sandbox pool at one.
Do not await at module scope unless you have to. A top level await for a connection makes every cold start wait for it, even for requests that will not touch the database. Create the client eagerly, connect lazily:
let dbPromise;
const getDb = () => (dbPromise ??= connect());
export async function handler(event) {
if (!needsDb(event)) return fastPath(event);
const db = await getDb();
...
}
Secrets and config
A frequent hidden cost. Fetching secrets from a secrets manager on every cold start adds a network round trip, often 100 to 300ms.
Options, in order of preference:
Inject at deploy time as environment variables if the sensitivity allows it. Zero runtime cost.
Cache in module scope so it is once per sandbox rather than once per invocation.
Use the platform's caching extension. AWS Parameters and Secrets Lambda Extension caches locally and removes most of the cost.
The runtime matters
Rough cold start ranking for equivalent work: compiled languages first, then interpreted with small runtimes, then JVM and .NET without ahead of time compilation.
If cold start is critical and the function is small, a compiled language is a legitimate answer. For most applications it is not worth rewriting, and getting your bundle from 40MB to 2MB is a much cheaper win.
Also worth knowing: larger memory allocations get proportionally more CPU on most platforms. A function at 256MB may cold start twice as slowly as the same function at 1024MB, and cost the same or less overall because it finishes faster. Test at several memory sizes and measure cost per invocation, not just duration. This is genuinely counterintuitive and frequently the easiest win available.
Provisioned concurrency and its tradeoff
You can pay to keep sandboxes warm. It works and it removes the cold start.
It also means paying for idle capacity, which undoes part of the reason for going serverless. Worth it for a latency sensitive user facing endpoint with predictable traffic. Not worth it for a background job.
The pattern I would avoid is a warming ping on a schedule. It keeps one sandbox warm and does nothing for concurrent requests, so under any real traffic you still get cold starts, and you have added invocations and complexity for a partial fix.
The measurement to actually track
Cold start percentage matters more than cold start duration.
cold_starts / total_invocations
A 2 second cold start affecting 0.1 percent of requests is a different problem from a 400ms cold start affecting 30 percent. The second is worse for users and gets less attention because the number looks better.
Also watch p99 latency including cold starts, not just warm path latency. Dashboards that exclude cold starts are describing an experience some of your users are not having, which is the same category of mistake as averaging away a latency spike.
An order to work in
- Measure init versus imports versus handler, and get the distribution
- Bundle and tree shake, which is usually the biggest single win
- Remove or lazy load conditionally used heavy dependencies
- Move client creation to module scope, connect lazily
- Cache secrets, or inject them at deploy
- Test at several memory sizes and compare cost per invocation
- Only then consider provisioned concurrency
Most people start at step seven, which is the expensive answer to a problem usually solved in steps two and three.