# Fix 502 Bad Gateway on AWS API Gateway and Load Balancers

> Why AWS returns 502 Bad Gateway from API Gateway or an Application Load Balancer, how to find which hop wrote it, and the fix for each cause.

By Boris Tane, Founder of Polylane · Published October 18, 2026 · 11 min read
Canonical: https://polylane.com/learn/troubleshooting/aws-502-bad-gateway/

```text
502 Bad Gateway
```

On AWS a 502 means the proxy in front of your code, usually API Gateway or an Application Load Balancer, got an answer from the backend it couldn't use. With API Gateway and a Lambda proxy integration, the usual cause is a function that throws or returns something other than the statusCode, headers and string body shape. With an ALB, it is usually a target that closes keep-alive connections before the load balancer's 60 second idle timeout, or a target that is deregistered while it still has requests in flight.

You call your API or open your site and get `502 Bad Gateway`. Your code may not have logged an error at all. That's because AWS writes the 502 itself: something sits between the client and your code, and it's telling you the reply it got from your code was broken, missing or cut off.

On AWS that something is almost always one of two services. One is Amazon API Gateway, usually in front of a Lambda function. The other is an Application Load Balancer (ALB) in front of EC2 instances, ECS tasks or Lambda. Each has its own list of causes and its own logs that confirm them. Start by working out which one sent the response.

## What 502 Bad Gateway means on AWS

A gateway status code describes the hop between the proxy and your backend. A 502 means the proxy reached your backend, or tried to, and got nothing usable back. A 504 is a different problem: the proxy gave up waiting.

The first question I ask about any 502 is which hop wrote it. The answer tells you which logs to open.

```mermaid alt="Decision path for an AWS 502: API Gateway with Lambda versus an Application Load Balancer, and what to check for each"
graph TB
    A[Client gets 502 Bad Gateway] --> B{Which service fronts the app?}
    B -->|API Gateway| C{Log says malformed or failed?}
    C -->|malformed| D[Return statusCode and string body]
    C -->|failed| E[Check Lambda errors and permissions]
    B -->|ALB| F{HTTPCode_ELB_502_Count has data?}
    F -->|yes| G[Read access logs for the cause]
    F -->|no| H[Your app returned the 502 itself]
    style D fill:#d1fae5,stroke:#6ee7b7,color:#065f46
    style G fill:#d1fae5,stroke:#6ee7b7,color:#065f46
```

If the URL looks like `https://abc123.execute-api.us-east-1.amazonaws.com/...` or is a custom domain mapped to an API, read the API Gateway section. If DNS for your domain points at a load balancer (`*.elb.amazonaws.com`), read the ALB section.

## Fixing API Gateway 502s from a Lambda proxy integration

With a Lambda proxy integration, API Gateway passes the raw request to your function and expects a fixed response shape back. The [API Gateway docs](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format) say that if the function's output is in any other format, API Gateway returns a `502 Bad Gateway` error response. The shape is:

```json
{
  "isBase64Encoded": false,
  "statusCode": 200,
  "headers": { "Content-Type": "application/json" },
  "multiValueHeaders": { "Set-Cookie": ["a=1", "b=2"] },
  "body": "a string"
}
```

`headers` and `multiValueHeaders` are optional. `body` must be a string.

### Confirm it in the execution logs

Turn on CloudWatch execution logging for the stage ([setup steps](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-logging.html)), call the API again and open the log group. The [AWS Knowledge Center article on this error](https://repost.aws/knowledge-center/malformed-502-api-gateway) shows the two patterns to look for:

```text
Execution failed due to configuration error:
Method completed with status: 502
```

```text
Lambda execution failed with status 200 due to customer function error: [Errno 13] Permission denied: '/var/task/lambda_function.py'. Lambda request id: ...
Method completed with status: 502
```

The first means the function returned the wrong shape. The second means the function itself failed. You can also run the method from the CLI, which returns the same log inline:

```bash
aws apigateway test-invoke-method \
  --rest-api-id abc123 \
  --resource-id xyz789 \
  --http-method GET \
  --query log --output text
```

### Cause 1: the response has the wrong shape

**How to tell:** the log says the execution failed with a configuration error, and the Lambda's own logs show it finished without an exception.

The usual mistakes are a `body` that is an object, a missing `statusCode`, or a handler that returns nothing at all.

1. Return an object with a numeric `statusCode` and a string `body`. In Node.js, stringify JSON yourself:
   ```javascript
   export const handler = async (event) => {
     const data = { ok: true };
     return {
       statusCode: 200,
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify(data),
       isBase64Encoded: false,
     };
   };
   ```
2. In Python, the same rule applies:
   ```python
   import json

   def handler(event, context):
       return {
           "statusCode": 200,
           "headers": {"Content-Type": "application/json"},
           "body": json.dumps({"ok": True}),
       }
   ```
3. Make sure every code path returns. An `async` handler that fires a promise and doesn't `await` it can finish with `undefined`, which is not a valid proxy response.
4. If you return binary data, base64-encode the body, set `isBase64Encoded` to `true` and add the media type under the API's binary media types, as the docs describe.

### Cause 2: the function throws to signal an HTTP error

**How to tell:** you see 502s only on the paths where your code handles an error, such as not found or bad input. The execution log shows a customer function error.

This is a common version of the question. Code that calls `context.fail(...)`, `callback(new Error(...))` or `throw` to send a 404 makes the invocation fail, and API Gateway turns a failed invocation into a 502. The docs spell out the fix: for a client-side error, return the status code as a normal result.

1. Replace the throw with a returned response:
   ```javascript
   if (!item) {
     return {
       statusCode: 404,
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ error: "not found" }),
     };
   }
   ```
2. Wrap the handler body in `try`/`catch` and return a 500 response from the `catch`, logging the error first so it still reaches CloudWatch.
3. Keep `throw` for errors you actually want to surface as a gateway failure.

### Cause 3: the function times out or can't run

**How to tell:** the Lambda logs for the request end without your handler's output, show a timeout, or show an import or permission error. The 502 often appears on cold starts or on slow downstream calls.

The [Lambda timeout docs](https://docs.aws.amazon.com/lambda/latest/dg/configuration-timeout.html) set the default timeout at 3 seconds, adjustable up to 900 seconds. A function that loads a framework on a cold start can run past 3 seconds on its first call.

1. Raise the timeout to something your slowest real request fits inside:
   ```bash
   aws lambda update-function-configuration \
     --function-name my-function \
     --timeout 30
   ```
2. Check that the handler name in the function configuration matches the file and export, for example `index.handler` for `export const handler` in `index.mjs`.
3. Check that the function's resource policy lets API Gateway invoke it. The Knowledge Center article lists this as a cause. Add the permission if it's missing:
   ```bash
   aws lambda add-permission \
     --function-name my-function \
     --statement-id apigateway-invoke \
     --action lambda:InvokeFunction \
     --principal apigateway.amazonaws.com \
     --source-arn "arn:aws:execute-api:us-east-1:123456789012:abc123/*/*/*"
   ```
4. For a `Permission denied` on files in `/var/task`, rebuild the deployment package with readable file permissions and redeploy.

## Fixing ALB 502s: find out whether the load balancer or the target sent it

An ALB either forwards a target's response or generates its own. The [ALB troubleshooting guide](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html#http-502-issues) lists every reason the load balancer itself sends a 502. The [Knowledge Center article on ALB 502s](https://repost.aws/knowledge-center/elb-alb-troubleshoot-502-errors) gives the first split:

- Data in `HTTPCode_ELB_502_Count` means the load balancer generated the 502.
- Data in `HTTPCode_Target_5XX_Count` means your target returned it, and the ALB passed it on. Debug your app.

```bash
aws cloudwatch get-metric-statistics \
  --namespace AWS/ApplicationELB \
  --metric-name HTTPCode_ELB_502_Count \
  --dimensions Name=LoadBalancer,Value=app/my-alb/50dc6c495c0c9188 \
  --start-time 2026-10-18T00:00:00Z --end-time 2026-10-18T12:00:00Z \
  --period 300 --statistics Sum
```

Then turn on access logs. In each line, an `elb_status_code` of `502` with a `target_status_code` of `-` means the ALB generated it. Both fields at `502` means the target did. For Lambda targets, the `error_reason` field names the failure.

```bash
# ALB access logs are gzipped files in S3. Fields 9 and 10 are elb_status_code and target_status_code.
aws s3 cp s3://my-alb-logs/AWSLogs/123456789012/elasticloadbalancing/us-east-1/2026/10/18/ ./logs --recursive
zcat logs/*.gz | awk '$9 == 502 { print $2, $5, $6, $7, $8, $10, $13 }' | head
```

### Cause 4: the target closes keep-alive connections too early

**How to tell:** a small, steady trickle of 502s on healthy targets, often on the first request after a quiet spell. The access log shows the request reached a target and the target status is `-`.

The ALB keeps connections to your targets open and reuses them. The [load balancer attributes docs](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html#connection-idle-timeout) set the idle timeout at 60 seconds by default. If your server closes an idle connection first, the ALB can send a request down a socket that is already closing, and the client gets a 502. AWS recommends setting your application's idle timeout higher than the load balancer's.

1. Read the ALB's idle timeout:
   ```bash
   aws elbv2 describe-load-balancer-attributes \
     --load-balancer-arn "$ALB_ARN" \
     --query "Attributes[?Key=='idle_timeout.timeout_seconds']"
   ```
2. Set your server's keep-alive timeout above it. In Node.js that is `server.keepAliveTimeout`. A common practice is to also set `headersTimeout` a little higher than `keepAliveTimeout`:
   ```javascript
   const server = app.listen(8080);
   server.keepAliveTimeout = 65000; // above the ALB's 60 s idle timeout
   server.headersTimeout = 66000;   // common practice: a little above keepAliveTimeout
   ```
3. In NGINX on the target:
   ```nginx
   http {
     keepalive_timeout 75s;
   }
   ```
4. In Gunicorn, pass `--keep-alive 75`. For other servers, find the keep-alive or idle timeout setting and raise it the same way.

### Cause 5: 502s during deploys and scale-in

**How to tell:** the 502s cluster around ECS deployments, Auto Scaling scale-in or instance replacement. CloudTrail shows `DeregisterTargets` calls at the same time.

When a target is deregistered it goes into `draining`. The [target group attributes docs](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-target-group-attributes.html#deregistration-delay) say the default deregistration delay is 300 seconds. If the delay runs out while a request is still in flight, or the task stops before the delay ends, the client gets a 5xx. Teams often shorten this delay to make deploys faster, then see 502s on rollouts.

1. Check the current delay:
   ```bash
   aws elbv2 describe-target-group-attributes \
     --target-group-arn "$TG_ARN" \
     --query "Attributes[?Key=='deregistration_delay.timeout_seconds']"
   ```
2. Set it longer than your slowest request:
   ```bash
   aws elbv2 modify-target-group-attributes \
     --target-group-arn "$TG_ARN" \
     --attributes "Key=deregistration_delay.timeout_seconds,Value=60"
   ```
3. Make the app finish in-flight requests when it gets its stop signal, and give it enough time to stop before the container is killed.

### Cause 6: the target refuses the connection

**How to tell:** `TargetConnectionErrorCount` has data points, and in the access log `request_processing_time`, `target_processing_time` and `response_processing_time` are all `-1`. That means the ALB never delivered the request.

The ALB got a TCP RST, or an ICMP host unreachable, when it tried to connect. Nothing is listening on the target port, or the network blocks the path.

1. Confirm the app listens on the target group's port on all interfaces (`0.0.0.0`), and not only on `127.0.0.1`.
2. Check that the target's security group allows the target port from the load balancer's security group.
3. Check that network ACLs on both sets of subnets allow the target port and the ephemeral ports 1024-65535.
4. Check target health. The resource map in the console shows the failure reason for each unhealthy target:
   ```bash
   aws elbv2 describe-target-health --target-group-arn "$TG_ARN"
   ```

### Cause 7: a Lambda target fails or replies too big

**How to tell:** the target is a Lambda function and the access log's `error_reason` is set. `LambdaResponseTooLarge` means the body went over 1 MB. `LambdaUnhandled` means the function errored or didn't respond before its timeout. Both also show up in the `LambdaUserError` metric. Throttling shows up in the function's `Throttles` metric.

1. For large responses, write the payload to S3 and return a download URL.
2. For `LambdaUnhandled`, read the function's logs, fix the error, and raise the timeout if the work needs it.
3. For throttling, retry with backoff on the client and request a higher concurrency quota.

### Less common ALB causes

- **TLS handshake errors** on HTTPS target groups: the target's ciphers or certificate don't match what the ALB offers. Capture packets with `sudo tcpdump -i any -w capture.pcap` on the target and check the handshake.
- **Malformed responses**: invalid HTTP headers, or a response header block over 32 K.
- **HTTP/2 or gRPC targets** that end a stream with `RST_STREAM`: the ALB returns a 502 by default. The target group attribute `routing.http2.rst_stream_error_forwarding.enabled` passes the target's error code through.

## Keeping 502 Bad Gateway from coming back

- **Alarm on the load balancer's own 502s.** An alarm on `HTTPCode_ELB_502_Count` catches the keep-alive and deploy cases, which never show up in your app's error logs:
  ```bash
  aws cloudwatch put-metric-alarm \
    --alarm-name alb-elb-502 \
    --namespace AWS/ApplicationELB \
    --metric-name HTTPCode_ELB_502_Count \
    --dimensions Name=LoadBalancer,Value=app/my-alb/50dc6c495c0c9188 \
    --statistic Sum --period 300 --evaluation-periods 1 \
    --threshold 10 --comparison-operator GreaterThanOrEqualToThreshold \
    --alarm-actions arn:aws:sns:us-east-1:123456789012:oncall
  ```
- **Keep ALB access logs on.** The `error_reason` and processing-time fields are the fastest way to tell causes apart, and they only exist if logging was on when the error happened.
- **Keep API Gateway execution logging on** for at least one stage, so a malformed response shows up as a log line.
- **Test the response contract.** A unit test that calls each Lambda handler on its error paths and checks for a numeric `statusCode` and a string `body` catches cause 1 and cause 2 before deploy.
- **Write timeouts down in one place.** The ALB idle timeout, your server's keep-alive timeout, the deregistration delay and the Lambda timeout all interact. Put them in the same infrastructure code so a change to one prompts a check of the others.
- **Line 502 spikes up with deploys.** Most ALB 502 bursts start at a rollout, so a deploy marker on the same chart answers the question quickly.

## How Polylane catches AWS 502s

Polylane detects production issues and fixes them by opening pull requests. It connects to AWS, so when a load balancer starts returning 502s, it works out why, and the fix arrives ready for your team's normal review.

Running on AWS? See [how Polylane monitors AWS in production](https://polylane.com/for/aws/).

## Common questions

**What is the difference between a 502 and a 504 on AWS?**

A 502 means the proxy got a reply it couldn't use, or the connection broke: a malformed Lambda response, a TCP reset or a target that closed the connection. A 504 means the proxy gave up waiting. On an ALB, a 504 comes from failing to connect within 10 seconds or a target that doesn't respond before the idle timeout.

**Why does API Gateway return 502 when my Lambda throws an error?**

With a Lambda proxy integration, a thrown error or context.fail makes the invocation fail, and API Gateway turns that into a 502. To send a 404 or 400, return an object with that statusCode and a string body. Keep throw for real failures.

**Does the body in a Lambda proxy response have to be a string?**

Yes. API Gateway expects body as a string, so call JSON.stringify in Node.js or json.dumps in Python before returning. For binary data, base64-encode it, set isBase64Encoded to true and register the media type on the API.

**How do I tell whether my ALB or my app returned the 502?**

Check CloudWatch: data in HTTPCode_ELB_502_Count means the load balancer generated it, and data in HTTPCode_Target_5XX_Count means your target did. In the access logs, an elb_status_code of 502 with a target_status_code of - points at the load balancer.

**Why do I get 502s from my ALB during ECS deployments?**

Old tasks are deregistered and enter draining. If the deregistration delay runs out, or the task stops while a request is still in flight, the client gets a 5xx. The default delay is 300 seconds, so check whether yours was lowered, and make the app finish in-flight requests on shutdown.

**What keep-alive timeout should my server use behind an ALB?**

Set it higher than the ALB's connection idle timeout, which is 60 seconds by default. In Node.js that means setting server.keepAliveTimeout to a value above the ALB idle timeout, and a common practice is to set headersTimeout a little above that. If you raise the ALB idle timeout later, raise the server's keep-alive timeout with it.

**What is the default Lambda timeout, and can it cause a 502?**

The default is 3 seconds, adjustable up to 900 seconds. A function that runs past its timeout fails the invocation, so API Gateway or an ALB returns a 502. Raise it with aws lambda update-function-configuration --timeout.

## Sources

- [Troubleshoot your Application Load Balancers (AWS docs)](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html)
- [How do I troubleshoot Application Load Balancer HTTP 502 errors? (AWS Knowledge Center)](https://repost.aws/knowledge-center/elb-alb-troubleshoot-502-errors)
- [How do I resolve HTTP 502 errors from API Gateway REST APIs with Lambda proxy integration? (AWS Knowledge Center)](https://repost.aws/knowledge-center/malformed-502-api-gateway)
- [Lambda proxy integrations in API Gateway (AWS docs)](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html)
- [Edit target group attributes for your Application Load Balancer (AWS docs)](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-target-group-attributes.html)
- [Edit attributes for your Application Load Balancer (AWS docs)](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html)
- [Configure Lambda function timeout (AWS docs)](https://docs.aws.amazon.com/lambda/latest/dg/configuration-timeout.html)
- [Polylane documentation](https://docs.polylane.com/llms-full.txt)
- [Polylane full content](https://polylane.com/llms-full.txt)

## About the author

Boris Tane is the founder of Polylane. He previously founded Baselime, observability for the future of the cloud, which Cloudflare acquired. At Cloudflare he built and led the Workers observability team.

## Related

- [Fix 403 Forbidden (Access Denied) from Amazon S3](https://polylane.com/learn/troubleshooting/aws-403-forbidden/): Why Amazon S3 returns 403 Forbidden: Block Public Access, missing s3:ListBucket, object ownership, clock skew, expired presigned URLs and CloudFront. Fix each.
- [Fix AuthorizerConfigurationException in API Gateway](https://polylane.com/learn/troubleshooting/aws-authorizerconfigurationexception/): AuthorizerConfigurationException means API Gateway couldn't call your Lambda authorizer or couldn't read its response. Find the cause and fix it.
- [Fix Railway “Application failed to respond” (502)](https://polylane.com/learn/troubleshooting/railway-application-failed-to-respond/): Why Railway returns “Application failed to respond” with a 502, how to tell a wrong host, port or target port from a crash or overload, and how to fix each.
- [Vercel 504 Gateway Timeout on Serverless Functions: Causes and Fixes](https://polylane.com/learn/troubleshooting/vercel-504-gateway-timeout-on-serverless-functions/): Fix Vercel 504 FUNCTION_INVOCATION_TIMEOUT errors: current duration limits, how to find the slow call, the fix for each cause and how to raise maxDuration.
- [How to correlate incidents with deploys using evidence](https://polylane.com/learn/deployment-safety/how-to-correlate-incidents-with-deploys/): Learn how to correlate incidents with deploys using release markers, metrics, logs and verified GitHub, Vercel, Cloudflare and Kubernetes change events.

Get started with one command: `curl -fsSL https://polylane.com/setup | bash` installs the CLI, connects your coding agents, and creates the account.
