AWS ALB + Lambda - 502 Bad Gateway Due to Payload Limit or Timeout
Fixing 502 Bad Gateway on ALB with Lambda target: exceeding 1MB payload limit, ALB 29s timeout, malformed JSON response, Lambda cold start + ALB idle timeout.
This runbook covers the specific causes of 502 errors in the ALB → Lambda configuration. For a more general runbook covering ALB 502 with EC2/ECS targets, see ALB 502 - Target Health Check Failures. We discuss serverless costs and architecture in the article Serverless without bill shock - Lambda pricing. Need help with serverless architecture? Book a consultation.
Symptom
ALB returns HTTP 502 for requests routed to a Lambda target group. CloudWatch ALB metrics show an increase in HTTPCode_ELB_502_Count:
# Check 502 metrics on ALB
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name HTTPCode_ELB_502_Count \
--dimensions Name=LoadBalancer,Value=app/my-alb/1234567890abcdef \
--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 300 \
--statistics Sum
# Check ALB access logs (S3) - look for 502 with cause information
# Columns "actions_executed" and "target_status_code" indicate the source
aws s3 cp s3://my-alb-logs/AWSLogs/123456789012/elasticloadbalancing/eu-west-1/$(date +%Y/%m/%d)/ /tmp/alb-logs/ --recursive
zcat /tmp/alb-logs/*.gz | grep " 502 " | head -20
# ALB access log format - key columns:
# target_status_code = "-" means Lambda did not respond at all
# target_processing_time = "-" means timeout or connection error
# actions_executed = "forward" + target_status_code "-" = Lambda crash/timeout
# Check Lambda errors in CloudWatch Logs
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 "ERROR Task timed out" \
--max-items 10
# Check Lambda CloudWatch metrics
aws cloudwatch get-metric-statistics \
--namespace AWS/Lambda \
--metric-name Errors \
--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 300 \
--statistics Sum
# Check Duration vs Timeout
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 300 \
--statistics Maximum
Differential diagnosis - what causes the 502:
| Access log symptom | target_status_code | target_processing_time | Cause |
|---|---|---|---|
| 502 | - |
- |
Lambda was not invoked at all (payload too large) |
| 502 | - |
29.xxx |
ALB timeout (Lambda takes >29s) |
| 502 | - |
<1s |
Lambda crash (OOM, unhandled exception) |
| 502 | 200 but 502 to client |
Normal | Malformed Lambda response (invalid JSON) |
Root Cause
ALB → Lambda has specific limitations that do not apply to direct invoke:
1. Request body payload limit: 1 MB (ALB → Lambda):
ALB restricts the request body to Lambda to 1 MB. If a client sends a request larger than 1 MB (e.g. file upload, large JSON), ALB immediately returns 502 without invoking Lambda. Lambda itself supports up to 6 MB payload, but ALB truncates at 1 MB.
2. Response body payload limit: 1 MB (Lambda → ALB):
The Lambda response to ALB also cannot exceed 1 MB. If Lambda returns a larger JSON/body, ALB rejects the response and returns 502 to the client.
3. ALB connection timeout: 29 seconds (hardcoded, not configurable):
ALB waits for a Lambda response for a maximum of 29 seconds. Even if Lambda has a 15-minute timeout, ALB will terminate the connection after 29s and return 502. This is a hard limitation - it cannot be changed.
4. Malformed Lambda response:
ALB expects Lambda to return a response in a strictly defined JSON format. Missing required fields (statusCode, body) or incorrect types (statusCode as string instead of int) causes 502.
5. Lambda cold start + ALB health check:
During cold start, Lambda may need 5-15s for initialisation. If ALB performs a health check during this time and does not receive a response within 5s (default health check timeout), it marks the target as unhealthy.
Solution
A) Payload too large - multipart upload via S3 presigned URL:
# lambda_handler.py - workaround for 1MB upload limit
import json
import boto3
import uuid
s3 = boto3.client("s3")
BUCKET = "my-uploads-bucket"
def handler(event, context):
"""Endpoint for generating presigned URL for large uploads."""
# Client first requests an upload URL
if event["path"] == "/upload/presign" and event["httpMethod"] == "POST":
file_key = f"uploads/{uuid.uuid4()}"
presigned_url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": BUCKET, "Key": file_key},
ExpiresIn=300, # 5 minutes to upload
)
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"upload_url": presigned_url,
"file_key": file_key,
}),
}
# After upload, client notifies Lambda of the file to process
if event["path"] == "/upload/process" and event["httpMethod"] == "POST":
body = json.loads(event["body"])
file_key = body["file_key"]
# Process file directly from S3 (no 1MB limit)
obj = s3.get_object(Bucket=BUCKET, Key=file_key)
content = obj["Body"].read()
# ... processing ...
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"status": "processed", "size": len(content)}),
}
B) Response too large - streaming via S3 or pagination:
# When Lambda generates a response >1MB, save to S3 and return a link
import json
import boto3
import gzip
s3 = boto3.client("s3")
def handler(event, context):
"""Returns large responses via S3 presigned URL."""
# Generate large response
large_result = generate_report() # e.g. 5MB JSON
result_json = json.dumps(large_result)
if len(result_json) > 900_000: # Leave margin (1MB limit)
# Save to S3 and return presigned URL for download
key = f"results/{context.aws_request_id}.json.gz"
s3.put_object(
Bucket="my-results-bucket",
Key=key,
Body=gzip.compress(result_json.encode()),
ContentType="application/json",
ContentEncoding="gzip",
)
download_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "my-results-bucket", "Key": key},
ExpiresIn=3600,
)
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"status": "complete",
"result_url": download_url,
"size_bytes": len(result_json),
}),
}
else:
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": result_json,
}
C) 29s timeout - asynchronous pattern with polling:
# Pattern: ALB → Lambda (start job) → SQS/Step Functions → Lambda (process)
# Client polls for status or uses WebSocket
import json
import boto3
import uuid
sfn = boto3.client("stepfunctions")
dynamodb = boto3.resource("dynamodb")
jobs_table = dynamodb.Table("async-jobs")
STATE_MACHINE_ARN = "arn:aws:states:eu-west-1:123456789012:stateMachine:long-processing"
def handler(event, context):
"""Asynchronous endpoint for operations taking >29s."""
path = event["path"]
method = event["httpMethod"]
# POST /jobs - start a long-running operation
if path == "/jobs" and method == "POST":
job_id = str(uuid.uuid4())
body = json.loads(event.get("body", "{}"))
# Start Step Function (can run for hours)
sfn.start_execution(
stateMachineArn=STATE_MACHINE_ARN,
name=job_id,
input=json.dumps({"job_id": job_id, "params": body}),
)
# Save status in DynamoDB
jobs_table.put_item(Item={
"job_id": job_id,
"status": "PROCESSING",
"created_at": context.get_remaining_time_in_millis(),
})
return {
"statusCode": 202, # Accepted
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"job_id": job_id,
"status": "PROCESSING",
"poll_url": f"/jobs/{job_id}",
}),
}
# GET /jobs/{id} - check status (polling)
if path.startswith("/jobs/") and method == "GET":
job_id = path.split("/")[-1]
item = jobs_table.get_item(Key={"job_id": job_id}).get("Item")
if not item:
return {"statusCode": 404, "body": json.dumps({"error": "Job not found"})}
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"job_id": job_id,
"status": item["status"],
"result_url": item.get("result_url"),
}),
}
D) Fix Lambda response format:
# CORRECT Lambda response format for ALB:
def handler(event, context):
return {
"statusCode": 200, # REQUIRED: int, not string
"statusDescription": "200 OK", # optional
"headers": { # REQUIRED: dict
"Content-Type": "application/json",
},
"isBase64Encoded": False, # REQUIRED: bool
"body": json.dumps({"key": "value"}) # REQUIRED: string (not dict!)
}
# INCORRECT formats causing 502:
# ❌ return {"statusCode": "200", ...} # statusCode as string
# ❌ return {"status_code": 200, ...} # wrong key name
# ❌ return {"statusCode": 200, "body": {...}} # body as dict instead of string
# ❌ return "Hello World" # string instead of dict
# ❌ return {"statusCode": 200} # missing body and headers
E) Cold start - provisioned concurrency + health check tuning:
# Enable provisioned concurrency (eliminates cold start)
aws lambda put-provisioned-concurrency-config \
--function-name my-api-function \
--qualifier prod \
--provisioned-concurrent-executions 5
# Adjust ALB Target Group health check
aws elbv2 modify-target-group \
--target-group-arn arn:aws:elasticloadbalancing:eu-west-1:123456789012:targetgroup/my-lambda-tg/1234567890abcdef \
--health-check-enabled \
--health-check-path /health \
--health-check-interval-seconds 35 \
--health-check-timeout-seconds 30 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 3
Validation
# 1. Test with payload < 1MB (should work)
curl -s -o /dev/null -w "%{http_code}" \
-X POST https://my-alb.example.com/api/data \
-H "Content-Type: application/json" \
-d '{"small": "payload"}'
# Expected: 200
# 2. Test with payload > 1MB (should return 413 or redirect to presigned URL)
dd if=/dev/urandom bs=1100000 count=1 | base64 > /tmp/big-payload.txt
curl -s -o /dev/null -w "%{http_code}" \
-X POST https://my-alb.example.com/api/upload \
-H "Content-Type: application/octet-stream" \
-d @/tmp/big-payload.txt
# Expected: 413 (Payload Too Large) with instructions to use presigned URL
# OR: 200 if you implemented a redirect
# 3. Test timeout - endpoint completing in <29s
curl -s -o /dev/null -w "%{http_code} %{time_total}s" \
https://my-alb.example.com/api/quick-operation
# Expected: 200, time < 29s
# 4. Test response format (check no 502)
curl -v https://my-alb.example.com/api/data 2>&1 | grep "< HTTP"
# Expected: "< HTTP/1.1 200 OK"
# 5. Check CloudWatch - no new 502s
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name HTTPCode_ELB_502_Count \
--dimensions Name=LoadBalancer,Value=app/my-alb/1234567890abcdef \
--start-time $(date -u -d '30 minutes ago' +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 300 \
--statistics Sum
# Expected: Sum = 0
ALB + Lambda returning 502?
Schedule a free 30-minute call. We will diagnose the cause of 502 errors and design a serverless architecture that handles your requirements without ALB limitations.