Cold Start Latency — Why Your Serverless Function Is Slow on First Request
Advertisement
Introduction
You migrate an API to AWS Lambda and response times look great — 50ms on average. But every few minutes, one request takes four seconds for no apparent reason, and your p99 latency is terrible. This is cold start latency: the unavoidable cost of spinning up a new container for a serverless function. Understanding what drives cold starts — and how to minimize them — is the difference between serverless feeling like a win and a liability.
What Is a Cold Start?
A serverless function does not run on a persistent server. When invoked on a fresh container, the cloud provider must complete three phases before your handler code runs:
- Provision a container — allocate resources, download the runtime
- Initialize your code — execute module-level imports and setup
- Run the handler — the actual business logic
On a warm container (one that already exists from a previous invocation), only phase 3 runs. The cold start tax is phases 1 and 2, which can range from 200ms for a minimal JavaScript function to 10 seconds for a heavyweight JVM service.
A typical Node.js cold start breakdown:
Container provisioning: 500ms
Node.js runtime init: 200ms
Heavy imports (SDKs): 800ms
Database connection: 300ms
Handler execution: 50ms
Total cold start: 1850ms
Warm invocation:
Handler execution: 50msWhat Makes Cold Starts Slow
The two biggest contributors are heavy module-level imports and synchronous initialization work:
// Every one of these lines runs during cold start, before any request is handled
const { PrismaClient } = require('@prisma/client');
const sharp = require('sharp'); // native module, very slow to load
const pdfkit = require('pdfkit'); // heavy dependency
const { S3Client } = require('@aws-sdk/client-s3');
const OpenAI = require('openai');
const db = new PrismaClient(); // opens database connection
const config = JSON.parse(
fs.readFileSync('/etc/config.json', 'utf8') // synchronous disk read
);Each import and initialization step runs sequentially during the cold start. If most requests do not use sharp or pdfkit, loading them unconditionally wastes 400-600ms on every cold start.
Fix 1: Lazy Load Heavy Modules
Load modules only when they are actually needed:
let s3Client = null;
let sharpLib = null;
async function resizeImage(buffer) {
if (!sharpLib) {
sharpLib = require('sharp'); // loaded once, reused on warm invocations
}
if (!s3Client) {
const { S3Client } = require('@aws-sdk/client-s3');
s3Client = new S3Client({ region: process.env.AWS_REGION });
}
return sharpLib(buffer).resize(800, 600).toBuffer();
}
exports.handler = async (event) => {
if (event.type === 'resize') {
return resizeImage(event.buffer);
}
// Fast path: no heavy modules loaded
return { statusCode: 200, body: 'ok' };
};The module is loaded on the first warm invocation that needs it, then cached in the container for subsequent warm calls.
Fix 2: Reuse Connections Across Warm Invocations
Lambda containers are reused between invocations. Initialize expensive resources at module level (outside the handler) so they persist across warm calls:
const { Pool } = require('pg');
// Initialized once per container, reused across warm invocations
let pool = null;
function getPool() {
if (!pool) {
pool = new Pool({ connectionString: process.env.DATABASE_URL });
}
return pool;
}
exports.handler = async (event) => {
const db = getPool();
const result = await db.query('SELECT * FROM users WHERE id = $1', [event.userId]);
return result.rows[0];
};Without this pattern, each invocation opens and closes a database connection — adding 100-300ms per request.
Fix 3: Reduce Bundle Size
Smaller deployment packages load faster. Use esbuild to tree-shake and minify:
# Bundle handler.js with esbuild — exclude AWS SDK (already in Lambda runtime)
npx esbuild src/handler.js \
--bundle \
--minify \
--platform=node \
--target=node20 \
--external:@aws-sdk/* \
--outfile=dist/handler.js
# Check bundle size
ls -lh dist/handler.jsMarking @aws-sdk/* as external drops 40MB from the bundle. The Lambda runtime includes AWS SDK v3 natively — bundling it doubles your cold start time for nothing.
Fix 4: Provisioned Concurrency
Provisioned concurrency keeps a set number of containers pre-initialized. Cold starts on those containers never happen:
# serverless.yml
functions:
api:
handler: dist/handler.main
provisionedConcurrency: 5 # 5 containers always warm// AWS CDK equivalent
const fn = new lambda.Function(this, 'Api', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'handler.main',
code: lambda.Code.fromAsset('dist'),
memorySize: 1024,
});
const alias = new lambda.Alias(this, 'Live', {
aliasName: 'live',
version: fn.currentVersion,
provisionedConcurrentExecutions: 5,
});Provisioned concurrency has an hourly cost even when idle. Use it for latency-sensitive endpoints (checkout, authentication) and accept cold starts on background jobs.
Fix 5: Memory Allocation and SnapStart
Lambda allocates CPU proportionally to memory. A 1GB Lambda gets twice the CPU of a 512MB Lambda. More CPU means faster module initialization and handler execution.
Set memory to at least 1024MB for latency-sensitive functions. The additional CPU often pays for itself in reduced cold start time even if your handler does not use much RAM.
For Java and Python Lambda functions, AWS SnapStart takes a snapshot of the initialized environment and restores it on cold starts, reducing JVM cold starts from 8-10 seconds to under 1 second.
Fix 6: Scheduled Warmer (No-Cost Alternative)
A scheduled function can ping your handler every few minutes to keep containers warm:
// warmer.js — invoked by CloudWatch Events every 5 minutes
const { LambdaClient, InvokeCommand } = require('@aws-sdk/client-lambda');
exports.handler = async () => {
const lambda = new LambdaClient({});
await lambda.send(new InvokeCommand({
FunctionName: process.env.TARGET_FUNCTION,
InvocationType: 'Event',
Payload: JSON.stringify({ source: 'warmer' }),
}));
};// In the target handler — ignore warmer pings immediately
exports.handler = async (event) => {
if (event.source === 'warmer') {
return { statusCode: 200 };
}
// Normal handler logic
};Warmers work for low-concurrency functions. For functions that receive bursts of traffic simultaneously, provisioned concurrency is the only reliable solution.
Key Takeaways
- Cold start latency is the cost of phases 1 and 2 (container provisioning and code initialization) that only run on new Lambda containers.
- The biggest cold start drivers are heavy module-level imports (native modules, large SDKs) and synchronous initialization work performed before the handler runs.
- Lazy loading heavy modules (sharp, PDF libraries, OpenAI SDK) at first use rather than at module level can cut cold start times by 30-60%.
- Database connections and HTTP clients initialized outside the handler function persist across warm invocations — do not re-create them on every call.
- Marking
@aws-sdk/*as external in your esbuild bundle removes 40MB and eliminates one of the largest cold start contributors. - Memory allocation above 1GB significantly increases available CPU, which speeds up both module initialization and handler execution.
- Provisioned concurrency eliminates cold starts entirely for a fixed set of containers at an hourly idle cost.
- For latency-sensitive endpoints (checkout, login), provisioned concurrency at even 2-3 containers often pays for itself in reduced user churn from slow first loads.
Advertisement