AWS Elastic Load Balancing
Choose the right AWS load balancer. ALB vs NLB vs GWLB, target groups, health checks, TLS termination and debugging 502 and 503 errors.
Elastic Load Balancing distributes traffic across targets in multiple Availability Zones and takes unhealthy ones out of rotation. AWS offers three types, and picking the wrong one is a decision you feel months later when you need a feature the other one has.
Choosing a type
| Application Load Balancer | Network Load Balancer | Gateway Load Balancer | |
|---|---|---|---|
| OSI layer | 7 (HTTP/HTTPS) | 4 (TCP/UDP/TLS) | 3 (IP) |
| Routing on | Path, host, header, method, query | Port only | N/A (transparent) |
| Latency | Low | Very low | Adds a hop |
| Static IP | No (DNS name only) | Yes, one Elastic IP per AZ | N/A |
| Preserves source IP | Via X-Forwarded-For header | Yes, natively | Yes |
| WebSockets, gRPC, HTTP/2 | Yes | Passes through | N/A |
| Typical use | Web apps, APIs, microservices | High throughput, non-HTTP, static IPs | Third-party firewalls and IDS |
The short version: ALB for HTTP, NLB for everything else. Two things push an HTTP workload to an NLB anyway. A client that requires a fixed IP to allowlist, and a need for extreme throughput at minimal latency.
The Classic Load Balancer still exists. Don't start anything new on it.
Structure
Three objects make up a load balancer, and understanding the split makes the rest straightforward:
- The load balancer owns the DNS name, the subnets, and the security group.
- Listeners watch a port and protocol, and hold the rules that decide where traffic goes.
- Target groups hold the actual targets, instances, IPs, or Lambda functions, and own the health check.
Health checks belong to the target group, not the load balancer. Two target groups behind one ALB can check different paths with different thresholds.
# Target group
aws elbv2 create-target-group \
--name payments-tg \
--protocol HTTP --port 8080 \
--vpc-id vpc-0abc123 \
--target-type ip \
--health-check-path /healthz \
--health-check-interval-seconds 15 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 2
# Load balancer across at least two AZs
aws elbv2 create-load-balancer \
--name payments-alb \
--type application \
--subnets subnet-0aaa subnet-0bbb \
--security-groups sg-0abc123 \
--scheme internet-facing
An ALB requires subnets in at least two Availability Zones, each with a minimum of 8 free IP addresses. It scales by adding nodes, and it can't scale into a subnet with no room.
Listener rules
This is where ALBs earn their price. One load balancer fronting many services:
aws elbv2 create-rule \
--listener-arn $LISTENER_ARN \
--priority 10 \
--conditions Field=path-pattern,Values='/api/payments/*' \
--actions Type=forward,TargetGroupArn=$PAYMENTS_TG
Rules evaluate by priority, lowest number first, and the default action catches anything unmatched. Beyond forwarding, rules can redirect (HTTP to HTTPS in one rule, no application code), return a fixed response, or authenticate against Cognito or an OIDC provider before traffic ever reaches your service.
Weighted target groups make canary deploys straightforward. Send 5% of traffic to the new version, watch the metrics, then shift.
Health checks
Most load balancer incidents are really health check misconfigurations.
Interval: 15s how often each target is checked
Timeout: 5s must be less than the interval
Healthy threshold: 2 consecutive passes to return to service
Unhealthy threshold: 2 consecutive failures to remove
Success codes: 200
Time to detection is interval × unhealthy threshold, so the settings above take 30 seconds to pull a dead target. That's a real availability decision, not a default to leave alone.
Point the check at an endpoint that reflects whether the instance can actually serve traffic, but not one that checks every downstream dependency. A health check that fails when the database is slow will remove every target simultaneously and turn a degraded system into a total outage. Check that the process is alive and can serve; let dependency failures surface as errors.
/healthz should not require authentication, should not be logged at high volume, and should be cheap.
Draining connections
When a target deregisters, a deploy, a scale-in, the load balancer stops sending it new connections but lets existing ones finish. That window is the deregistration delay, 300 seconds by default:
aws elbv2 modify-target-group-attributes \
--target-group-arn $TG_ARN \
--attributes Key=deregistration_delay.timeout_seconds,Value=30
300 seconds makes deploys crawl. Set it slightly above your longest normal request; 30 seconds suits most APIs. Set it too low and you cut off in-flight requests, which shows up to users as failed requests during every deploy.
TLS
Terminate TLS at the load balancer with an ACM certificate. ACM certificates are free and renew automatically:
aws elbv2 create-listener \
--load-balancer-arn $ALB_ARN \
--protocol HTTPS --port 443 \
--certificates CertificateArn=$CERT_ARN \
--ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
--default-actions Type=forward,TargetGroupArn=$TG_ARN
Choose the security policy deliberately. The default on older load balancers still permits TLS 1.0. A modern policy that requires TLS 1.2 or better is what you want unless you have a specific legacy client.
Redirect HTTP to HTTPS at the listener rather than in application code:
aws elbv2 create-listener \
--load-balancer-arn $ALB_ARN --protocol HTTP --port 80 \
--default-actions '[{"Type":"redirect","RedirectConfig":{"Protocol":"HTTPS","Port":"443","StatusCode":"HTTP_301"}}]'
Debugging 5xx errors
The status code tells you which side failed, which narrows it immediately:
| Code | Meaning | Usual cause |
|---|---|---|
| 502 Bad Gateway | Target responded, but malformed | App crashed mid-response, or the target speaks HTTPS while the target group is set to HTTP |
| 503 Service Unavailable | No healthy targets | All targets failing health checks, or the group is empty |
| 504 Gateway Timeout | Target too slow | Response exceeded the idle timeout (60s default) |
| 460 | Client disconnected first | Client gave up: often a downstream timeout shorter than yours |
| 500 from the ELB itself | Load balancer error | Rare; check HTTPCode_ELB_5XX_Count in CloudWatch and the AWS health dashboard |
Start with the target group, always:
aws elbv2 describe-target-health --target-group-arn $TG_ARN
{
"TargetHealthDescriptions": [
{
"Target": { "Id": "10.0.1.42", "Port": 8080 },
"TargetHealth": {
"State": "unhealthy",
"Reason": "Target.Timeout",
"Description": "Request timed out"
}
}
]
}
The Reason field is precise and worth reading carefully:
Target.Timeout. The app didn't respond in time, or a security group is blocking the health checkTarget.FailedHealthChecks. It responded with the wrong status codeTarget.ResponseCodeMismatch. A 302 where a 200 was expected, often an auth redirect on/healthzElb.RegistrationInProgress, just waitTarget.NotInUse. The target is in an AZ the load balancer isn't enabled in
The security group case deserves special mention because it produces a healthy-looking application that never receives traffic: the target's security group must allow inbound from the load balancer's security group on the target port. Referencing the load balancer's security group by ID rather than a CIDR is the correct form.
Sticky sessions
Available, but treat them as a workaround:
aws elbv2 modify-target-group-attributes \
--target-group-arn $TG_ARN \
--attributes Key=stickiness.enabled,Value=true \
Key=stickiness.type,Value=lb_cookie \
Key=stickiness.lb_cookie.duration_seconds,Value=3600
Stickiness produces uneven load, breaks graceful deploys, and means one lost target loses real user state. Externalising session state to Redis or DynamoDB is nearly always the better fix.
Access logs
Off by default. Turn them on before you need them:
aws elbv2 modify-load-balancer-attributes \
--load-balancer-arn $ALB_ARN \
--attributes Key=access_logs.s3.enabled,Value=true \
Key=access_logs.s3.bucket,Value=my-alb-logs
Logs land in S3 and query cleanly from Athena, which is how you answer "which client caused that spike" after the fact. Pair them with a lifecycle policy so the bucket doesn't grow forever.
Quick reference
| Task | Command |
|---|---|
| Target health | aws elbv2 describe-target-health --target-group-arn <arn> |
| List load balancers | aws elbv2 describe-load-balancers |
| Listener rules | aws elbv2 describe-rules --listener-arn <arn> |
| Register a target | aws elbv2 register-targets --target-group-arn <arn> --targets Id=<id> |
| Drain a target | aws elbv2 deregister-targets --target-group-arn <arn> --targets Id=<id> |
| Group attributes | aws elbv2 describe-target-group-attributes --target-group-arn <arn> |
Key takeaways
- ALB for HTTP, NLB for everything else: or when you need a static IP to hand a client for allowlisting.
- Health checks live on the target group and detection takes
interval × unhealthy threshold. Treat those numbers as an availability decision. - Never health-check downstream dependencies. It converts a degraded database into a full outage by removing every target at once.
- 502 means the target replied badly, 503 means nothing was healthy, 504 means too slow. The code tells you where to look.
- The target's security group must allow the load balancer's security group on the target port. Reference it by ID, not CIDR.
- Lower the 300-second deregistration delay to just above your longest request, or every deploy will crawl.
Check your understanding
A sudden 503
An ALB starts returning 503 for all requests. The application instances are running and healthy when curled directly from a bastion host. What is the most likely cause?
Designing a health check
Should the /healthz endpoint verify that the database is reachable?
Frequently Asked Questions
What is the difference between an ALB and an NLB?
An ALB works at layer 7 and understands HTTP, so it can route on path, host, header, method or query string, terminate TLS, redirect, and authenticate against an identity provider. An NLB works at layer 4 and only sees TCP, UDP and TLS, so it routes on port alone, but it handles far higher throughput at lower latency, supports a static Elastic IP per Availability Zone, and preserves the client source IP natively rather than through an X-Forwarded-For header. Use an ALB for HTTP applications and an NLB for non-HTTP protocols, extreme throughput, or when a client needs a fixed IP address to allowlist.
Why is my ALB returning 502 Bad Gateway?
The load balancer reached a target but could not parse the reply. The two most common causes are the application crashing or closing the connection partway through a response, and a protocol mismatch. The target group set to HTTP while the application only speaks HTTPS on that port, or the reverse. Also check that your application's keep-alive timeout is longer than the ALB's idle timeout; if the target closes an idle connection the ALB still considers open, the next request on it produces a 502.
Why do my targets show as unhealthy when the application works fine?
Run aws elbv2 describe-target-health and read the Reason field before anything else. Target.Timeout usually means the target's security group does not allow inbound traffic from the load balancer's security group on the health check port. Reference the load balancer's security group by ID rather than a CIDR range. Target.ResponseCodeMismatch means the endpoint returned something outside the success codes, often a 302 redirect because the health check path sits behind authentication.
How do I make deploys faster without dropping requests?
Lower the deregistration delay from its 300-second default to just above your longest normal request: 30 seconds works for most APIs. That is how long the load balancer waits for in-flight requests to finish after a target starts draining. Setting it too low cuts off active requests and users see errors on every deploy; leaving it at 300 means every rolling deploy waits five minutes per batch for no benefit.
Should I use sticky sessions?
Only as a temporary measure. Stickiness distributes load unevenly, complicates rolling deploys because draining a target strands its sessions, and means losing one target loses real user state. The durable fix is to keep session state outside the application, in ElastiCache for Redis or DynamoDB, so any target can serve any request. If you must enable stickiness, prefer the load balancer cookie and keep the duration short.
Do I need one load balancer per service?
No, and you generally should not. A single ALB can front many services using listener rules that route on host or path, which saves both the hourly charge and a great deal of DNS and certificate management. Separate load balancers make sense when services need genuinely different configurations. Different security policies, WAF rules, or internal versus internet-facing schemes, or when you want hard blast-radius isolation between them.