A load balancer spreads traffic across several copies of your application so that one of them failing is not an outage. AWS has three, and for a web backend the choice is really between two.
ALB or NLB
| Application LB | Network LB | |
|---|---|---|
| Layer | 7 — understands HTTP | 4 — TCP/UDP only |
| Routes on | Path, host, header, method, query | Port |
| TLS termination | Yes | Yes, or pass through |
| Static IP | No — a DNS name that changes | Yes, one per AZ |
| Latency | Milliseconds | Lower |
| Protocols | HTTP, HTTPS, gRPC, WebSocket | Anything over TCP/UDP |
Use an ALB for HTTP. Use an NLB when you need a fixed IP address someone will whitelist, a non-HTTP protocol, or the lowest possible latency. The Classic Load Balancer is the previous generation and there is no reason to choose it for something new.
Target groups are the part that matters
The load balancer itself is nearly configuration-free. The object that decides whether your deploy is a deploy or an outage is the target group: a set of destinations plus the health check that determines which of them get traffic.
aws elbv2 create-target-group \
--name stayhub-api \
--protocol HTTP --port 8000 \
--vpc-id vpc-0abc123 \
--target-type ip \
--health-check-path /healthz \
--health-check-interval-seconds 10 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 2--target-type is instance for EC2, ip for Fargate tasks
and anything with its own network interface, and lambda to put a function behind an
ALB.
The health check arithmetic nobody does
Those numbers are not decoration. Time to notice a dead target is:
interval × unhealthy-threshold
The defaults on many setups are a 30-second interval and a threshold of 3, which is 90 seconds of traffic sent into a dead target before it is removed. Every request in that window fails. At 10 seconds and 2 failures it is 20 seconds — a tenth of the errors, for a health check that costs nothing.
The same arithmetic runs the other way. A new target receives no traffic until it passes
healthy-threshold checks, so a slow-starting application plus a long interval means a
deploy sits there apparently stuck.
Point the check at a real endpoint. A check on / that returns a static page is
green while the database is unreachable. A check on /healthz that verifies
dependencies tells the truth — but keep it cheap, because it runs on every target every interval,
and do not have it fail on a dependency the instance cannot fix by being replaced.
Listeners and rules
A listener watches a port; rules decide where matching requests go. This is where an ALB earns its keep — one load balancer in front of several services:
aws elbv2 create-rule \
--listener-arn arn:aws:elasticloadbalancing:us-west-2:111122223333:listener/app/stayhub/abc/def \
--priority 10 \
--conditions '[{"Field":"path-pattern","PathPatternConfig":{"Values":["/api/*"]}}]' \
--actions '[{"Type":"forward","TargetGroupArn":"arn:aws:elasticloadbalancing:us-west-2:111122223333:targetgroup/stayhub-api/abc"}]'Rules are evaluated by priority, lowest first, and the first match wins — so a broad rule with a low number shadows everything after it. The default action catches whatever matches nothing.
Always redirect port 80 to 443 at the listener rather than in your application. It is a built-in action, it costs nothing, and it means the unencrypted request never reaches your code.
Draining, and the deploy that drops requests
When a target is deregistered it enters draining: no new connections, existing ones
allowed to finish, for up to deregistration_delay.timeout_seconds — 300 by default.
Too long and every deploy crawls. Too short and in-flight requests are cut off. Match it to your slowest normal request and no more; 30 seconds suits most APIs.
Draining alone does not prevent dropped requests, though. The load balancer stops sending new
work, but your application must also stop accepting and finish what it has — which means handling
SIGTERM rather than exiting immediately. A container that dies instantly on the signal
drops every request in flight no matter how the target group is configured.
Sticky sessions
Stickiness pins a client to one target with a cookie. It is available, and it is usually a sign of a problem rather than a solution: it means your application holds state in memory, so scaling out does not distribute load evenly and losing a target loses user sessions.
Put session state in ElastiCache or a database and leave stickiness off. The exception is a legacy application you cannot change — then it is the pragmatic answer.
What it costs
A load balancer bills two ways: an hourly charge for existing, and a usage charge in LCUs — Load Balancer Capacity Units — which meter new connections, active connections, processed bytes and rule evaluations, billing on whichever is highest.
The practical consequence is that one ALB in front of several services is markedly cheaper than one per service, because you pay the hourly charge once. This is the main argument for path-based routing even when you do not need it architecturally.
Idle load balancers are a common source of forgotten spend — they bill whether or not any target is registered.
When something is unreachable
aws elbv2 describe-target-health \
--target-group-arn arn:aws:elasticloadbalancing:us-west-2:111122223333:targetgroup/stayhub-api/abc \
--query "TargetHealthDescriptions[].[Target.Id,TargetHealth.State,TargetHealth.Reason]" \
--output tableTarget.FailedHealthChecks means it is reachable and answering wrongly — check the
path and the expected status code. Target.Timeout almost always means a security
group: the target's group must allow the load balancer's group on the target port. And a 503 from
the load balancer itself with no targets listed means the group is empty, which is a registration
problem rather than a health one.