Skip to content
Ayhan Sipahi Ayhan Sipahi

AWS Lambda Cold Start Optimization: Production Lessons Learned

Production-tested strategies for cutting AWS Lambda cold starts: runtime selection, provisioned concurrency, and practical optimization techniques.

Cold start latency lands on the user’s clock. For a Lambda function behind an interactive API, the cheapest wins come from three places: the runtime you pick, the size of the deployment package, and what the initialization code does before the handler runs. Work through those three first.

Provisioned concurrency removes the init cost from the requests it covers, and for a checkout endpoint with a latency SLA it is the right answer. Traffic above the provisioned count spills onto on-demand environments and can still cold start, so the scaling policy matters as much as the reservation. It also changes the bill enough to deserve a cost calculation before it ships.

Where Cold Starts Cost You

The pattern is familiar on payment paths. Traffic climbs, Lambda opens new execution environments to absorb it, and every new environment pays the init cost before the handler sees the request. The median stays flat while the tail walks away, and no retry policy hides a slow checkout call from the person waiting on it.

Async work absorbs the same delay without anyone noticing. An SQS consumer that spends an extra second on some invocations still drains the queue. That difference decides how much effort a given function deserves.

Understanding Cold Start Fundamentals

What Happens During a Cold Start

When Lambda creates a new execution environment, the Init phase runs before your handler is called:

  1. Download the deployment package and any attached layers.
  2. Start the runtime (Node.js, Python, and so on).
  3. Run your initialization code: module-level imports, client construction, database connections.
  4. Hand the event to the handler.

Steps 1 through 3 are the cold start. Steps 1 and 3 are the two you control.

Init duration varies by runtime and package size. Commonly reported ranges cluster like this:

  • Node.js 22: 200-800ms typical
  • Python 3.12: 300-1200ms typical
  • Java 21: 1-4 seconds (yes, really)
  • Go: 100-400ms (the speed champion)

Runtime Selection Strategy

Based on runtime performance characteristics, here’s what works well:

For new projects:

  • Node.js 22: Best balance of performance and ecosystem
  • Go: Choose this if startup time is critical
  • Python: Only if your team expertise demands it

Avoid for latency-sensitive workloads:

  • Java: Unless you’re willing to invest in SnapStart optimization
  • .NET: Cold starts can be unpredictable
// BAD: dependency loading deferred into the handler
export const handler = async (event) => {
  const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb');
  const { format } = await import('date-fns');
  // The first invocation in each environment pays for this, and that
  // time lands on a user request instead of the Init phase.
};

// GOOD: module scope, so it runs during Init
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { format } from 'date-fns';

const client = new DynamoDBClient({});

export const handler = async (event) => {
  // Request work only
};

Provisioned Concurrency: When and How

The Business Case for Provisioned Concurrency

Use Provisioned Concurrency when:

  • User-facing APIs with SLA requirements
  • Functions triggered by human interaction
  • Peak traffic patterns are predictable
  • Cost of poor UX > provisioned concurrency cost

Skip Provisioned Concurrency for:

  • Async processing (SQS, EventBridge)
  • Batch jobs and data processing
  • Internal APIs with relaxed SLA
  • Functions with unpredictable traffic

Provisioned Concurrency in CloudFormation

There is no standalone provisioned concurrency resource. It is a property of a version or an alias, which means you need a published version and something stable to point traffic at:

# CloudFormation template
Resources:
  PaymentProcessorFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: nodejs22.x
      Handler: index.handler
      MemorySize: 1024  # Sweet spot for most workloads
      Timeout: 30
      Role: !GetAtt PaymentProcessorRole.Arn
      Code:
        S3Bucket: !Ref ArtifactBucket
        S3Key: !Ref ArtifactKey  # Carries the build hash, so a new build is a new object

  # Every property here requires replacement, and replacement is what publishes
  # a new version, so CodeSha256 has to move with the artifact
  PaymentProcessorVersion:
    Type: AWS::Lambda::Version
    Properties:
      FunctionName: !Ref PaymentProcessorFunction
      CodeSha256: !Ref ArtifactSha256

  # Provisioned concurrency is a property of the alias
  PaymentProcessorAlias:
    Type: AWS::Lambda::Alias
    Properties:
      Name: provisioned
      FunctionName: !Ref PaymentProcessorFunction
      FunctionVersion: !GetAtt PaymentProcessorVersion.Version
      ProvisionedConcurrencyConfig:
        ProvisionedConcurrentExecutions: 50  # Start conservative

  # Auto-scaling for traffic spikes
  ProvisionedConcurrencyTarget:
    Type: AWS::ApplicationAutoScaling::ScalableTarget
    # The alias name below is a literal, so the ordering has to be declared
    DependsOn: PaymentProcessorAlias
    Properties:
      MaxCapacity: 200
      MinCapacity: 20
      ResourceId: !Sub 'function:${PaymentProcessorFunction}:provisioned'
      ScalableDimension: lambda:function:ProvisionedConcurrency
      ServiceNamespace: lambda

  ProvisionedConcurrencyPolicy:
    Type: AWS::ApplicationAutoScaling::ScalingPolicy
    Properties:
      PolicyName: pc-utilization
      PolicyType: TargetTrackingScaling
      ScalingTargetId: !Ref ProvisionedConcurrencyTarget
      TargetTrackingScalingPolicyConfiguration:
        TargetValue: 0.7
        PredefinedMetricSpecification:
          PredefinedMetricType: LambdaProvisionedConcurrencyUtilization

Three details bite people here. A deploy that only changes code does not publish a new version by itself: AWS::Lambda::Version is replaced when one of its properties changes, so unless CodeSha256 carries the new artifact hash, the alias keeps serving the old code with the provisioned capacity attached to it. Callers have to invoke the alias, because provisioned concurrency on provisioned does nothing for requests that hit $LATEST. And the scalable target on its own only registers the resource; without the scaling policy, the minimum and maximum are just numbers sitting in the template.

What Provisioned Concurrency Costs

Provisioned concurrency keeps initialized environments ready, so requests routed to them skip the Init phase. The trade is a bill that no longer scales down to zero.

List prices below are us-east-1 on x86, and worth re-deriving for your region:

  • On-demand duration: $0.0000166667 per GB-second, plus $0.20 per million requests
  • Provisioned concurrency reservation: $0.0000041667 per GB-second, which is $0.015 per GB-hour
  • Duration for invocations served by provisioned capacity: $0.0000097222 per GB-second

Take a 1 GB function invoked one million times a month, averaging 500 ms per invocation. That is 500,000 GB-seconds of compute.

  • On demand: 500,000 × $0.0000166667 = $8.33, plus $0.20 in requests. About $8.53.
  • With 10 units of provisioned concurrency held for 12 hours a day: 10 GB × 12 h × 30 days = 3,600 GB-hours × $0.015 = $54.00 for the reservation. The same 500,000 GB-seconds bill at the lower provisioned rate for $4.86, plus $0.20 in requests. About $59.06.

Reserving capacity for half the day costs roughly seven times the on-demand bill for a function this shape. That ratio is the point. The cheaper the function, the worse provisioned concurrency looks as a percentage, so the decision has to rest on what the latency is worth rather than on the multiplier.

Keep-Warm Strategies: The Good and Bad

EventBridge Keep-Warm (Legacy Approach)

// Keep-warm implementation
exports.handler = async (event) => {
  // Handle keep-warm pings
  if (event.source === 'aws.events' && event['detail-type'] === 'Keep Warm') {
    return { statusCode: 200, body: 'Staying warm!' };
  }
  
  // Regular handler logic
  return processRequest(event);
};

Why keep-warm patterns became obsolete:

  • Added complexity to every function
  • EventBridge costs add up
  • Unreliable during traffic spikes
  • Provisioned Concurrency is more predictable

Where Lambda Extensions Fit

Extensions do not hold an environment open. Lambda freezes the whole environment, extension processes included, once an invocation finishes. What an extension buys you is a seat at the lifecycle: it registers for INVOKE and SHUTDOWN and can report what each environment did, which is how you find out how often you are paying for Init.

// Runs as a separate process started from /opt/extensions
const EXTENSION_NAME = 'init-telemetry';
const API = `http://${process.env.AWS_LAMBDA_RUNTIME_API}/2020-01-01/extension`;

const registration = await fetch(`${API}/register`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Lambda-Extension-Name': EXTENSION_NAME
  },
  body: JSON.stringify({ events: ['INVOKE', 'SHUTDOWN'] })
});

// Every later call carries this identifier
const extensionId = registration.headers.get('Lambda-Extension-Identifier');

// Long-poll for work; the request blocks until Lambda has an event
const next = await fetch(`${API}/event/next`, {
  headers: { 'Lambda-Extension-Identifier': extensionId }
});

Package Size Optimization

Bundle Analysis

Deployment package size feeds straight into step 1 of the Init phase, so start by looking at what you are actually shipping:

npx webpack-bundle-analyzer dist/stats.json

Three offenders show up repeatedly:

  • aws-sdk (v2): end of support, and it pulls in every service client. Move to the @aws-sdk/client-* packages, which ship one service each.
  • moment: no tree shaking, and it drags in locale data by default. date-fns or the built-in Intl API cover most formatting needs.
  • lodash: importing the root package pulls the whole library. Import the individual functions instead.

Practical Bundling Strategy

// BAD: pulls in AWS SDK v2, which is past end of support
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();

// GOOD: Selective imports with AWS SDK v3
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';

const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));

Webpack Configuration for Lambda

// webpack.config.js optimized for Lambda
module.exports = {
  target: 'node',
  mode: 'production',
  entry: './src/index.ts',
  // The managed Node.js runtime ships AWS SDK v3, but bundling the clients
  // you use pins the version and keeps module resolution short.
  optimization: {
    minimize: true,
    usedExports: true, // Tree shaking
    sideEffects: false
  },
  resolve: {
    extensions: ['.ts', '.js']
  }
};

Lambda Layers: Strategic Usage

What Belongs in a Layer

Good candidates for layers:

  • Shared business logic across functions
  • Heavy dependencies (analytics SDKs, etc.)
  • Custom runtimes or tools

Keep in function package:

  • Function-specific logic
  • Frequently changing code
  • Small utility libraries

Layer Performance Impact

Layers are fetched and unpacked into /opt during Init, which puts them inside the cold start rather than beside it. The quotas that bound this: five layers per function, and 250 MB unzipped for the function package and all its layers combined.

So the cost model is simple. Bytes are bytes, whether they arrive in the package or in a layer. A layer earns its place when several functions share the same heavy dependency and you want one artifact to update instead of six.

Rule of thumb: one shared layer, added only when more than one function needs what is in it.

Connection Pooling and Initialization

Database Connection Strategy

// Connection pooling outside handler
import { Pool } from 'pg';

const pool = new Pool({
  host: process.env.DB_HOST,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 1, // Important: Lambda = single concurrent execution
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 10000,
});

export const handler = async (event: any) => {
  const client = await pool.connect();
  try {
    const result = await client.query('SELECT NOW()');
    return result.rows;
  } finally {
    // Release in finally, or a thrown query leaks the connection
    client.release();
  }
};

AWS Service Client Reuse

// Service client reuse pattern
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { S3Client } from '@aws-sdk/client-s3';

// Initialize outside handler
const dynamoClient = new DynamoDBClient({});
const s3Client = new S3Client({});

export const handler = async (event: any) => {
  // Reuse clients across invocations
  // AWS SDK v3 handles connection pooling internally
};

Monitoring Cold Starts in Production

Essential CloudWatch Metrics

import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
import type { Context } from 'aws-lambda';

const cloudwatch = new CloudWatchClient({});

// Module scope: stays false only for the first invocation
// in each execution environment
let isWarm = false;

export const handler = async (event: unknown, context: Context) => {
  if (!isWarm) {
    isWarm = true;
    await cloudwatch.send(new PutMetricDataCommand({
      Namespace: 'Lambda/Performance',
      MetricData: [{
        MetricName: 'ColdStart',
        Value: 1,
        Unit: 'Count',
        Dimensions: [{ Name: 'FunctionName', Value: context.functionName }]
      }]
    }));
  }

  // Request work
};

Note what this does to the invocation you care about most: it adds a synchronous API call to the slowest request the function will serve. Writing an embedded metric format log line instead keeps the measurement off the critical path, since CloudWatch parses the metric out of the log asynchronously.

X-Ray Tracing Setup

Turn on active tracing and Lambda reports the Init phase for you, as an Initialization subsegment on the service segment. That subsegment is the number to watch; no SDK call produces it. The SDK’s job is downstream visibility, so you can see whether a slow init is your imports or a client reaching out over the network:

import AWSXRay from 'aws-xray-sdk-core';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

// Instrument at module scope so setup work is traced too
const dynamo = AWSXRay.captureAWSv3Client(new DynamoDBClient({}));

export const handler = async (event: unknown) => {
  // Calls through `dynamo` appear as subsegments automatically
};

Common Cold Start Pitfalls

Pitfall 1: Over-Engineering Warm-Up Logic

Teams often spend weeks building complex keep-warm systems that ultimately cost more than Provisioned Concurrency and work less reliably.

Pitfall 2: Ignoring Memory Impact

Lambda scales CPU with the memory setting, and the Init phase is CPU work. A 128 MB function with a 50 MB package cold starts slower than a 1 GB function carrying exactly the same package.

Pitfall 3: Wrong Runtime Choice

Choosing Java for a user-facing API without understanding the cold start implications. Unless you’re prepared to use SnapStart and tune extensively, stick with Node.js or Python.

Pitfall 4: Dependency Bloat

Adding npm packages without considering bundle impact. Every dependency adds to cold start time, especially transitive dependencies.

When to Stop Optimizing

For most functions the default holds: pick Node.js or Go, keep the package lean, build clients at module scope, and stop there. Cold starts touch a small share of invocations, and on an async consumer that share costs nothing anyone will notice. Override the default when a person is waiting on the response and the tail latency breaks something you have promised. That is the point where provisioned concurrency, or SnapStart on a JVM function, earns the bill it brings.

What’s Next: Performance Deep Dive

The next part of this series covers memory allocation and the tuning work that shapes how a warm function runs.

Topics covered:

  • Memory vs CPU allocation strategies
  • Benchmarking techniques
  • Performance profiling tools
  • Cost analysis frameworks

References

AWS Lambda Production Guide: 5 Years of Real-World Experience

A comprehensive guide to AWS Lambda based on 5+ years of production experience, covering cold start optimization, performance tuning, monitoring, and cost optimization with real war stories and practical solutions.

Progress 1/4 posts completed

Related posts