Load Balancers
A load balancer fronts a pool of backends and distributes incoming requests across them. It also does health checks (remove dead backends), TLS termination, and often more (rate limiting, WAF, sticky sessions).
In a Python web stack you typically have at least one load balancer in front of your app servers — even if you only run one app server today, you’ll need it tomorrow.
L4 vs L7 — the central distinction
| L4 (transport) | L7 (application) | |
|---|---|---|
| Routes by | IP + port | URL, host, headers, cookies, body |
| Examples | AWS NLB, HAProxy (TCP mode), kube-proxy, IPVS | AWS ALB, nginx, Envoy, Traefik, HAProxy (HTTP mode) |
| TLS | usually passthrough | usually terminated here |
| Latency | lower (no parsing) | higher |
| Throughput | higher | lower |
| Use case | non-HTTP (DBs, gRPC at scale, raw TCP), passthrough TLS | HTTP APIs (the default for web traffic) |
If you’re load-balancing HTTP APIs, default to L7 (ALB / nginx). Only drop to L4 when you’re not doing HTTP or you need maximum throughput / TLS passthrough.
Algorithms
| Algorithm | What it does | When to use |
|---|---|---|
| Round robin | next request to next backend | default; works fine if backends are identical |
| Weighted round robin | hand out by weight | mixing instance types or canary deploys |
| Least connections | send to the backend with fewest open connections | requests have variable duration |
| Least response time | combines connections + latency | when backends have unequal capacity |
| IP hash / consistent hash | hash a key (client IP, header) → pick backend | sticky sessions, cache locality |
| Random | pick at random | simple, surprisingly fine at scale |
| Power of two choices | pick 2 random, choose the better one | almost as good as least-connections, much cheaper |
Real LBs (nginx, Envoy, ALB) default to round robin or least connections. The fancier algorithms matter less than people think — the actual lever is good health checks plus a sane backend pool.
Health checks
The LB pings each backend periodically and removes failing ones from the pool. Tunables:
| Knob | Typical |
|---|---|
| Interval | 5–30s |
| Timeout | 2–10s |
| Healthy threshold | 2–3 consecutive passes |
| Unhealthy threshold | 2–3 consecutive fails |
| Path (L7) | GET /healthz returning 200 |
Best practice for the health endpoint:
- Cheap — should not query the database. A
/healthzthat hits Postgres on every check makes the LB part of your DB outage. - Liveness vs readiness — liveness = “process is alive”; readiness = “ready to serve”. Two endpoints, two behaviors. (Kubernetes formalized this; the same idea applies to LBs.)
- Return JSON, not HTML — easier to parse in dashboards.
TLS termination
The load balancer decrypts incoming HTTPS, sees plain HTTP, makes a routing decision, and either forwards as plain HTTP (most common) or re-encrypts to the backend (TLS re-encryption).
| Termination strategy | Trade-off |
|---|---|
| Terminate at LB, plain HTTP to backend | simplest; backend doesn’t deal with certs; LAN traffic is unencrypted |
| Terminate at LB, re-encrypt to backend | encrypted everywhere; double crypto cost; backend manages its own cert |
| Pass through (TCP/L4) | LB never sees plaintext (e.g. mTLS terminated at the app); LB can’t do L7 routing |
For most Python web apps: terminate at LB, plain HTTP backend, both inside a private VPC. mTLS / passthrough is for compliance-heavy environments.
Sticky sessions (session affinity)
Without affinity, a user’s requests can hit any backend. If sessions are in cookies/JWT/Redis, fine. If sessions are in process memory (flask.session with the in-process dev backend), affinity is mandatory.
Two common strategies:
| Type | How |
|---|---|
| LB-issued cookie | LB sets AWSALB cookie; subsequent requests routed to the same backend |
| Application cookie | LB looks at an existing app cookie (session_id) and hashes it |
Avoid sticky sessions when you can. They prevent rolling deploys from gracefully draining traffic and make scale-up uneven.
Connection draining / graceful shutdown
When you remove a backend (deploy, scale-in), the LB should:
- Stop sending it new connections (deregister).
- Wait for in-flight requests to finish (drain timeout, e.g. 30s).
- Then it’s safe to terminate.
Configurable in ALB (“deregistration delay”), nginx, Kubernetes (terminationGracePeriodSeconds). Without this, deploys cause request errors.
Multi-AZ and HA
A single LB endpoint should fan out to backends in multiple AZs. AWS ALB/NLB are inherently multi-AZ — you pick the subnets and they place LB nodes in each AZ. DNS returns multiple IPs (one per AZ).
Health checks per-AZ: if one AZ goes dark, that AZ’s LB nodes are removed from DNS rotation (slow — DNS TTL).
AWS LB choices
| ALB | NLB | CLB (legacy) | GWLB | |
|---|---|---|---|---|
| Layer | L7 | L4 | L4/L7 | L3 |
| Protocols | HTTP/HTTPS, gRPC, WebSocket | TCP, UDP, TLS | HTTP/HTTPS, TCP | for traffic inspection appliances |
| Static IP | no (use NLB or AWS Global Accelerator) | yes | no | n/a |
| TLS termination | yes | yes | yes | no |
| Routing rules | host, path, header, query | port only | basic | flow-based |
| Auth integration | yes (Cognito, OIDC) | no | no | no |
ALB is the default for HTTP. NLB is for non-HTTP, ultra-low latency, or when you need a static IP per AZ.
nginx as a load balancer
upstream app {
least_conn;
server app1.internal:8000 max_fails=3 fail_timeout=30s;
server app2.internal:8000;
server app3.internal:8000 backup;
}
server {
listen 443 ssl http2;
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
X-Forwarded-For matters: behind an LB, request.META["REMOTE_ADDR"] is the LB’s IP — use X-Forwarded-For to get the real client. (Django needs SECURE_PROXY_SSL_HEADER and USE_X_FORWARDED_HOST configured.)
Layer 4 with HAProxy / NLB
For TCP services without HTTP awareness (Postgres, Redis, gRPC streams):
[Client] ──TCP:6432──▶ [HAProxy/NLB] ──TCP:5432──▶ [pgbouncer pool]
L4 LBs can’t read the request, so they distribute by connection (not request). Means a long-lived connection sits on one backend forever — not great if you want even load. Solve with shorter connection lifetimes or app-side connection pooling.
Common interview confusions
- “L7 LB is always slower than L4.” — measurably yes, but in microseconds. Networking around it dominates. Pick by features, not theoretical latency.
- “Sticky sessions == HA.” — opposite, often. Sticky breaks rolling deploys and makes scale-out uneven. Externalize session state instead.
- “LB IP is static.” — depends. ALB has a DNS name with rotating IPs (use the DNS name, not IPs). NLB has static IPs per AZ.
- “Health check should test everything.” — no. Should test that the process can serve a basic response. If you check the DB and the DB blips, the LB pulls every backend out and you have a worse outage.
Interview angle
- “L4 vs L7 load balancer — when do you reach for which?” — L7 (ALB/nginx) for HTTP — content-based routing, TLS termination, header-aware. L4 (NLB/HAProxy-TCP) for non-HTTP, very high throughput, or TLS passthrough.
- “How does an LB know a backend is alive?” — periodic health checks (HTTP path or TCP connect). Multiple consecutive failures remove it; multiple consecutive successes re-add it. Tune intervals/thresholds for failure-detection speed vs flapping.
- “What goes in a
/healthzendpoint?” — minimal: process is up and can return 200. Avoid downstream dependencies (DB, cache) — coupling the LB’s “is this alive” decision to a flaky downstream amplifies outages. - “Sticky sessions — when needed, when avoid?” — needed when session state is in process memory; avoid otherwise (breaks rolling deploys, uneven scaling). Move sessions to Redis/JWT/cookies.
- “What does
X-Forwarded-Fordo?” — header set by the LB carrying the original client IP, since the backend sees the LB’s IP asREMOTE_ADDR. Frameworks need explicit config to trust it (otherwise spoofable). - “How does TLS termination work and what are the trade-offs?” — LB decrypts incoming TLS, talks plain HTTP to the backend (simplest), or re-encrypts (mTLS/compliance), or passes TCP through (LB never sees plaintext, no L7 routing).