AWS ALB Lambda serverless 502

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.

Jerzy Kopaczewski ·
Application Load Balancer (ALB) returns 502 Bad Gateway responses to clients. The backend is a Lambda function registered as a target in an ALB Target Group. The Lambda function works correctly when invoked directly (invoke), but returns 502 through ALB. The problem may be intermittent (only with large payloads) or persistent (after deploying a new version).

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
Key ALB → Lambda limits that cannot be changed: request body 1 MB, response body 1 MB, connection timeout 29s. These restrictions do not apply to direct Lambda invoke (6 MB payload, 15 min timeout). If your application needs larger payloads or longer processing - use API Gateway (10 MB payload, 29s timeout) or move to an asynchronous pattern with S3 + Step Functions.

 

Jerzy Kopaczewski

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.