Serverless Function Cold Start Optimization
Cold start—delay on first Lambda invoke after idle period or scaling to new instance. Consists of multiple phases: container creation (~100–500ms), runtime init (~50–200ms for Node.js), your function init code (depends on you). First two phases barely optimizable—all you can do is work with third.
Real numbers for Node.js 20 on AWS Lambda: without optimizations init-phase takes 300–800ms. After optimizations—50–150ms. On arm64 (Graviton2) runtime-phase 10–20% faster x86.
What Happens at Cold Start
Cold start phases:
1. Container init │ ~100-500ms │ AWS manages, not optimizable
2. Runtime init │ ~50-200ms │ depends on runtime and architecture
3. Function init │ YOUR CODE │ require/import, DB connections, SDK init
4. Handler execution │ YOUR CODE │ actual function work
Profile init-phase via environment variable:
# Add to Lambda environment
AWS_LAMBDA_EXEC_WRAPPER=/opt/aws-lambda-exec-wrapper
# or use built-in profiling
Best way to measure—CloudWatch Logs. Look for Init Duration line:
REPORT RequestId: abc123
Duration: 45.23 ms
Billed Duration: 46 ms
Memory Size: 512 MB
Max Memory Used: 89 MB
Init Duration: 312.45 ms ← this is cold start overhead
Reducing Bundle Size
Main reason for slow cold start—large bundle with unnecessary modules.
Before optimization:
// Bad—imports entire AWS SDK
import AWS from 'aws-sdk';
const s3 = new AWS.S3();
const dynamo = new AWS.DynamoDB.DocumentClient();
After:
// Good—only needed clients, AWS SDK v3 modular
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
// Initialize once outside handler
const s3 = new S3Client({ region: process.env.AWS_REGION });
const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({}));
Exclude AWS SDK from bundle (already in Lambda runtime for Node.js 18+):
// esbuild config
{
"external": ["@aws-sdk/*"],
"bundle": true,
"minify": true,
"target": "node20",
"platform": "node"
}
Sizes before/after. Typical Express app with aws-sdk v2:
- Before: 8–15 MB zip
- After: 500KB–2MB zip
Lazy Loading for Rarely Used Modules
// Bad—everything loads on init
import { createCanvas } from 'canvas';
import sharp from 'sharp';
import { PDFDocument } from 'pdf-lib';
export const handler = async (event) => {
if (event.type === 'generate-pdf') {
// used 5% of invocations
const pdf = await PDFDocument.create();
}
};
// Good—heavy modules only when needed
export const handler = async (event) => {
if (event.type === 'generate-pdf') {
const { PDFDocument } = await import('pdf-lib');
const pdf = await PDFDocument.create();
}
};
Initialization Outside Handler
Code outside handler executes once at cold start and reused between warm invocations:
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
// INIT PHASE—once
const client = new DynamoDBClient({
// Reuse TCP connections
requestHandler: {
requestTimeout: 3000,
httpsAgent: { keepAlive: true, maxSockets: 50 },
},
});
const dynamo = DynamoDBDocumentClient.from(client);
// Read environment variables once
const TABLE_NAME = process.env.TABLE_NAME!;
const STAGE = process.env.STAGE ?? 'dev';
// HANDLER—executes every time
export const handler = async (event) => {
// dynamo already initialized, connection reused
const result = await dynamo.send(new GetCommand({
TableName: TABLE_NAME,
Key: { pk: event.userId, sk: 'profile' },
}));
return result.Item;
};
Database Connection Optimization
Standard connection pool (pg Pool, Sequelize) doesn't work in serverless—each Lambda instance creates its own connection, at 1000 concurrent executions you get 1000 connections to PostgreSQL.
Solution 1: RDS Proxy (AWS-managed connection pooler):
// Connect to RDS Proxy, not directly to RDS
import { Pool } from 'pg';
const pool = new Pool({
host: process.env.RDS_PROXY_ENDPOINT, // xxx.proxy-xxx.rds.amazonaws.com
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 1, // 1 connection per Lambda instance
idleTimeoutMillis: 0,
connectionTimeoutMillis: 5000,
});
// Reuse between warm invocations
let cachedClient: pg.PoolClient | null = null;
export const getDbClient = async () => {
if (!cachedClient) {
cachedClient = await pool.connect();
}
return cachedClient;
};
Solution 2: PlanetScale or Neon—HTTP-based serverless databases without persistent connections:
import { neon } from '@neondatabase/serverless';
// Each request—HTTP, not TCP
const sql = neon(process.env.DATABASE_URL!);
export const handler = async (event) => {
const users = await sql`SELECT id, email FROM users WHERE active = true LIMIT 10`;
return users;
};
Provisioned Concurrency
Provisioned Concurrency—AWS keeps N Lambda instances always warmed. Init-phase executes beforehand, user gets response without delay.
# serverless.yml or SAM template
Resources:
ApiFunction:
Type: AWS::Serverless::Function
Properties:
# ...
AutoPublishAlias: live
ApiProvisionedConcurrency:
Type: AWS::Lambda::ProvisionedConcurrencyConfig
Properties:
FunctionName: !Ref ApiFunction
Qualifier: live
ProvisionedConcurrentExecutions: 5 # keep 5 warm instances
Via Serverless Framework:
functions:
api:
handler: src/handler.main
provisionedConcurrency: 5
# Auto-scaling via Application Auto Scaling
# configured separately via AWS Console or CDK
Provisioned Concurrency costs (billed even when idle), so use only for critical endpoints.
arm64 vs x86_64
Switch to Graviton2 (arm64)—simplest optimization with zero code changes:
# SAM template
Globals:
Function:
Architectures: [arm64] # was x86_64
Benefit: ~10–20% less init duration, ~20% cheaper on AWS rates. Only limitation: native Node.js modules (.node files) need recompiling for arm64. Regular JS/TS modules work unchanged.
Timeline
Bundle audit and basic optimization (esbuild, tree shaking, AWS SDK extraction)—1 day. Rework initialization with client extraction from handler, RDS Proxy setup—2–3 days. Provisioned Concurrency setup with monitoring and auto-scaling—1–2 days. Full cycle: from audit to production with measurable result—1 week.







