MervCodes

Tech Reviews From A Programmer

AWS Lambda Cold Start: Causes and How to Reduce Latency

7 min read

Cold starts are the tax you pay for serverless. I've spent more time than I'd like to admit staring at CloudWatch metrics, watching p99 latency spike every time Lambda decides to spin up a fresh container. When there's no warm execution environment available, Lambda provisions one from scratch — downloading your code, starting the runtime, and initializing your application. That process adds hundreds of milliseconds (or actual seconds, if you're unlucky enough to be running Java) to your first request.

Here's everything I've learned about understanding and minimizing that cost.

TL;DR: Understand what causes AWS Lambda cold starts and learn proven techniques to minimize latency including provisioned concurrency, SnapStart, and code optimization.

What Happens During a Cold Start

A Lambda cold start has four phases:

  1. Download code — Lambda pulls your deployment package from S3 or ECR (~50-200ms)
  2. Start the runtime — Initialize Node.js, Python, or the JVM (~50-500ms depending on runtime)
  3. Initialize your code — Execute module-level code, import dependencies, establish connections (~100ms-10s)
  4. Execute the handler — Run your actual function logic

Phases 1-3 only happen on cold starts. Warm invocations skip straight to phase 4. The frustrating part is that phase 3 is usually where most of the time goes, and it's the one you have the most control over.

Measuring Cold Starts

Use CloudWatch Logs Insights to identify cold starts:

filter @type = "REPORT"
| stats count() as invocations,
        pct(@duration, 50) as p50,
        pct(@duration, 99) as p99,
        pct(@initDuration, 50) as cold_p50,
        pct(@initDuration, 99) as cold_p99
| filter ispresent(@initDuration)

The @initDuration field only appears for cold starts. If you're not monitoring this, you're flying blind.

Optimization 1: Reduce Package Size

This is the biggest win for the least effort. Smaller packages download faster, and the difference is dramatic.

# Check your current package size
du -sh lambda-package.zip

For Node.js: Use ESBuild or esbuild-based bundlers

I cannot stress this enough — stop shipping node_modules. Bundle your code into a single file instead:

// build.mjs
import { build } from 'esbuild';

await build({
  entryPoints: ['src/handler.ts'],
  bundle: true,
  minify: true,
  platform: 'node',
  target: 'node20',
  outfile: 'dist/handler.js',
  external: ['@aws-sdk/*'], // AWS SDK v3 is included in the Lambda runtime
});

This typically reduces a 50MB node_modules deployment to a 500KB bundle. I've seen this single change cut cold starts by 60-70%.

Critical: Mark @aws-sdk/* as external. The AWS SDK v3 is pre-installed in the Lambda runtime, so bundling it just wastes space and adds to download time.

For Python: Use Lambda Layers

Move shared dependencies into a Lambda Layer:

# Create a layer with your dependencies
pip install -r requirements.txt -t python/lib/python3.12/site-packages/
zip -r layer.zip python/

aws lambda publish-layer-version \
  --layer-name my-dependencies \
  --zip-file fileb://layer.zip \
  --compatible-runtimes python3.12

Layers are cached separately from your function code, so updating your function doesn't re-download dependencies. This alone makes deployments faster and cold starts shorter.

Optimization 2: Minimize Initialization Code

This one bit me on a real project. I had a Lambda that connected to the database at module scope — even for health check requests that didn't need the database at all. Move expensive operations into lazy initialization:

// SLOW: Database connection established on every cold start, even for health checks
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function handler(event: APIGatewayEvent) {
  // pool is already connected, but initialization took 200ms
}
// FASTER: Lazy connection — only connects when first needed
import { Pool } from 'pg';

let pool: Pool | null = null;

function getPool() {
  if (!pool) {
    pool = new Pool({ connectionString: process.env.DATABASE_URL });
  }
  return pool;
}

export async function handler(event: APIGatewayEvent) {
  if (event.path === '/health') {
    return { statusCode: 200, body: 'OK' };
  }

  const db = getPool(); // Only connects when actually needed
  const result = await db.query('SELECT * FROM users');
  return { statusCode: 200, body: JSON.stringify(result.rows) };
}

Reduce Imports

Each import in Node.js triggers file resolution and module evaluation. This adds up fast:

// SLOW: imports everything
import AWS from 'aws-sdk'; // v2: imports the entire SDK (40MB)

// FASTER: import only what you need
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; // v3: ~200KB

If you're still on AWS SDK v2, migrating to v3 is worth the effort just for the cold start improvement.

Optimization 3: Provisioned Concurrency

The nuclear option: keep Lambda environments warm permanently.

aws lambda put-provisioned-concurrency-config \
  --function-name my-function \
  --qualifier prod \
  --provisioned-concurrent-executions 10

This maintains 10 warm environments at all times. No cold starts for the first 10 concurrent requests.

Cost: Provisioned concurrency costs roughly $0.015 per GB-hour. For a 512MB function with 10 provisioned instances, that's about $55/month — regardless of whether they handle a single request. You're paying for warm standby, so make sure the use case justifies it.

When It's Worth It

  • User-facing APIs where p99 latency matters
  • Financial or trading applications where milliseconds count
  • Functions with expensive initialization (database connections, ML model loading)

When It's Not Worth It

  • Background workers processing queues
  • Low-traffic functions (< 100 invocations/day)
  • Functions where 200-500ms cold start latency is perfectly acceptable

Optimization 4: SnapStart (Java and .NET)

For Java Lambdas, SnapStart is a game-changer. It takes a snapshot of the initialized execution environment and restores it on cold start, reducing startup from 5-10 seconds to ~200ms:

# SAM template
MyFunction:
  Type: AWS::Serverless::Function
  Properties:
    Runtime: java21
    SnapStart:
      ApplyOn: PublishedVersions

SnapStart is automatic and free. If you run Java Lambdas and haven't enabled this, stop reading and go do it now.

Optimization 5: Choose the Right Runtime

Cold start latency varies significantly by runtime, and this is worth considering when you're picking your stack:

Fastest cold starts:

  • Python: ~100-200ms
  • Node.js: ~100-200ms
  • Custom runtime (Rust, Go): ~50-100ms

Slower cold starts:

  • Java (without SnapStart): 3-10 seconds
  • .NET: 500ms-2s

For latency-sensitive APIs, Node.js or Python gives you the best cold start performance out of the box. If you need maximum performance, a compiled language like Rust or Go with a custom runtime is hard to beat. Java works great with SnapStart enabled.

Optimization 6: Arm64 Architecture

Switch from x86_64 to arm64 (Graviton2). It's 20% cheaper and often faster:

MyFunction:
  Type: AWS::Serverless::Function
  Properties:
    Architectures:
      - arm64

Most Node.js and Python code works on arm64 without any changes. Native extensions may need recompilation, but that's increasingly rare.

Optimization 7: Connection Reuse

Reuse HTTP connections across invocations. The AWS SDK does this by default in v3, but if you use other HTTP clients:

import { Agent } from 'node:https';

// Create agent once, reuse across warm invocations
const agent = new Agent({ keepAlive: true });

export async function handler(event: APIGatewayEvent) {
  const response = await fetch('https://api.example.com/data', {
    agent,
  });
  // ...
}

For database connections, use RDS Proxy to pool connections:

const pool = new Pool({
  host: process.env.RDS_PROXY_ENDPOINT,
  ssl: { rejectUnauthorized: false },
  max: 1, // Lambda functions should use 1 connection per instance
});

That max: 1 is important — each Lambda instance should only hold one connection. Without RDS Proxy, you'll exhaust your database connection limit fast as Lambda scales up.

Real-World Impact

Here's the cold start reduction I measured after applying these optimizations to a real Node.js Lambda serving an API:

Before optimization:

  • Package size: 45MB (full node_modules)
  • Cold start: 1,200ms
  • Warm invocation: 50ms

After optimization:

  • Package size: 800KB (esbuild bundled, AWS SDK external)
  • Lazy database initialization
  • arm64 architecture
  • Cold start: 180ms
  • Warm invocation: 45ms

That's an 85% reduction in cold start latency without provisioned concurrency. The whole optimization took about an afternoon.

Pro Tips

  1. Use @aws-sdk/* from the Lambda runtime — don't bundle it. It's pre-installed and optimized.
  2. Set NODE_OPTIONS=--enable-source-maps for better error traces without sacrificing minification.
  3. Monitor cold start percentage in CloudWatch. If it's above 1% of invocations, investigate.
  4. Use warm-up plugins (e.g., serverless-plugin-warmup) as a cheaper alternative to provisioned concurrency for low-traffic functions.
  5. Profile initialization with INIT_START and INIT_END markers in your cold start path.

Key Takeaways

  • Cold starts happen when Lambda provisions a new execution environment
  • Reduce package size with bundling — this gives the biggest improvement
  • Use lazy initialization for expensive resources like database connections
  • Use provisioned concurrency only for latency-critical, high-traffic functions
  • Enable SnapStart for Java Lambdas — it's free and dramatic
  • arm64 (Graviton2) is 20% cheaper and often faster than x86_64

Sources

  1. AWS Lambda Documentation
  2. AWS Documentation
  3. AWS Serverless

Looking for more? Check out Adaptels.

Related Articles

How to Deploy a Node.js App to AWS EC2 (Step-by-Step Guide)

Deploy Node.js apps to AWS EC2 with this production-ready guide. Learn instance setup, PM2, Nginx, SSL, and automated deployments.

How to Build a SaaS App with Next.js and AWS — Complete Guide 2026

A full-stack guide to building a production SaaS application with Next.js, AWS Cognito, DynamoDB, and Amplify. From auth to billing to deployment.

Deploy Next.js to AWS Amplify: Step-by-Step Guide (2026)

A practical guide to deploying Next.js applications on AWS Amplify Gen 2 with custom domains, environment variables, and CI/CD.