Cold starts are the tax you pay for not running servers. The first invocation of a function that has no warm container behind it must wait for the platform to allocate compute, download your code, bootstrap the runtime, and initialize your application before a single line of handler logic executes. For a latency-sensitive API this can turn a 20ms p50 into a 2-second p99, and the worst part is that it happens unpredictably. This post breaks down exactly what a cold start consists of, which phases you can influence, and the concrete techniques that actually move the needle.
Anatomy of a Cold Start
A cold start is not one event — it is a pipeline of distinct phases, each with different optimization levers.
graph TD
A["Invocation arrives"] --> B{"Warm container?"}
B -->|"Yes"| C["Run handler immediately"]
B -->|"No: cold start"| D["Provision microVM / sandbox"]
D --> E["Download deployment package"]
E --> F["Start language runtime"]
F --> G["Run init code (outside handler)"]
G --> H["Execute handler"]
C --> I["Return response"]
H --> I
The phases, and who controls them:
| Phase | Owner | Your leverage |
|---|---|---|
| MicroVM / sandbox provisioning | Platform | None (but provisioned concurrency skips it) |
| Package download & unpack | Platform | High — package size |
| Runtime bootstrap | Platform | Medium — runtime choice |
| Init code execution | You | Very high — your code |
| Handler execution | You | N/A (this is the actual work) |
The crucial line is between init code (everything in module scope, run once per container) and handler code (run on every invocation). The platform charges init time as part of the cold start, and on many platforms init runs with a temporary CPU boost — making it the single biggest area where your decisions matter.
Init-Phase Discipline
Module-scope code runs once when the container is created and is reused across all subsequent warm invocations. This is where you want expensive, reusable setup — and where you must avoid anything per-request.
// GOOD: runs once, reused across invocations
const db = new DatabaseClient({ pool: true });
const config = loadConfig();
exports.handler = async (event) => {
// GOOD: only per-request work here
return db.query(buildQuery(event));
};
The anti-pattern is doing lazy initialization inside the handler “to be safe,” which spreads cold-start cost across every invocation instead of amortizing it once. But there is a tension: code in the init phase that is not needed for a given invocation path still runs and still costs you. The refinement is selective lazy init — initialize the SDK clients you almost always need at module scope, but defer rarely-used heavy dependencies behind a memoized accessor:
let _s3;
const getS3 = () => (_s3 ??= new S3Client({}));
This pays the cost only on the first invocation that actually touches S3, not on every cold start of a function that mostly never calls it.
Shrink the Package
Package download and unpack scales with size. A 250MB deployment artifact takes meaningfully longer to stage than a 5MB one. Practical reductions:
- Tree-shake and bundle. Ship a single minified bundle (esbuild, webpack) instead of a sprawling
node_modules. This routinely cuts package size by 80% and reduces the number of files the runtime must read. - Drop dev dependencies. They have no business in a production artifact.
- Use layers judiciously. Shared layers are cached and can avoid re-downloading common dependencies, but a layer still has to be mounted; do not treat layers as free.
- Prefer architecture-native binaries. A function with native dependencies compiled for the wrong architecture either fails or falls back to slow paths.
For container-image-based functions, the equivalent lever is image layer caching: structure your Dockerfile so that dependencies sit in a lower, stable layer and your application code in the top layer, so cold starts that pull the image reuse cached lower layers.
Runtime Choice Matters
Different runtimes have radically different bootstrap costs because they carry different amounts of baggage before your code runs.
| Runtime class | Relative bootstrap | Notes |
|---|---|---|
| Compiled (Go, Rust) | Lowest | Tiny static binary, near-instant start |
| Interpreted lightweight (Node.js, Python) | Low–medium | Fast to start, but init code dominates |
| JVM / .NET | Highest | Class loading & JIT warmup are expensive |
The JVM is the classic cold-start sufferer because class loading and JIT compilation happen at startup. Mitigations like ahead-of-time compilation (GraalVM native image) or framework-level optimizations (Quarkus, Micronaut) can collapse a multi-second JVM cold start to tens of milliseconds by doing at build time what the JVM normally does at runtime. If you control the runtime choice and cold starts are your bottleneck, a compiled language sidesteps most of the problem.
Provisioned Concurrency
The most direct mitigation is to simply never be cold. Provisioned concurrency keeps a configured number of execution environments initialized and waiting, so invocations up to that count skip provisioning, download, bootstrap, and init entirely — they go straight to the handler.
resource "aws_lambda_provisioned_concurrency_config" "api" {
function_name = aws_lambda_function.api.function_name
provisioned_concurrent_executions = 20
qualifier = aws_lambda_alias.live.name
}
The tradeoff is cost: you pay for those environments whether or not they handle traffic. The economically sound pattern is to combine provisioned concurrency with autoscaling tied to a schedule or a utilization metric — provision a floor that covers your steady-state traffic, and let on-demand concurrency (with its cold starts) absorb spikes above that floor. This way you pay for warm capacity only where it earns its keep.
The Scheduled-Ping Myth
A folk remedy is to ping your function every few minutes on a timer to “keep it warm.” This is fragile and mostly obsolete:
- It only keeps one container warm. The moment two requests arrive concurrently, the second one cold-starts anyway.
- It does nothing for scale-out: a traffic spike that needs 50 containers still pays 49 cold starts.
- It wastes invocations and muddies your metrics.
A single ping warms a single environment. Real concurrency needs provisioned concurrency, not a cron job. Treat the ping trick as a legacy hack and reach for proper provisioned capacity instead.
Connection and Dependency Cold Penalties
Cold starts compound with downstream connection setup. A fresh container has no established database connections, no warmed TLS sessions, no cached credentials. The first invocation pays for all of it.
# Module scope: established once, reused while container is warm
import boto3
session = boto3.Session()
ddb = session.resource("dynamodb")
table = ddb.Table("orders")
def handler(event, context):
return table.get_item(Key={"id": event["id"]})
For relational databases, the connection-per-container model interacts badly with cold starts and scale-out: a burst of cold containers can each open connections and exhaust the database’s connection limit. The standard answer is a connection proxy (RDS Proxy and equivalents) that pools connections on the database side, so a stampede of cold lambdas multiplexes onto a bounded pool rather than each grabbing a raw connection.
Measuring It Honestly
You cannot optimize what you do not isolate. Separate cold-start latency from warm latency in your telemetry. On AWS Lambda the Init Duration field in the report log line tells you the init cost; segment your latency dashboards by cold vs. warm so a 1% cold-start rate with 2-second inits does not hide inside an otherwise healthy p50.
A useful exercise is to compute the cold-start contribution to p99: if 2% of requests are cold and cold requests are 2 seconds, that 2% can entirely define your p99 even though the median is fast. This is why cold starts feel worse than the averages suggest — they live in the tail, which is exactly where SLAs are written.
A Decision Framework
- Latency-critical, predictable traffic: provisioned concurrency sized to steady state.
- Latency-critical, spiky traffic: provisioned floor + lean init code + small package to make on-demand cold starts as cheap as possible.
- Cost-sensitive, latency-tolerant (batch, async): accept cold starts, just minimize package and init.
- JVM/.NET workloads with cold-start pain: evaluate native compilation before throwing money at provisioned concurrency.
Conclusion
Cold starts are not a single problem with a single fix — they are a pipeline, and each phase has its own remedy. Most teams over-index on the scheduled-ping hack and under-invest in the two things that actually matter most: disciplined init-phase code and a small deployment package. Provisioned concurrency is the blunt instrument that eliminates the problem at a cost, and it should be reserved for the traffic that genuinely needs it. The sophisticated move is to measure cold starts in isolation, understand their contribution to your tail latency, and apply each lever where its cost is justified — rather than treating “serverless is slow” as an immutable fact.