mdashikjs/blog
All posts
Taming AWS Lambda Cold Starts in Production
Cloud

Taming AWS Lambda Cold Starts in Production

Cloud5 min

Taming AWS Lambda Cold Starts in Production

Cold starts are the tax you pay for not managing servers. In our payment processing Lambda, that tax was 3 seconds on the first invocation. Here is how we brought it under 200ms.

AWSLambdaServerlessPerformance
Share:

What actually happens during a cold start

When Lambda receives a request and no warm execution environment exists, it must:

  1. Download your deployment package from S3
  2. Start the runtime (Node.js, Python, etc.)
  3. Execute your initialization code (imports, DB connections, SDK clients)
  4. 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
MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.