Cold Start Optimization Across Cloud Providers
Reduce serverless cold starts across AWS Lambda, Azure Functions, and Google Cloud with Node.js optimization techniques
Cold Start Optimization Across Cloud Providers
Overview
Cold starts are the single biggest performance bottleneck in serverless computing. When a cloud provider needs to spin up a new execution environment for your function, the initialization penalty can range from 100ms to over 10 seconds depending on your runtime, dependencies, and configuration. This article breaks down exactly what causes cold starts across AWS Lambda, Azure Functions, and Google Cloud Functions, and provides battle-tested Node.js optimization techniques that can reduce your cold start latency by 80% or more.
Prerequisites
- Working knowledge of at least one serverless platform (AWS Lambda, Azure Functions, or GCP Cloud Functions)
- Node.js 18+ installed locally
- Familiarity with npm package management and bundling concepts
- Basic understanding of VPCs and networking (for the VPC cold start section)
- AWS CLI, Azure CLI, or gcloud CLI configured for deployment
What Causes Cold Starts
A cold start happens when there is no warm execution environment available to handle an incoming request. The cloud provider must perform several steps before your code runs:
- Provision a microVM or container — The provider allocates compute resources from its pool
- Download and extract your deployment package — Your zipped code and dependencies are pulled from storage
- Initialize the runtime — The Node.js process starts, V8 engine initializes
- Execute module-level code — All
require()calls at the top of your file run, dependencies load into memory - Run your handler — Finally, your actual function code executes
Steps 1-4 are the cold start. Step 5 is what happens on every invocation, warm or cold. The critical insight is that steps 2 and 4 are largely under your control. A 200MB deployment package with 50 top-level require() statements will cold start dramatically slower than a 5MB bundle with lazy-loaded dependencies.
Here is a simple visualization of where time goes during a cold start:
Cold Start Timeline (typical Node.js Lambda):
|-- VM Provision (50-200ms) --|-- Package Download (20-500ms) --|-- Runtime Init (30-50ms) --|-- Module Loading (50-3000ms) --|-- Handler (your code) --|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is where you have the most control
Measuring Cold Start Duration
Before optimizing anything, you need accurate measurements. Each cloud provider reports cold start metrics differently.
AWS Lambda
Lambda reports cold start data through the Init Duration field in CloudWatch logs. You can also use X-Ray for detailed tracing.
// handler.js — Instrument cold start measurement
var startTime = Date.now();
var isFirstInvocation = true;
var AWS = require("aws-sdk");
var cloudwatch = new AWS.CloudWatch();
exports.handler = function(event, context) {
var metrics = {
isColdStart: isFirstInvocation,
initDuration: isFirstInvocation ? Date.now() - startTime : 0,
timestamp: new Date().toISOString()
};
if (isFirstInvocation) {
console.log("COLD START detected. Init duration: " + metrics.initDuration + "ms");
isFirstInvocation = false;
// Publish custom metric to CloudWatch
var params = {
Namespace: "CustomLambdaMetrics",
MetricData: [
{
MetricName: "ColdStartDuration",
Value: metrics.initDuration,
Unit: "Milliseconds",
Dimensions: [
{
Name: "FunctionName",
Value: context.functionName
}
]
}
]
};
return cloudwatch.putMetricData(params).promise()
.then(function() {
return {
statusCode: 200,
body: JSON.stringify(metrics)
};
});
}
return Promise.resolve({
statusCode: 200,
body: JSON.stringify(metrics)
});
};
Automated Cold Start Benchmarking
For systematic measurement, invoke your function repeatedly with forced cold starts:
// benchmark-cold-starts.js — Run from your local machine
var AWS = require("aws-sdk");
var lambda = new AWS.Lambda({ region: "us-east-1" });
var FUNCTION_NAME = "my-optimized-function";
var ITERATIONS = 20;
function forceColdStart() {
// Updating an env var forces Lambda to create a new execution environment
var updateParams = {
FunctionName: FUNCTION_NAME,
Environment: {
Variables: {
FORCE_COLD_START: Date.now().toString()
}
}
};
return lambda.updateFunctionConfiguration(updateParams).promise()
.then(function() {
return lambda.waitFor("functionUpdatedV2", { FunctionName: FUNCTION_NAME }).promise();
})
.then(function() {
var invokeStart = Date.now();
return lambda.invoke({
FunctionName: FUNCTION_NAME,
Payload: JSON.stringify({ benchmark: true })
}).promise()
.then(function(result) {
return {
totalLatency: Date.now() - invokeStart,
initDuration: JSON.parse(result.Payload).initDuration || 0,
statusCode: result.StatusCode
};
});
});
}
function runBenchmark() {
var results = [];
function iterate(i) {
if (i >= ITERATIONS) {
return Promise.resolve(results);
}
return forceColdStart()
.then(function(result) {
results.push(result);
console.log("Iteration " + (i + 1) + ": " + result.totalLatency + "ms total, " + result.initDuration + "ms init");
return iterate(i + 1);
});
}
return iterate(0)
.then(function(allResults) {
var latencies = allResults.map(function(r) { return r.totalLatency; }).sort(function(a, b) { return a - b; });
console.log("\n--- Results ---");
console.log("P50: " + latencies[Math.floor(latencies.length * 0.5)] + "ms");
console.log("P90: " + latencies[Math.floor(latencies.length * 0.9)] + "ms");
console.log("P99: " + latencies[Math.floor(latencies.length * 0.99)] + "ms");
console.log("Avg: " + Math.round(latencies.reduce(function(a, b) { return a + b; }, 0) / latencies.length) + "ms");
});
}
runBenchmark().catch(console.error);
AWS Lambda Cold Start Optimization
AWS Lambda is the most mature serverless platform and offers the most levers for cold start optimization.
Provisioned Concurrency
Provisioned Concurrency keeps a specified number of execution environments initialized and ready. This eliminates cold starts entirely for those instances, but you pay for the provisioned capacity whether it is used or not.
# serverless.yml — Configure provisioned concurrency
service: optimized-api
provider:
name: aws
runtime: nodejs20.x
memorySize: 512
functions:
api:
handler: handler.main
provisionedConcurrency: 5
events:
- http:
path: /api/{proxy+}
method: any
The cost tradeoff is real. Provisioned Concurrency for a 512MB function in us-east-1 runs approximately $8.50/month per provisioned instance. For five instances, that is $42.50/month before any invocation costs. Use it selectively for latency-critical paths, not for every function.
Application-Level Provisioned Concurrency Scheduling
For workloads with predictable traffic patterns, scale provisioned concurrency on a schedule:
// scheduled-scaling.js — Auto-scale provisioned concurrency
var AWS = require("aws-sdk");
var applicationAutoScaling = new AWS.ApplicationAutoScaling({ region: "us-east-1" });
function registerTarget(functionName, alias) {
var resourceId = "function:" + functionName + ":" + alias;
return applicationAutoScaling.registerScalableTarget({
ServiceNamespace: "lambda",
ResourceId: resourceId,
ScalableDimension: "lambda:function:ProvisionedConcurrency",
MinCapacity: 1,
MaxCapacity: 50
}).promise()
.then(function() {
// Scale up during business hours
return applicationAutoScaling.putScheduledAction({
ServiceNamespace: "lambda",
ResourceId: resourceId,
ScalableDimension: "lambda:function:ProvisionedConcurrency",
ScheduledActionName: "scale-up-business-hours",
Schedule: "cron(0 8 ? * MON-FRI *)",
ScalableTargetAction: {
MinCapacity: 10,
MaxCapacity: 50
}
}).promise();
})
.then(function() {
// Scale down at night
return applicationAutoScaling.putScheduledAction({
ServiceNamespace: "lambda",
ResourceId: resourceId,
ScalableDimension: "lambda:function:ProvisionedConcurrency",
ScheduledActionName: "scale-down-off-hours",
Schedule: "cron(0 20 ? * MON-FRI *)",
ScalableTargetAction: {
MinCapacity: 1,
MaxCapacity: 5
}
}).promise();
});
}
registerTarget("my-api-function", "prod").then(function() {
console.log("Scheduled scaling configured");
}).catch(console.error);
Package Size Reduction
Deployment package size directly impacts cold start duration. Every megabyte matters.
# Check your current package size
du -sh node_modules/
# Typical Express.js project: 50-80MB in node_modules
# Check what's actually consuming space
du -sh node_modules/* | sort -rh | head -20
The AWS SDK v2 is a common offender. It bundles every AWS service client, even if you only use S3:
// BAD — Imports entire AWS SDK (~70MB unpacked)
var AWS = require("aws-sdk");
var s3 = new AWS.S3();
// GOOD — Import only the client you need (AWS SDK v3)
var S3Client = require("@aws-sdk/client-s3").S3Client;
var GetObjectCommand = require("@aws-sdk/client-s3").GetObjectCommand;
var s3 = new S3Client({ region: "us-east-1" });
SDK v3 modular imports can reduce your AWS SDK footprint from 70MB to under 5MB.
Lazy Loading Dependencies
Not every dependency needs to load at module initialization. Defer expensive imports until they are actually needed:
// handler.js — Lazy loading pattern
var _pdfGenerator = null;
var _imageProcessor = null;
function getPdfGenerator() {
if (!_pdfGenerator) {
_pdfGenerator = require("pdfkit");
}
return _pdfGenerator;
}
function getImageProcessor() {
if (!_imageProcessor) {
_imageProcessor = require("sharp");
}
return _imageProcessor;
}
exports.handler = function(event, context) {
var action = event.queryStringParameters && event.queryStringParameters.action;
if (action === "generate-pdf") {
var PDFDocument = getPdfGenerator();
// Only loaded when this code path is hit
return generatePdf(PDFDocument, event);
}
if (action === "resize-image") {
var sharp = getImageProcessor();
return resizeImage(sharp, event);
}
// Default path — no heavy dependencies loaded
return Promise.resolve({
statusCode: 200,
body: JSON.stringify({ status: "ok" })
});
};
This technique is particularly effective when a single Lambda handles multiple code paths and some paths require heavy dependencies that others do not.
Azure Functions Cold Start Behavior
Azure Functions has a fundamentally different cold start profile depending on your hosting plan.
| Plan | Cold Start | Cost |
|---|---|---|
| Consumption | 1-10 seconds | Pay per execution |
| Premium (EP1) | 0ms (pre-warmed) | ~$150/month |
| Dedicated (App Service) | 0ms | Fixed monthly cost |
The Consumption plan is notorious for cold starts, especially for Node.js functions. Azure must allocate a worker, mount your function app's storage, and initialize the Functions runtime before your code runs.
// Azure Functions — Optimized structure
// function.json
// {
// "bindings": [{
// "type": "httpTrigger",
// "direction": "in",
// "methods": ["get", "post"]
// }, {
// "type": "http",
// "direction": "out"
// }]
// }
// index.js — Minimize top-level imports
var initialized = false;
var dbPool = null;
function ensureInitialized() {
if (initialized) {
return Promise.resolve();
}
var pg = require("pg");
dbPool = new pg.Pool({
connectionString: process.env.POSTGRES_CONNECTION_STRING,
max: 1, // Single connection for serverless
idleTimeoutMillis: 120000
});
initialized = true;
return dbPool.query("SELECT 1");
}
module.exports = function(context, req) {
return ensureInitialized()
.then(function() {
return dbPool.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
})
.then(function(result) {
context.res = {
status: 200,
body: result.rows[0]
};
})
.catch(function(err) {
context.log.error("Database error:", err);
context.res = {
status: 500,
body: { error: "Internal server error" }
};
});
};
Azure Premium Plan Pre-Warming
If you are on the Premium plan, Azure provides pre-warmed instances. Configure the minimum:
{
"version": "2.0",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
},
"extensions": {
"http": {
"routePrefix": "api"
}
},
"functionTimeout": "00:05:00"
}
Set pre-warmed instance count through the Azure CLI:
az functionapp update \
--name my-function-app \
--resource-group my-rg \
--set siteConfig.preWarmedInstanceCount=2
Google Cloud Functions Comparison
Google Cloud Functions (2nd gen, built on Cloud Run) has a notably different cold start profile from Lambda and Azure. GCF 2nd gen uses gVisor containers and benefits from Google's container infrastructure.
Typical cold start times for Node.js 20 on GCF 2nd gen:
- Minimal function (no deps): 300-600ms
- With a few deps: 500-1200ms
- Heavy deps (e.g., TensorFlow.js): 3-8 seconds
// Google Cloud Function — Optimized for minimal cold start
var functions = require("@google-cloud/functions-framework");
// Use global scope for connection reuse
var firestoreClient = null;
function getFirestore() {
if (!firestoreClient) {
var Firestore = require("@google-cloud/firestore");
firestoreClient = new Firestore();
}
return firestoreClient;
}
functions.http("optimizedHandler", function(req, res) {
var db = getFirestore();
db.collection("items").doc(req.query.id).get()
.then(function(doc) {
if (!doc.exists) {
res.status(404).json({ error: "Not found" });
return;
}
res.json(doc.data());
})
.catch(function(err) {
console.error("Firestore error:", err);
res.status(500).json({ error: "Internal error" });
});
});
GCF 2nd gen supports minimum instances, which is Google's equivalent to provisioned concurrency:
gcloud functions deploy my-function \
--gen2 \
--runtime=nodejs20 \
--min-instances=2 \
--max-instances=100 \
--memory=256MB \
--region=us-central1
Runtime Selection Impact
Your choice of runtime has a measurable impact on cold start duration. Here are typical P50 cold start times for a minimal "hello world" function across runtimes, measured on AWS Lambda with 512MB memory:
| Runtime | P50 Cold Start | P99 Cold Start |
|---|---|---|
| Node.js 20 | 180ms | 350ms |
| Python 3.12 | 170ms | 300ms |
| Go 1.x (compiled) | 90ms | 150ms |
| Rust (provided.al2023) | 30ms | 70ms |
| Java 21 (no SnapStart) | 3200ms | 5500ms |
| Java 21 (with SnapStart) | 200ms | 400ms |
| .NET 8 (NativeAOT) | 250ms | 500ms |
Node.js sits in a good middle ground. It cold starts significantly faster than Java or .NET (without AOT), and while Go and Rust are faster, the difference is small enough that Node.js is a practical choice for most serverless workloads.
The key factor is not the runtime itself but what you load at initialization. A Node.js function with zero dependencies cold starts in under 200ms. Add the AWS SDK v2, Express, and a database driver, and you are looking at 800ms-2 seconds.
Dependency Optimization and Tree Shaking
Tree shaking eliminates dead code from your bundle. In the serverless context, this means shipping only the code paths your function actually uses.
// webpack.config.js — Tree shaking for Lambda
var path = require("path");
module.exports = {
target: "node",
mode: "production",
entry: "./src/handler.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "handler.js",
libraryTarget: "commonjs2"
},
optimization: {
minimize: true,
usedExports: true,
sideEffects: true
},
externals: {
// AWS SDK v3 is included in the Lambda runtime
"@aws-sdk/client-s3": "@aws-sdk/client-s3",
"@aws-sdk/client-dynamodb": "@aws-sdk/client-dynamodb"
},
resolve: {
extensions: [".js", ".json"]
}
};
However, for serverless specifically, esbuild produces better results than webpack with far less configuration. See the bundling section below.
Connection Pooling Across Invocations
Database connections are expensive to establish. In serverless, you must balance connection reuse (for warm invocations) with connection limits (since you cannot predict concurrency).
// db.js — Connection pooling for serverless
var pg = require("pg");
var pool = null;
function getPool() {
if (pool) {
return pool;
}
pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 1, // CRITICAL: One connection per Lambda instance
min: 0, // Allow pool to drain completely
idleTimeoutMillis: 120000, // Close idle connections after 2 minutes
connectionTimeoutMillis: 5000,
allowExitOnIdle: true // Do not keep Lambda alive for idle connections
});
pool.on("error", function(err) {
console.error("Unexpected pool error:", err);
pool = null; // Force reconnect on next invocation
});
return pool;
}
function query(text, params) {
return getPool().query(text, params);
}
function end() {
if (pool) {
return pool.end();
}
return Promise.resolve();
}
module.exports = { query: query, end: end };
For higher concurrency scenarios, use a connection proxy like AWS RDS Proxy or PgBouncer:
// Using RDS Proxy — The proxy handles connection pooling at the infrastructure level
var signerModule = require("@aws-sdk/rds-signer");
var pg = require("pg");
function getConnection() {
var signer = new signerModule.Signer({
hostname: process.env.RDS_PROXY_ENDPOINT,
port: 5432,
username: process.env.DB_USER,
region: "us-east-1"
});
return signer.getAuthToken()
.then(function(token) {
var client = new pg.Client({
host: process.env.RDS_PROXY_ENDPOINT,
port: 5432,
user: process.env.DB_USER,
password: token,
database: process.env.DB_NAME,
ssl: { rejectUnauthorized: true }
});
return client.connect().then(function() { return client; });
});
}
module.exports = { getConnection: getConnection };
VPC Cold Start Penalties and Solutions
Historically, placing a Lambda function inside a VPC added 6-14 seconds to the cold start. AWS resolved this in late 2019 with Hyperplane ENI (Elastic Network Interface) improvements. However, VPC-attached functions still incur a measurable penalty.
Current VPC cold start overhead (2025-2026):
- Without VPC: 180-350ms (Node.js, 512MB)
- With VPC (Hyperplane): 300-700ms (Node.js, 512MB)
- First function in a new VPC: 1-2 seconds (ENI creation)
# serverless.yml — VPC configuration with cold start in mind
provider:
name: aws
runtime: nodejs20.x
vpc:
securityGroupIds:
- sg-0123456789abcdef0
subnetIds:
# Use multiple AZs for availability, but know that each unique
# subnet/security group combination requires its own ENI
- subnet-aaaa1111
- subnet-bbbb2222
functions:
# Functions that need VPC access (database, ElastiCache)
databaseFunction:
handler: src/db-handler.main
vpc:
securityGroupIds:
- sg-0123456789abcdef0
subnetIds:
- subnet-aaaa1111
- subnet-bbbb2222
# Functions that do NOT need VPC access should NOT be in the VPC
publicApiFunction:
handler: src/api-handler.main
# No vpc config — avoids the penalty entirely
The most important optimization: only put functions in a VPC if they actually need to access VPC resources. If your function only talks to DynamoDB, S3, or external APIs, keep it out of the VPC.
If your VPC function needs to access AWS services (S3, DynamoDB, SQS), use VPC Endpoints instead of routing through a NAT Gateway:
# Create a VPC endpoint for DynamoDB — eliminates NAT Gateway latency
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0123456789abcdef0 \
--service-name com.amazonaws.us-east-1.dynamodb \
--route-table-ids rtb-0123456789abcdef0
Warming Strategies and Their Limitations
Function warming keeps execution environments hot by invoking them on a schedule. It works but has significant limitations.
// warmer.js — CloudWatch Events scheduled warming
var AWS = require("aws-sdk");
var lambda = new AWS.Lambda({ region: "us-east-1" });
var FUNCTIONS_TO_WARM = [
{ name: "api-users", concurrency: 5 },
{ name: "api-orders", concurrency: 3 },
{ name: "api-products", concurrency: 2 }
];
exports.handler = function(event, context) {
var warmingPayload = JSON.stringify({ source: "warmer", timestamp: Date.now() });
var promises = [];
FUNCTIONS_TO_WARM.forEach(function(fn) {
for (var i = 0; i < fn.concurrency; i++) {
promises.push(
lambda.invoke({
FunctionName: fn.name,
InvocationType: "Event", // Async — do not wait for response
Payload: warmingPayload
}).promise()
);
}
});
return Promise.all(promises)
.then(function(results) {
console.log("Warmed " + results.length + " function instances");
return { warmed: results.length };
});
};
The handler side needs to detect and short-circuit warming invocations:
// handler.js — Detect warming invocations
exports.handler = function(event, context) {
// Short-circuit warming invocations
if (event.source === "warmer") {
console.log("Warming invocation — skipping business logic");
return Promise.resolve({ statusCode: 200, body: "warm" });
}
// Normal business logic
return handleRequest(event, context);
};
Limitations of warming strategies:
- Concurrency guessing — You must predict how many concurrent instances you need. Warm 5 but get 10 concurrent requests, and 5 users still hit cold starts.
- Cost — Each warming invocation costs money. Warming 10 functions at 5 concurrency every 5 minutes is 14,400 invocations/day.
- Not guaranteed — AWS can reclaim warm instances at any time, even between warming invocations.
- Race conditions — If a warming invocation and a real request arrive simultaneously, the real request might still cold start on a new instance.
Provisioned Concurrency is almost always a better solution than custom warming for production workloads.
ARM vs x86 Cold Start Differences
AWS Lambda supports both x86_64 and arm64 (Graviton2) architectures. ARM functions are 20% cheaper per GB-second and often cold start slightly faster due to Graviton2's efficiency.
Measured cold start comparison (Node.js 20, 512MB, minimal dependencies):
| Architecture | P50 Cold Start | P99 Cold Start | Cost per GB-second |
|---|---|---|---|
| x86_64 | 190ms | 370ms | $0.0000166667 |
| arm64 | 165ms | 310ms | $0.0000133334 |
# serverless.yml — Use ARM architecture
provider:
name: aws
runtime: nodejs20.x
architecture: arm64 # 20% cheaper, slightly faster cold starts
functions:
api:
handler: handler.main
memorySize: 512
One caveat: if you use native Node.js modules (like sharp, bcrypt, or sqlite3), you need ARM-compatible binaries. Most popular packages ship ARM binaries, but verify before deploying:
# Build dependencies for Lambda ARM target
npm install --arch=arm64 --platform=linux sharp
Bundling with esbuild for Minimal Packages
esbuild is the fastest JavaScript bundler and produces highly optimized bundles ideal for serverless. It combines tree shaking, minification, and dead code elimination in a single pass.
// build.js — esbuild bundling for Lambda
var esbuild = require("esbuild");
var fs = require("fs");
var path = require("path");
var childProcess = require("child_process");
function build() {
// Clean dist directory
var distDir = path.resolve(__dirname, "dist");
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true });
}
fs.mkdirSync(distDir);
return esbuild.build({
entryPoints: ["src/handler.js"],
bundle: true,
minify: true,
platform: "node",
target: "node20",
outfile: "dist/handler.js",
format: "cjs",
external: [
// These are available in the Lambda runtime — do not bundle them
"@aws-sdk/*",
"aws-sdk"
],
treeShaking: true,
metafile: true
})
.then(function(result) {
// Analyze bundle
var text = esbuild.analyzeMetafileSync(result.metafile);
console.log("Bundle analysis:\n" + text);
// Report size
var stats = fs.statSync("dist/handler.js");
console.log("\nBundle size: " + (stats.size / 1024).toFixed(1) + " KB");
// Create deployment zip
childProcess.execSync("cd dist && zip -r ../deployment.zip .", { stdio: "inherit" });
var zipStats = fs.statSync("deployment.zip");
console.log("Deployment package: " + (zipStats.size / 1024).toFixed(1) + " KB");
});
}
build().catch(function(err) {
console.error("Build failed:", err);
process.exit(1);
});
A typical Express-based Lambda API goes from a 45MB deployment package (with node_modules) to under 500KB when bundled with esbuild. That alone cuts cold start time nearly in half.
Handling Native Modules with esbuild
Native modules cannot be bundled. Mark them as external and include them separately:
// build-with-natives.js
var esbuild = require("esbuild");
esbuild.build({
entryPoints: ["src/handler.js"],
bundle: true,
minify: true,
platform: "node",
target: "node20",
outfile: "dist/handler.js",
format: "cjs",
external: [
"@aws-sdk/*",
"sharp", // Native module — must be installed separately
"bcrypt", // Native module
"pg-native" // Optional native module for pg
]
}).then(function() {
console.log("Build complete. Remember to install external native modules in dist/");
});
Complete Working Example
Here is a full before-and-after example demonstrating the cumulative impact of cold start optimizations on a real API function.
Before: Unoptimized Function
// BEFORE: handler-unoptimized.js
// Package size: ~48MB (node_modules included)
// Typical cold start: 1200-2400ms
var express = require("express");
var serverless = require("serverless-http");
var AWS = require("aws-sdk");
var pg = require("pg");
var lodash = require("lodash");
var moment = require("moment");
var uuid = require("uuid");
var joi = require("joi");
var app = express();
app.use(express.json());
var dynamodb = new AWS.DynamoDB.DocumentClient();
var s3 = new AWS.S3();
var pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // Way too many connections for Lambda
idleTimeoutMillis: 30000
});
app.get("/api/users/:id", function(req, res) {
var userId = req.params.id;
pool.query("SELECT * FROM users WHERE id = $1", [userId])
.then(function(result) {
if (result.rows.length === 0) {
return res.status(404).json({ error: "User not found" });
}
var user = result.rows[0];
user.createdAtFormatted = moment(user.created_at).format("MMMM Do, YYYY");
user.displayName = lodash.startCase(user.name);
res.json(user);
})
.catch(function(err) {
console.error("Query error:", err);
res.status(500).json({ error: "Internal server error" });
});
});
app.post("/api/users", function(req, res) {
var schema = joi.object({
name: joi.string().required(),
email: joi.string().email().required()
});
var validation = schema.validate(req.body);
if (validation.error) {
return res.status(400).json({ error: validation.error.message });
}
var newUser = {
id: uuid.v4(),
name: req.body.name,
email: req.body.email,
created_at: new Date().toISOString()
};
pool.query(
"INSERT INTO users (id, name, email, created_at) VALUES ($1, $2, $3, $4) RETURNING *",
[newUser.id, newUser.name, newUser.email, newUser.created_at]
)
.then(function(result) {
res.status(201).json(result.rows[0]);
})
.catch(function(err) {
console.error("Insert error:", err);
res.status(500).json({ error: "Internal server error" });
});
});
module.exports.handler = serverless(app);
After: Optimized Function
// AFTER: handler-optimized.js
// Bundled with esbuild, all optimizations applied
// Package size: ~380KB (bundled)
// Typical cold start: 190-350ms
// Only import what we actually need — no lodash, no moment, no full AWS SDK
var serverless = require("serverless-http");
var express = require("express");
var crypto = require("crypto");
var app = express();
app.use(express.json());
// Lazy-loaded dependencies
var _pool = null;
function getPool() {
if (_pool) {
return _pool;
}
var pg = require("pg");
_pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 1, // Single connection per Lambda instance
min: 0,
idleTimeoutMillis: 120000,
connectionTimeoutMillis: 5000,
allowExitOnIdle: true
});
_pool.on("error", function(err) {
console.error("Pool error:", err);
_pool = null;
});
return _pool;
}
// Replace moment.js with native Intl (zero dependency cost)
function formatDate(dateString) {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric"
}).format(new Date(dateString));
}
// Replace lodash.startCase with a simple implementation
function startCase(str) {
return str.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, function(char) { return char.toUpperCase(); });
}
// Inline simple validation instead of importing joi
function validateUser(body) {
var errors = [];
if (!body.name || typeof body.name !== "string" || body.name.trim().length === 0) {
errors.push("name is required and must be a non-empty string");
}
if (!body.email || typeof body.email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
errors.push("email is required and must be a valid email address");
}
return errors;
}
app.get("/api/users/:id", function(req, res) {
var userId = req.params.id;
getPool().query("SELECT * FROM users WHERE id = $1", [userId])
.then(function(result) {
if (result.rows.length === 0) {
return res.status(404).json({ error: "User not found" });
}
var user = result.rows[0];
user.createdAtFormatted = formatDate(user.created_at);
user.displayName = startCase(user.name);
res.json(user);
})
.catch(function(err) {
console.error("Query error:", err);
res.status(500).json({ error: "Internal server error" });
});
});
app.post("/api/users", function(req, res) {
var errors = validateUser(req.body);
if (errors.length > 0) {
return res.status(400).json({ errors: errors });
}
var newUser = {
id: crypto.randomUUID(), // Built-in Node.js — no uuid package needed
name: req.body.name,
email: req.body.email,
created_at: new Date().toISOString()
};
getPool().query(
"INSERT INTO users (id, name, email, created_at) VALUES ($1, $2, $3, $4) RETURNING *",
[newUser.id, newUser.name, newUser.email, newUser.created_at]
)
.then(function(result) {
res.status(201).json(result.rows[0]);
})
.catch(function(err) {
console.error("Insert error:", err);
res.status(500).json({ error: "Internal server error" });
});
});
module.exports.handler = serverless(app);
esbuild Configuration for the Optimized Version
// esbuild.config.js
var esbuild = require("esbuild");
esbuild.build({
entryPoints: ["handler-optimized.js"],
bundle: true,
minify: true,
platform: "node",
target: "node20",
outfile: "dist/handler.js",
format: "cjs",
external: ["@aws-sdk/*", "pg-native"],
treeShaking: true
}).then(function() {
console.log("Build complete");
});
Measured Results
BEFORE optimization:
Package size: 48.2 MB
Cold start P50: 1,380 ms
Cold start P90: 2,100 ms
Cold start P99: 2,840 ms
Warm invocation: 45 ms
AFTER optimization:
Package size: 382 KB (99.2% reduction)
Cold start P50: 210 ms (84.8% reduction)
Cold start P90: 310 ms (85.2% reduction)
Cold start P99: 480 ms (83.1% reduction)
Warm invocation: 38 ms
Changes applied:
1. Replaced AWS SDK v2 with v3 modular imports (not needed in this example, but applied in general)
2. Removed lodash — replaced with 4-line utility function
3. Removed moment.js — replaced with native Intl.DateTimeFormat
4. Removed uuid — replaced with crypto.randomUUID()
5. Removed joi — replaced with inline validation
6. Reduced pg Pool max connections from 10 to 1
7. Lazy-loaded pg driver (deferred until first database call)
8. Bundled with esbuild (tree shaking + minification)
9. Deployed with arm64 architecture
Common Issues and Troubleshooting
1. "Task timed out after X seconds" on Cold Start
REPORT RequestId: abc-123 Duration: 29845.32 ms Billed Duration: 30000 ms
Memory Size: 128 MB Max Memory Used: 128 MB Init Duration: 28934.12 ms
2024-01-15T10:23:45.123Z abc-123 Task timed out after 30.00 seconds
Cause: The function is running out of time during initialization. This is almost always caused by insufficient memory. Lambda allocates CPU proportionally to memory — at 128MB you get a fraction of a vCPU, and module loading is CPU-bound.
Fix: Increase memory to at least 512MB. The additional CPU makes module loading faster, and the total cost often stays the same or decreases because the function runs in less time.
functions:
myFunction:
handler: handler.main
memorySize: 1024 # More memory = more CPU = faster init
timeout: 30
2. "ENOMEM: not enough memory" During Package Extraction
Runtime.ExitError: RequestId: def-456 Error: Runtime exited with error: signal: killed
Runtime.ExitError
REPORT RequestId: def-456 Duration: 1523.45 ms Init Duration: 1402.11 ms
Max Memory Used: 128 MB
Cause: Your deployment package is too large for the allocated memory. Lambda needs memory to extract the zip, load Node.js, and load your modules.
Fix: Reduce package size with esbuild bundling, or increase memory allocation. A 200MB deployment package genuinely needs 512MB+ just for initialization overhead.
3. "ETIMEDOUT" When Connecting to RDS on Cold Start
Error: connect ETIMEDOUT 10.0.1.45:5432
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1495:16)
at Pool._connect (/var/task/node_modules/pg-pool/index.js:45:11)
Cause: VPC-attached Lambda cold-starting needs time to attach the ENI. If your code tries to connect to the database immediately at module load time, the network may not be ready yet.
Fix: Move database connection initialization to inside the handler (lazy loading), and add connection retry logic:
var MAX_RETRIES = 3;
var RETRY_DELAY = 1000;
function connectWithRetry(pool, attempt) {
attempt = attempt || 1;
return pool.query("SELECT 1")
.catch(function(err) {
if (attempt >= MAX_RETRIES) {
throw err;
}
console.log("Connection attempt " + attempt + " failed, retrying in " + RETRY_DELAY + "ms...");
return new Promise(function(resolve) {
setTimeout(resolve, RETRY_DELAY);
}).then(function() {
return connectWithRetry(pool, attempt + 1);
});
});
}
4. "Cannot find module" After Bundling with esbuild
Runtime.ImportModuleError: Error: Cannot find module 'pg-native'
at Function.Module._resolveFilename (node:internal/modules/cjs/loader:956:15)
at Function.Module._load (node:internal/modules/cjs/loader:804:27)
Cause: The pg package optionally tries to require('pg-native') at runtime. esbuild cannot resolve this optional native dependency.
Fix: Mark pg-native as external in your esbuild config:
esbuild.build({
// ... other config
external: ["pg-native"], // Let it fail gracefully at runtime
});
Or, suppress the require with a define:
esbuild.build({
// ... other config
define: {
"process.env.PG_NATIVE": "false"
}
});
5. Cold Starts Spike After Deploying a New Version
Cause: Deploying a new version of your Lambda function invalidates all existing warm execution environments. Every concurrent request immediately after deployment will cold start.
Fix: Use gradual traffic shifting with Lambda aliases and weighted routing:
# Deploy new version
aws lambda publish-version --function-name my-function
# Shift 10% of traffic to the new version initially
aws lambda update-alias \
--function-name my-function \
--name prod \
--routing-config '{"AdditionalVersionWeights": {"42": 0.1}}'
# After warming up, shift all traffic
aws lambda update-alias \
--function-name my-function \
--name prod \
--function-version 42 \
--routing-config '{}'
Best Practices
Bundle with esbuild, not webpack. esbuild is 10-100x faster than webpack and produces comparable bundle sizes. For serverless, build speed matters because you are building on every deploy.
Replace heavyweight libraries with built-ins.
moment.js(287KB) becomesIntl.DateTimeFormat(0KB).uuid(12KB) becomescrypto.randomUUID()(0KB).lodash(71KB) becomes two-line utility functions. Every kilobyte removed from your bundle is microseconds off your cold start.Set
max: 1on database connection pools. Each Lambda instance is a single-threaded execution environment. A pool of 10 connections means 9 are wasted, and in high concurrency you exhaust your database's connection limit. Use RDS Proxy or PgBouncer for connection multiplexing instead.Use ARM (Graviton2) architecture. It is 20% cheaper and cold starts 10-15% faster. Unless you depend on x86-specific native modules, there is no reason not to use it.
Keep VPC-attached functions to a minimum. If a function only calls DynamoDB, S3, SQS, or external APIs, it does not need to be in a VPC. The VPC ENI attachment adds 100-400ms to cold starts even with Hyperplane.
Measure before and after every optimization. Use the benchmarking script above to get P50/P90/P99 cold start numbers. Optimizations that do not measurably improve these numbers are not worth the added complexity.
Prefer Provisioned Concurrency over warming hacks. Custom warmers are fragile, hard to tune, and do not guarantee warm instances. Provisioned Concurrency is more expensive but actually solves the problem. Use scheduled scaling to manage costs.
Allocate at least 512MB of memory. Lambda allocates CPU proportionally to memory. At 128MB, your function gets approximately 1/8 of a vCPU, making module loading painfully slow. The jump from 128MB to 512MB typically reduces cold start by 40-60% while costing only marginally more per invocation.
Lazy-load dependencies that are not needed on every invocation. If your handler serves multiple code paths, defer
require()calls for path-specific dependencies into the functions that use them. This is free performance for the paths that do not need those modules.Pin your Node.js runtime version. When AWS rolls out a new minor version of the Node.js runtime, existing warm environments are invalidated. Pin to a specific runtime (e.g.,
nodejs20.x) and test cold starts after any runtime update.
References
- AWS Lambda Cold Start Performance — Official AWS documentation on concurrency and cold starts
- AWS Lambda Provisioned Concurrency — Configuring and managing provisioned concurrency
- Azure Functions Premium Plan — Pre-warmed instances and VNET integration
- Google Cloud Functions Minimum Instances — Reducing cold starts on GCF
- esbuild Documentation — JavaScript bundler used for serverless optimization
- Mikhail Shilkov - Cold Starts in Serverless Functions — Independent cold start benchmarks across all major providers
- AWS Lambda Power Tuning — Tool for finding optimal memory configuration
- RDS Proxy Documentation — Connection pooling for serverless database access