Load balancers
A load balancer is the front door that spreads requests across your server copies and stops sending traffic to a copy that is sick. This lesson shows how it picks a server, how it notices failure, and the settings that cause most real outages.
By the end you can
- Explain the difference between an L4 and an L7 load balancer
- Choose between round robin, least connections, weighted and hash balancing
- Set up active and passive health checks, and a real NestJS health endpoint
- Write an Nginx upstream with failover, timeouts and the right headers
- Spot the load balancer as a single point of failure in a diagram
The everyday picture
On a busy evening, a good floor manager stands where the checkout queues begin. She looks at the counters and sends each customer to the one that will free up first. Counter 4 has a customer with a full trolley, so she sends the next three people elsewhere. The cashier at counter 2 has gone for tea, so she sends nobody there until he is back.
She is doing three jobs: picking a counter for each customer (the balancing algorithm), checking which counters are open (health checks), and being the one place where everyone enters (the front door). If she faints, nobody knows where to go. A load balancer does the same three jobs for your servers, and has the same weakness.
What a load balancer does
A load balancer (LB) sits between clients and your server copies. Clients only know the LB's address. The LB keeps a list of backend servers, called a pool, and forwards each request or connection to one of them.
Because every request passes through it, the LB is also the natural place for other front-door work: holding the TLS certificate, adding a request id, limiting abusive clients (Rate limiting) and writing access logs. Nginx and HAProxy are the common self-hosted choices. AWS Application Load Balancer, Google Cloud Load Balancing and Cloudflare are common managed ones.
L4 or L7: how much of the request it reads
The names come from the layers of the network model (Networking you actually need). An L4 load balancer works at the transport layer. It sees the client's IP address and the port, picks a server, and pipes the whole TCP connection through. It never reads the HTTP inside, which is usually encrypted anyway. That makes it fast and protocol-neutral: it can balance Postgres, MQTT or any other TCP traffic. AWS Network Load Balancer is an L4 balancer.
An L7 load balancer works at the application layer. It decrypts HTTPS, reads the method, path, headers and cookies, and then decides. So it can send /reports/* to one pool and everything else to another, add headers, retry a failed request on another server, and see status codes for its health logic. Nginx as a reverse proxy, HAProxy in mode http and AWS ALB are L7.
One difference matters a lot in practice. An L4 balancer picks a server per connection. A mobile app with HTTP keep-alive or HTTP/2 can send hundreds of requests over one connection, and all of them land on the same server. An L7 balancer picks a server per request, so load spreads evenly even when clients reuse connections.
Quick check
You want every request for /reports/* to go to a separate pool of servers. Which balancer can do that?
Routing by path needs the HTTP request, which an L4 balancer never reads: to it, the request is encrypted bytes inside a TCP connection. Every path arrives on the same port 443, so the port tells it nothing about reports.
/reports to a separate pool and spread requests from one connection across servers.How it picks a server
The balancing algorithm is the floor manager's rule. Four cover almost every case:
- Round robin. Server 1, 2, 3, 1, 2, 3, in turn. It is the default in Nginx and works well when servers are the same size and requests take similar time.
- Weighted round robin. Same idea, but a server with
weight=2gets twice as many requests. Use it while you run mixed machine sizes, for example during a migration. - Least connections. Send the request to the server with the fewest requests in progress right now. This adapts on its own: a slow server finishes requests slowly, keeps more in progress, and so receives fewer new ones. It is the best default when request times vary, such as a mix of quick stock lookups and slow report exports.
- Hash (IP hash or a key hash). Hash something about the request, such as the client IP or a URL, and always map the same value to the same server. Use it when a server keeps something useful per key, such as a local cache. With the
consistentoption, adding a server moves only a small share of keys (Sharding and consistent hashing).
IP hash has a trap in many countries, especially where most people go online through mobile data. Mobile operators put many phones behind a small number of public IP addresses, a setup called CGNAT, and all devices in one store usually share the store router's IP. Thousands of users can hash to the same server while others sit idle.
Quick check
200 store devices reach your API from behind 4 public IP addresses, and Nginx uses ip_hash across 6 servers. What happens?
IP hash maps each address to one server. Four addresses can reach at most four servers, however many devices sit behind them, and one busy address makes its server hot. Nothing is rejected; the load is uneven.
| Algorithm | Nginx directive | Good for | Watch out |
|---|---|---|---|
| Round robin | (default) | Equal servers, similar requests | Keeps feeding a slow server its full share |
| Weighted | server … weight=2 | Mixed machine sizes | Weights go stale when machines change |
| Least connections | least_conn; | Mixed fast and slow requests | A server that fails fast looks "free" and attracts traffic, so pair it with health checks |
| IP hash | ip_hash; | Simple stickiness | Uneven behind NAT; users move when servers change |
| Key hash | hash $request_uri consistent; | Per-key cache locality | Hot keys overload one server |
Health checks: skipping the counter on a break
A health check decides who is in the pool. There are two kinds, and good setups use both.
Active checks. The LB calls each server on its own schedule, for example GET /health/ready every 2 seconds. After a set number of failures in a row (often 2 or 3) it removes the server. After a set number of passes it adds it back. HAProxy and managed balancers do this. In Nginx, active checks are part of the paid NGINX Plus; open-source Nginx only has passive checks.
Passive checks. The LB watches real traffic. If requests to a server fail to connect or time out a few times within a window, it stops using that server for a while. Nginx's max_fails and fail_timeout work this way. The cost is that a few real user requests must fail before the LB reacts.
What the health endpoint tests matters. A liveness check asks "is the process running?" and is used to restart a stuck container. A readiness check asks "can this copy serve requests right now?" and is what the LB should use. A copy that has started but has not connected to the database yet is alive and not ready.
Quick check
A new copy answers HTTP, but its database connection is still being set up. What should its checks say?
The process is alive, so there is nothing to restart, and failing liveness would kill a copy that only needed a few more seconds. It cannot serve real requests yet, so readiness fails and the load balancer waits before sending it traffic.
Think first: your readiness check pings the database. The database restarts and is gone for 10 seconds. What does the load balancer do?
Every copy fails its check at the same moment, so the LB removes all of them. Users get the LB's own 502 or 503 page instead of your app's error message, and when the database returns, all copies rejoin together. That can be acceptable, because the app could not serve anyway. Many teams keep the database ping but give it a short timeout (about 1 second) and require 2 or 3 failures, so a short blip does not empty the pool. What you must never do is put a slow or optional dependency, such as an SMS gateway, in the readiness check. One slow partner would take your whole API out.
Try it yourself. Start with round robin and press "Send 20 at once". Then tick "Server 2 is slow", turn on "Steady traffic" and switch between round robin and least connections. Finally tick "Server 3 crashes" and watch the health checks remove it.
- With a slow server, round robin still gives it a third of the requests, so its queue grows. Least connections sends it fewer because it is always "busy".
- A crashed server causes failed requests until the second failed check. That gap is why the check interval and failure count matter.
Sticky sessions, TLS and timeouts
Sticky sessions. A sticky session setting pins a user to one server, usually with a cookie the LB sets. It hides in-memory state instead of removing it, makes load uneven, and logs users out when their server is redeployed. Make the app stateless instead (Scaling up vs scaling out). One real exception: Socket.IO's HTTP long-polling fallback needs all requests of one client to reach the same server (Real-time updates).
TLS termination. The LB holds the certificate, decrypts HTTPS, and talks plain HTTP to the servers inside the private network. You renew certificates in one place, and the LB can read the request for L7 routing. Because the app now sees the LB as the client, the LB must pass the original details in headers: X-Forwarded-For (client IP) and X-Forwarded-Proto (was it https). The app must trust those headers only from the LB.
Timeouts. Every LB has a connect timeout and a read timeout. If the LB's read timeout is 60 seconds and a monthly report takes 90, the user gets a 504 Gateway Timeout while the server keeps working and may finish a report nobody receives. Long work belongs in a background job (Queues and background jobs). The other classic bug is the idle timeout, below.
The load balancer is a single point of failure
You added three servers so that one can die. Now the LB is the one box that cannot. There are three common answers:
- A managed load balancer. AWS ALB, Google Cloud and Cloudflare run their balancers across several machines and zones for you. For most teams this is the right answer.
- Two self-hosted LBs with a floating IP. Two Nginx or HAProxy machines share one virtual IP, managed by keepalived. If the active one dies, the standby takes the address within a few seconds.
- Several DNS records. Publish two LB addresses in DNS. It is cheap but slow to react, because clients and resolvers cache DNS answers for the TTL (What happens when you open an app).
Global load balancing applies the same idea one level up. A DNS service or an anycast network sends each user to the nearest healthy region and moves traffic to another region if one fails. Most apps serving one country run in one region and do not need it yet. They do need a plan for what happens if that region or data centre has a bad day (Backups and disaster recovery).
Build it
Put Nginx in front of three API copies with least connections, passive health checks, sane timeouts and correct headers, add a real readiness endpoint, then see what active checks look like in HAProxy. To practise the two most common picking rules in code, try the JavaScript exercise in the Practice section; it runs in your browser.
-
Define the pool
max_fails=3 fail_timeout=10smeans: if 3 attempts fail within 10 seconds, skip this server for the next 10 seconds, then try it again.nginx# /etc/nginx/conf.d/api.conf upstream api_pool { least_conn; # fewest in-progress requests wins server 10.0.1.11:3000 max_fails=3 fail_timeout=10s; server 10.0.1.12:3000 max_fails=3 fail_timeout=10s; server 10.0.1.13:3000 max_fails=3 fail_timeout=10s weight=2; # bigger machine keepalive 32; # idle connections kept open to the pool } upstream report_pool { least_conn; server 10.0.1.21:3000 max_fails=3 fail_timeout=10s; server 10.0.1.22:3000 max_fails=3 fail_timeout=10s; } -
Terminate TLS, route by path, set headers and timeouts
proxy_next_upstreamlists which failures make Nginx try the next server. Connection errors and timeouts always count as failures formax_fails; 502 and 503 count only if you list them. Since Nginx 1.9.13, a POST that already reached a server is not retried elsewhere unless you addnon_idempotent, which protects you from recording the same delivery twice (APIs done right).nginxserver { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; proxy_http_version 1.1; proxy_set_header Connection ""; # allow upstream keepalive proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Request-Id $request_id; # trace one request in logs proxy_connect_timeout 2s; # a healthy server on a LAN accepts in ms proxy_send_timeout 30s; proxy_read_timeout 30s; # max wait between two reads, not total time proxy_next_upstream error timeout http_502 http_503; proxy_next_upstream_tries 2; # at most one retry on another server proxy_next_upstream_timeout 5s; location /reports/ { proxy_pass http://report_pool; proxy_read_timeout 120s; # exports are slow; better: make them jobs } location / { proxy_pass http://api_pool; } }Terminalsudo nginx -t && sudo systemctl reload nginx # test config, then reload with no downtime -
Tell NestJS it is behind a proxy
Without
trust proxy,req.ipis the LB's address for every user, which breaks logging and per-IP rate limits. RaisingkeepAliveTimeoutfixes the random 502s from the callout above.TypeScript// main.ts import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create<NestExpressApplication>(AppModule); app.set('trust proxy', 1); // trust ONE hop: our LB sets X-Forwarded-For app.enableShutdownHooks(); const server = app.getHttpServer(); server.keepAliveTimeout = 65_000; // longer than the LB idle timeout (60 s) server.headersTimeout = 66_000; // must be larger than keepAliveTimeout await app.listen(3000); } bootstrap(); -
Add liveness and readiness with @nestjs/terminus
Install with
npm i @nestjs/terminusand addTerminusModuleto the module's imports.PrismaHealthIndicatoris built into Terminus 10 and later. When a check fails,@HealthCheck()makes the endpoint return503, which is exactly what the LB needs to see.TypeScript// health.controller.ts import { Controller, Get } from '@nestjs/common'; import { HealthCheck, HealthCheckService, PrismaHealthIndicator } from '@nestjs/terminus'; import { PrismaService } from '../prisma/prisma.service'; @Controller('health') export class HealthController { constructor( private readonly health: HealthCheckService, private readonly prismaHealth: PrismaHealthIndicator, private readonly prisma: PrismaService, ) {} // Liveness: is the process responding? No dependencies on purpose. @Get('live') live() { return { status: 'ok' }; } // Readiness: can this copy serve traffic? The LB checks this one. @Get('ready') @HealthCheck() ready() { return this.health.check([ () => this.prismaHealth.pingCheck('database', this.prisma, { timeout: 1000 }), ]); } }Output$ curl -s localhost:3000/health/ready {"status":"ok","info":{"database":{"status":"up"}},"error":{},"details":{"database":{"status":"up"}}} # with Postgres stopped: HTTP 503 {"status":"error","info":{},"error":{"database":{"status":"down"}},"details":{"database":{"status":"down"}}} -
See active health checks in HAProxy
If you need active checks without NGINX Plus, HAProxy has them built in.
inter 2s fall 2 rise 1means: check every 2 seconds, remove after 2 failures in a row, add back after 1 pass. That is the same rule the simulator uses. Thehttp-check sendsyntax needs HAProxy 2.2 or later.Terminalsudo tee /etc/haproxy/haproxy.cfg > /dev/null <<'EOF' defaults mode http timeout connect 2s timeout client 30s timeout server 30s option forwardfor # adds X-Forwarded-For frontend web bind :80 default_backend api backend api balance leastconn option httpchk http-check send meth GET uri /health/ready http-check expect status 200 default-server inter 2s fall 2 rise 1 server api1 10.0.1.11:3000 check server api2 10.0.1.12:3000 check server api3 10.0.1.13:3000 check EOF haproxy -c -f /etc/haproxy/haproxy.cfg # prints "Configuration file is valid" sudo systemctl reload haproxy -
Break a server on purpose
Stop the API on one machine and send traffic. Nginx logs the failed attempts, skips that server for
fail_timeout, and users of the other two notice nothing.Terminal# on 10.0.1.13 sudo systemctl stop api # from anywhere: 20 requests, print status codes for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code} " https://api.example.com/health/ready; done; echo # on the LB: what Nginx saw sudo tail -n 3 /var/log/nginx/error.logOutput200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 ... connect() failed (111: Connection refused) while connecting to upstream, upstream: "http://10.0.1.13:3000/health/ready" ...All 200s. "Connection refused" happens before any request bytes are sent, so Nginx can safely try the next server, even for a POST. A server that crashes in the middle of a request is different: that request was already sent, so Nginx does not resend a POST, and that one write fails. That is fine as long as the app retries with an idempotency key (APIs done right).
Bring it to your team
Ask these when you see a box labelled "LB" in a design.
- Is this an L4 or an L7 balancer, and which algorithm does it use? Why that one for our mix of requests?
- What does the health check call, how often, and after how many failures is a server removed? How many user requests fail before that?
- Does the readiness check depend on anything slow or optional, like the SMS or payment gateway?
- What are the LB's read and idle timeouts, and what is our slowest endpoint? Is the Node keep-alive timeout longer than the LB's idle timeout?
- If the load balancer itself dies, what takes over, and how long does it take?
- Do we need sticky sessions anywhere? If yes, what state is the server keeping that it should not?
Red flags
- The health check returns 200 from a static route even when the database is unreachable.
- One self-hosted Nginx box in front of everything, with no standby.
- Report exports or bulk uploads that regularly hit the LB's 60-second timeout.
- Every log line shows the LB's IP as the client IP.
- Sticky sessions turned on because "users kept getting logged out".
Practice
Write round robin and least connections
Each server is an object such as { name: 'api1', healthy: true, active: 3 }, where active is the number of requests in progress. Write createRoundRobin(servers), which returns a pick() function that hands out healthy servers in turn and skips unhealthy ones, and leastConnections(servers), which returns the healthy server with the fewest active requests (the first one in the list wins a tie). Both return the server's name, or null when no server is healthy. Read healthy on every call, because health checks change it at any time.
function createRoundRobin(servers) {
let next = 0; // index to try first on the next pick
return function pick() {
// start at next, skip unhealthy servers, wrap around the end of the list
// return the chosen server's name, or null if none is healthy
};
}
function leastConnections(servers) {
// the healthy server with the fewest active requests; the first one wins a tie
// return its name, or null if none is healthy
}
const pool = () => [
{ name: 'api1', healthy: true, active: 0 },
{ name: 'api2', healthy: true, active: 0 },
{ name: 'api3', healthy: true, active: 0 },
];
test('round robin takes turns', () => {
const pick = createRoundRobin(pool());
expect([pick(), pick(), pick(), pick()]).toEqual(['api1', 'api2', 'api3', 'api1']);
});
test('round robin skips a server that fails its health check', () => {
const servers = pool();
const pick = createRoundRobin(servers);
expect(pick()).toBe('api1');
servers[1].healthy = false; // api2 drops out of the pool
expect([pick(), pick(), pick()]).toEqual(['api3', 'api1', 'api3']);
});
test('round robin returns null when no server is healthy', () => {
const servers = pool();
servers.forEach((s) => { s.healthy = false; });
expect(createRoundRobin(servers)()).toBe(null);
});
test('least connections picks the least busy healthy server', () => {
const servers = pool();
servers[0].active = 5; servers[1].active = 1;
servers[2].active = 0; servers[2].healthy = false; // idle but out of the pool
expect(leastConnections(servers)).toBe('api2');
});
test('least connections breaks a tie with the first server, and returns null if none is healthy', () => {
const servers = pool();
servers.forEach((s) => { s.active = 2; });
expect(leastConnections(servers)).toBe('api1');
servers.forEach((s) => { s.healthy = false; });
expect(leastConnections(servers)).toBe(null);
});
Show a solution
function createRoundRobin(servers) {
let next = 0;
return function pick() {
for (let i = 0; i < servers.length; i++) {
const idx = (next + i) % servers.length; // wrap around the end
if (servers[idx].healthy) {
next = (idx + 1) % servers.length; // the turn after this one
return servers[idx].name;
}
}
return null; // nobody healthy: the LB returns 502/503
};
}
function leastConnections(servers) {
let best = null;
for (const s of servers) {
if (!s.healthy) continue;
if (best === null || s.active < best.active) best = s; // strict: first one wins a tie
}
return best ? best.name : null;
}Line up the timeouts
Mangrove's API runs behind an AWS Application Load Balancer with its default 60-second idle timeout. Node's keepAliveTimeout is still the default 5 seconds, and users report a few random 502s a day. The slowest normal endpoint takes about 8 seconds. The monthly export takes about 90 seconds and often ends in a 504. Write the settings you would change, with one sentence each on why.
- You set Node's
keepAliveTimeoutabove the balancer's idle timeout (for example 65 seconds), withheadersTimeouta little higher - You explained the random 502s: the balancer reuses an idle connection at the moment Node closes it
- You left the balancer's timeout comfortably above the slowest normal request instead of raising it for every route
- You moved the 90-second export to a background job that returns 202 and a status link, or at least gave it its own route and pool
Show a model answer
Node: server.keepAliveTimeout = 65_000 and server.headersTimeout = 66_000. The balancer keeps idle connections for 60 seconds; Node must not close them first, or a request sent on a closing connection fails with a 502.
Balancer: keep the 60-second idle timeout. The slowest normal request (8 seconds) fits easily, and a long timeout for everything would hide slow code and hold connections open.
Export: answer 202 Accepted with a job id, build the file in a background worker, and let the app download it when it is ready. A 90-second request will keep failing at some proxy, whatever you raise.
Check yourself
One of your three servers has become slow (a noisy neighbour on its VM). Which algorithm keeps user latency lowest without any manual change?
Least connections reacts to the slowness automatically. Round robin and equal weights still send a third of requests to the slow server. IP hash pins some users to it permanently, so they suffer the most.
Your mobile app keeps one HTTP/2 connection open and sends all its requests over it. The LB in front is an L4 network balancer. What happens?
An L4 balancer chooses a server once per TCP connection and cannot see the requests inside. It does not read cookies or paths, and it does not care which HTTP version runs inside the connection. Per-request balancing needs an L7 balancer.
Which health check is the best fit for the load balancer's readiness check?
Readiness should test what this copy needs to serve requests, quickly. A route that always says 200 keeps a broken copy in the pool. Checking optional outside services means one slow partner removes every copy at once, which turns a small problem into a full outage.
Flashcards
Key takeaways
- A load balancer picks a server, removes sick ones, and is the front door. L4 balances connections; L7 reads HTTP and balances each request.
- Least connections is a strong default when request times vary. Avoid IP hash behind NAT.
- Use a fast readiness check (503 on failure) for the LB and a dependency-free liveness check for restarts. Active checks catch failures before users do; passive checks need some failures first.
- Align timeouts: LB read timeout above your slowest normal request, and Node's keep-alive timeout above the LB's idle timeout.
- The LB itself must be redundant: managed, a pair with a floating IP, or at least a plan.
Sources
- Module ngx_http_upstream_module, nginx documentation
- HAProxy documentation, HAProxy
- HTTP (server.keepAliveTimeout, server.headersTimeout), Node.js documentation