AWS – Load Balancers: ALB, NLB and Target Groups

June 25, 20244 min readUpdated 8/24/2026

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 LBNetwork LB
Layer7 — understands HTTP4 — TCP/UDP only
Routes onPath, host, header, method, queryPort
TLS terminationYesYes, or pass through
Static IPNo — a DNS name that changesYes, one per AZ
LatencyMillisecondsLower
ProtocolsHTTP, HTTPS, gRPC, WebSocketAnything 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 table

Target.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.