What actually happens during a cold start
When Lambda receives a request and no warm execution environment exists, it must:
- Download your deployment package from S3
- Start the runtime (Node.js, Python, etc.)
- Execute your initialization code (imports, DB connections, SDK clients)
- Run the handler
Steps 1-3 are the cold start. Step 4 is what you pay for on every invocation.
Measure before you optimize
Add this to your handler to separate cold start from execution time:
const initStart = Date.now();
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { Stripe } from 'stripe';
const db = new DynamoDBClient({});
const stripe = new Stripe(process.env.STRIPE_KEY!);
const initDuration = Date.now() - initStart;
console.log(`Init: ${initDuration}ms`);
export const handler = async (event: APIGatewayEvent) => {
const start = Date.now();
// ... handler logic
console.log(`Handler: ${Date.now() - start}ms`);
};
CloudWatch also reports Init Duration separately. Check it.
Reduce bundle size
The single biggest cold start lever. Our Lambda was bundling the entire AWS SDK v3 because of barrel imports:
// Bad: pulls in every AWS service
import { DynamoDB } from 'aws-sdk';
// Good: only the DynamoDB client
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
We switched from aws-sdk (v2, 70MB) to modular @aws-sdk (v3, 3MB for DynamoDB alone). Cold start dropped from 3s to 900ms just from this change.
Then we added esbuild bundling with tree-shaking:
// esbuild.config.ts
await build({
entryPoints: ['src/handler.ts'],
bundle: true,
minify: true,
platform: 'node',
target: 'node20',
outfile: 'dist/handler.js',
external: ['@aws-sdk/*'], // use Lambda's built-in SDK
});
Marking @aws-sdk/* as external uses the SDK bundled in the Lambda runtime. Deployment package went from 12MB to 180KB.
Provisioned concurrency
For latency-sensitive paths (payment processing, auth), provisioned concurrency keeps N environments warm at all times:
Resources:
PaymentFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handler.handler
Runtime: nodejs20.x
ProvisionedConcurrencyConfig:
ProvisionedConcurrentExecutions: 5
This costs money — you pay for idle compute. But for our payment Lambda, the alternative was a 3-second delay on the first transaction after quiet periods. The business case was obvious.
Keep connections outside the handler
Database connections established inside the handler are created on every invocation. Move them to module scope so they persist across warm invocations:
// Module scope: created once per cold start
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
export const handler = async (event) => {
// Reuses the existing connection
const result = await pool.query('SELECT ...');
};
Set max: 1 — each Lambda instance should hold exactly one connection.
Results
- Cold start: 3s to 180ms (bundle optimization + SDK v3)
- Warm invocation: unchanged at 45ms
- Payment processing: zero customer-facing cold starts (provisioned concurrency)
- Monthly cost increase from provisioned concurrency: $18
