AWS Lambda Cold Start Causing 504 Gateway Timeout on API Gateway
Fixing 504 Timeout on API Gateway caused by Lambda cold start: diagnosing VPC ENI, optimising package size, provisioned concurrency, SnapStart for Java.
This runbook covers diagnosing and eliminating Lambda cold starts that cause API Gateway timeouts. We discuss serverless costs and Lambda optimisation in the article Serverless without bill shock - Lambda pricing. If you need help with serverless architecture - book a consultation.
Symptom
API Gateway returns HTTP 504 sporadically, correlating with first requests after periods of inactivity:
# Check 5xx metrics on API Gateway
aws cloudwatch get-metric-statistics \
--namespace AWS/ApiGateway \
--metric-name 5XXError \
--dimensions Name=ApiName,Value=my-api \
--start-time $(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 3600 \
--statistics Sum
# Check IntegrationLatency - key metric (how long it waits for Lambda)
aws cloudwatch get-metric-statistics \
--namespace AWS/ApiGateway \
--metric-name IntegrationLatency \
--dimensions Name=ApiName,Value=my-api \
--start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 60 \
--statistics Maximum
# Cold start: Maximum > 10000ms (10s+)
# Warm: Maximum < 500ms
# Lambda Duration vs Init Duration
aws cloudwatch get-metric-statistics \
--namespace AWS/Lambda \
--metric-name Duration \
--dimensions Name=FunctionName,Value=my-api-function \
--start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 60 \
--statistics Maximum
# Max > 29000 = exceeds API Gateway timeout
# Look for Init Duration in CloudWatch Logs (cold start marker)
aws logs filter-log-events \
--log-group-name /aws/lambda/my-api-function \
--start-time $(date -u -d '24 hours ago' +%s)000 \
--filter-pattern "Init Duration" \
--max-items 20 \
--query 'events[].message'
# Format: "REPORT ... Init Duration: 8523.45 ms ..."
# Init Duration > 10000ms = VPC cold start or large package
Cold start vs warm invocation characteristics:
| Metric | Cold start | Warm invocation |
|---|---|---|
| Init Duration | 3-30s (depends on runtime + VPC) | 0 (none) |
| Duration (total) | Init + execution time | Execution time only |
| Billed Duration | Init + execution | Execution |
| API Gateway IntegrationLatency | Init + execution > 29s = 504 | < 500ms (typical) |
Root Cause
Lambda cold start is the initialisation time for a new execution environment instance. It consists of:
1. Download + decompress deployment package:
- Small package (5 MB): ~200ms
- Large package (50 MB, e.g. Python with numpy/pandas): ~2-5s
- Package with Lambda Layer: additional time per layer
2. Runtime initialization (interpreter/JVM start):
- Python: ~200-500ms
- Node.js: ~100-300ms
- Java (JVM cold start): ~3-8s
- .NET: ~1-3s
3. VPC ENI attachment (if Lambda is in a VPC):
- Hyperplane ENI: ~1-2s (post-2019 improvement)
- Old accounts/configurations: up to 10-15s
- Cross-AZ: additional ~500ms
4. Handler initialization code (import modules, DB connections):
- Import heavy libraries (PyTorch, TensorFlow): 5-15s
- Establishing DB/Redis connection: 1-3s
- Downloading secrets from Secrets Manager: 0.5-1s
Total: A VPC Lambda with a large package in Java can have a 15-30s cold start → exceeding the 29s API Gateway timeout.
5. When cold starts occur:
- First request after deploy (all instances are cold)
- After ~5-15 min of inactivity (AWS recycles the instance)
- During sudden traffic spikes (new instances start in parallel)
- After scaling down to 0
Solution
A) Provisioned Concurrency - eliminate cold starts:
# Enable provisioned concurrency on a published version/alias
# 1. Publish a version
VERSION=$(aws lambda publish-version \
--function-name my-api-function \
--query 'Version' --output text)
# 2. Create/update alias 'live' pointing to the version
aws lambda update-alias \
--function-name my-api-function \
--name live \
--function-version $VERSION
# 3. Set provisioned concurrency on the alias
aws lambda put-provisioned-concurrency-config \
--function-name my-api-function \
--qualifier live \
--provisioned-concurrent-executions 5
# 4. Check status (must be "Ready")
aws lambda get-provisioned-concurrency-config \
--function-name my-api-function \
--qualifier live \
--query '{status:Status, requested:RequestedProvisionedConcurrentExecutions, available:AvailableProvisionedConcurrentExecutions}'
# 5. Point API Gateway to the alias (not $LATEST!)
# In API Gateway integration URI change to:
# arn:aws:lambda:eu-west-1:123456789012:function:my-api-function:live
Autoscaling provisioned concurrency (for variable traffic):
# Register target with Application Auto Scaling
aws application-autoscaling register-scalable-target \
--service-namespace lambda \
--resource-id function:my-api-function:live \
--scalable-dimension lambda:function:ProvisionedConcurrency \
--min-capacity 2 \
--max-capacity 50
# Target tracking - maintain utilisation ~70%
aws application-autoscaling put-scaling-policy \
--service-namespace lambda \
--resource-id function:my-api-function:live \
--scalable-dimension lambda:function:ProvisionedConcurrency \
--policy-name target-tracking-70pct \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 0.7,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "LambdaProvisionedConcurrencyUtilization"
},
"ScaleInCooldown": 300,
"ScaleOutCooldown": 60
}'
B) Optimise deployment package (reduce init time):
# Check current package size
aws lambda get-function \
--function-name my-api-function \
--query 'Configuration.{CodeSize:CodeSize, Layers:Layers[].LayerArn, MemorySize:MemorySize, Runtime:Runtime}'
# Python - use Lambda Powertools Layer instead of bundling
# Node.js - tree-shaking with esbuild
# Java - use GraalVM native-image or SnapStart
# Dockerfile - multi-stage build for minimal image (container Lambda)
FROM public.ecr.aws/lambda/python:3.12 as builder
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -t /opt/python/
# Remove unnecessary files (tests, docs, cache)
RUN find /opt/python -name "*.pyc" -delete && \
find /opt/python -name "__pycache__" -type d -exec rm -rf {} + && \
find /opt/python -name "tests" -type d -exec rm -rf {} + && \
find /opt/python -name "*.dist-info" -type d -exec rm -rf {} +
FROM public.ecr.aws/lambda/python:3.12
COPY --from=builder /opt/python /opt/python
ENV PYTHONPATH=/opt/python
COPY app/ ${LAMBDA_TASK_ROOT}/
CMD ["app.handler.handler"]
# handler.py - lazy imports (import heavy libraries in handler, not globally)
import json
import os
# ✅ Lightweight global imports
import boto3
# ❌ DO NOT import heavy libraries globally
# import pandas as pd # +3s to cold start
# import numpy as np # +2s to cold start
def handler(event, context):
# ✅ Lazy import - only when needed
if event["path"] == "/reports":
import pandas as pd # Imported only for this endpoint
# ...
return {
"statusCode": 200,
"body": json.dumps({"status": "ok"})
}
C) SnapStart (Java) - eliminate JVM cold start:
# SAM template - enabling SnapStart for Java Lambda
Resources:
MyApiFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: java21
Handler: com.example.Handler::handleRequest
MemorySize: 1024
SnapStart:
ApplyOn: PublishedVersions # Key setting!
AutoPublishAlias: live
Events:
Api:
Type: HttpApi
Properties:
Path: /{proxy+}
Method: ANY
D) Warmer (cheap solution for low traffic):
# CloudWatch EventBridge rule - "ping" Lambda every 5 minutes
Resources:
WarmingRule:
Type: AWS::Events::Rule
Properties:
ScheduleExpression: rate(5 minutes)
State: ENABLED
Targets:
- Id: keep-warm
Arn: !GetAtt MyApiFunction.Arn
Input: '{"httpMethod":"GET","path":"/warmup","isWarmer":true}'
WarmingPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref MyApiFunction
Action: lambda:InvokeFunction
Principal: events.amazonaws.com
SourceArn: !GetAtt WarmingRule.Arn
# In handler - detect warming request and respond immediately
def handler(event, context):
if event.get("isWarmer"):
return {"statusCode": 200, "body": "warm"}
# ... normal logic
E) Increase memory (more CPU = faster initialisation):
# Lambda memory = proportionally more CPU
# 128 MB = 1/10 vCPU → slow initialisation
# 1024 MB = ~0.6 vCPU → fast initialisation
# 1769 MB = 1 vCPU → optimal for compute-heavy init
# Test different memory values with Lambda Power Tuning:
aws lambda update-function-configuration \
--function-name my-api-function \
--memory-size 1024
# Cost: 1024MB Lambda costs 8x more per ms than 128MB
# BUT: init time drops 3-5x, so total cost may be LOWER
Validation
# 1. Check Init Duration after optimisation
aws logs filter-log-events \
--log-group-name /aws/lambda/my-api-function \
--start-time $(date -u -d '1 hour ago' +%s)000 \
--filter-pattern "Init Duration" \
--max-items 5 \
--query 'events[].message'
# Expected: Init Duration < 5000ms (before optimisation it was 10-30s)
# If provisioned concurrency: NO Init Duration in logs = success
# 2. Force cold start test (deploy a new version)
aws lambda update-function-configuration \
--function-name my-api-function \
--environment Variables={FORCE_COLD=$(date +%s)}
sleep 5
time curl -s https://my-api.execute-api.eu-west-1.amazonaws.com/prod/health
# Expected: response time < 5s (before fix: 15-30s or timeout)
# 3. Check API Gateway metrics - no 504s
aws cloudwatch get-metric-statistics \
--namespace AWS/ApiGateway \
--metric-name 5XXError \
--dimensions Name=ApiName,Value=my-api \
--start-time $(date -u -d '6 hours ago' +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 3600 \
--statistics Sum
# Expected: Sum = 0 in all periods
# 4. Provisioned concurrency status
aws lambda get-provisioned-concurrency-config \
--function-name my-api-function \
--qualifier live
# Expected: Status = "Ready", Available = Requested
# 5. Simulate traffic spike (k6 / artillery)
# Check whether provisioned concurrency autoscaling scales up
aws cloudwatch get-metric-statistics \
--namespace AWS/Lambda \
--metric-name ProvisionedConcurrencySpilloverInvocations \
--dimensions Name=FunctionName,Value=my-api-function \
--start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 60 \
--statistics Sum
# Expected: 0 (spillover = requests hitting cold instances)
Lambda cold starts causing timeouts?
Schedule a free 30-minute call. We will optimise your serverless architecture - from package size to provisioned concurrency - to eliminate 504 errors and reduce costs.