08 · Scaling & Distributed Systems

Load Balancing

7 min read · 5 questions · 1 exercise

A load balancer decides which of several interchangeable servers answers each incoming request.

A single server has a ceiling. It is built from CPU, memory, file descriptors, and the number of connections the operating system will hold open at once, and when traffic reaches that ceiling the server does not politely slow down. Latency climbs, queues back up, timeouts start firing, and clients retry — which adds load to a machine that is already past its limit. A launch, a sale, or one popular link can find the ceiling in minutes.

Buying a bigger machine buys time. What it cannot buy is survival of that machine’s failure, and there is always a size past which nothing bigger exists. The other direction is to run several ordinary servers and put something in front of them that decides, for every incoming request, which one answers it. That something is a load balancer: the host at the door of a busy restaurant, sending each party to a table so no single waiter ends up with the whole room.

Round-robin and its blind spot

The simplest strategy is to take turns. Keep one integer, send request 1 to the first server, request 2 to the second, request 3 to the third, request 4 back to the first. That is round-robin, and its appeal is real: it holds no opinion about the servers, measures nothing, and therefore has nothing that can go stale or be measured wrong. Over a few thousand requests each server receives the same number of them.

The same number of requests is not the same amount of work. A session check costs a millisecond; generating a monthly report costs thirty seconds of CPU and a lot of memory. Round-robin cannot tell those apart, because it never looks inside a request and never asks a server how it is doing. In the scene below, every fourth request is an expensive report — watch the rotation hand it out right on schedule. The counters climb evenly, and nothing in them says which server is thirty seconds deep in a report.

Predict first

Round-robin sends 12 requests in turn to S1, S2 and S3, starting with S1. Requests 3, 7 and 11 are expensive reports. When all 12 have been routed, how are they split?

  1. Request 3 does land on S3, but the reports come every fourth request and the rotation repeats every third, so each report lands one server further on: 3 goes to S3, 7 to S1, 11 to S2.

  2. Round-robin counts requests, so 12 over 3 servers is 4 apiece. Each report comes four requests after the last, one step past a full rotation, so they land on S3, S1 and S2. Round-robin did not plan that evenness; it fell out of the numbers.

  3. That is what a balancer that watched load would do. Round-robin never asks who is busy, so a server deep in a report still gets its turn, and the counts stay at 4, 4 and 4.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

Round-robin routing

one request · a server’s badge counts the requests routed to it

client
LB
S1
S2
S3

Three servers, one queue of requests — use Next step to go one at a time.

The even split in that lap is luck. Change the pattern so that every third request is a report — a dashboard that loads two cheap widgets and then one report, over and over — and every report lands on S3, because the pattern now repeats exactly as often as the rotation does. Each round of three requests hands S3 thirty seconds of CPU and S1 and S2 a millisecond each, and the three counters still climb in perfect step.

Servers can differ too, especially in a pool that grew over time. Weighted round-robin fixes that much: give each server a share of the rotation proportional to its capacity, so the older machine takes one request for every three the newer one takes. It is still a fixed opinion decided in advance, not a response to what is happening right now.

Counting what is in flight

To react to reality, the balancer needs a signal that changes with load. The cheapest good one is already in its own hands: how many requests it has open to each server right now. Least-connections sends the next request to whichever server has the fewest in flight.

That small change fixes the report problem. A server tied up on a slow request keeps that connection open, so its in-flight count stays high and new requests stop arriving until it catches up. Nobody had to classify requests as cheap or expensive in advance, and no server had to report its own health honestly.

Both strategies fit behind one interface, which is how real balancers let you swap the policy without touching anything else:

interface Server {
  id: string;
  healthy: boolean;
  inFlight: number;
}

type Strategy = (pool: Server[]) => Server;

function roundRobin(): Strategy {
  let next = 0;
  return (pool) => {
    const server = pool[next % pool.length]!;
    next += 1;
    return server;
  };
}

const leastConnections: Strategy = (pool) =>
  pool.reduce((best, server) => (server.inFlight < best.inFlight ? server : best));

function pickServer(pool: Server[], strategy: Strategy): Server | undefined {
  const healthy = pool.filter((server) => server.healthy);
  return healthy.length === 0 ? undefined : strategy(healthy);
}

Connection count is a proxy, not a measurement of load, and it can mislead. Idle keep-alive connections, WebSockets, and long-polling all inflate the number without costing the server anything, which is why balancers handling those use outstanding requests rather than open connections, and why latency-aware policies — routing by a rolling average of response time — exist for pools where neither count tells the truth.

Try it

Keep the count honest

leastConnections reads inFlight, but nothing in the sample ever changes it. Write route(pool, strategy, send), which picks a server with pickServer, counts the request as in flight on that server while send runs, and stops counting it once send settles, whether it succeeded or failed. It should resolve to whatever send resolves to.

Hint

Increment before you call send, not after it returns: the count has to be up while the request is running, because that is when the next pick needs to see it. Then ask what happens to a server’s count if send throws and nothing decrements it.

Solution
async function route<T>(
  pool: Server[],
  strategy: Strategy,
  send: (server: Server) => Promise<T>,
): Promise<T> {
  const server = pickServer(pool, strategy);
  if (!server) throw new Error('no healthy server to send to');

  server.inFlight += 1;
  try {
    return await send(server);
  } finally {
    server.inFlight -= 1;
  }
}

The finally is easy to leave out and expensive to miss. Without it, every failed request leaves its server looking one request busier, permanently, and least-connections sends it less and less traffic until a healthy server sits nearly idle.

To see it work, start a slow report and send three quick requests while it runs:

const pool: Server[] = ['S1', 'S2', 'S3'].map((id) => ({
  id,
  healthy: true,
  inFlight: 0,
}));
const sentTo: string[] = [];
const send = (ms: number) => async (server: Server): Promise<void> => {
  sentTo.push(server.id);
  await new Promise((resolve) => setTimeout(resolve, ms));
};

const report = route(pool, leastConnections, send(100)); // still running below
for (let i = 0; i < 3; i += 1) await route(pool, leastConnections, send(1));
await report;
console.log(sentTo); // [ 'S1', 'S2', 'S2', 'S2' ]

Nothing joins the report on S1. The quick requests all go to S2 because each one finishes before the next arrives, and reduce keeps the first server on a tie. Pass roundRobin() instead and the fourth request lands on S1, right beside the report.

Health checks

Notice the filter in pickServer. A dead server left in rotation is worse than a smaller pool: round-robin will keep handing it every third request and every one of them will fail. The balancer has to know, without being told, which servers are currently able to answer.

It finds out by probing. Every few seconds it sends a request to a known endpoint on each server. A set number of consecutive failures takes that server out of rotation; probing continues, and a set number of consecutive successes puts it back. Requiring several in a row in both directions is deliberate — it stops one unlucky timeout from ejecting a healthy server, and stops a half-recovered one from flapping in and out.

What the health endpoint checks is a genuine design decision. Returning 200 whenever the process is alive will happily keep a server in rotation whose database pool is exhausted and which fails every real request. Checking every dependency deeply has the opposite failure: when one shared dependency slows down, every server reports unhealthy at the same moment and the balancer empties the entire pool. The endpoint should be cheap, and should test the things that predict this particular server’s ability to serve.

What the balancer cannot fix

All of this assumes the servers are interchangeable. The moment one of them keeps something in local memory that a later request needs — a session, a half-finished upload, an in-process cache of the user’s cart — requests have to return to that same machine.

Sticky sessions do exactly that, usually with a cookie that names each client’s server. They work, and the bill comes later: your distribution is now only as even as the mapping, adding servers does not rebalance existing sessions, and when a server dies its users lose their state, which is the failure you added servers to avoid. The durable fix is to move that state out of the servers into a shared store so that any of them can answer any request. A load balancer distributes requests; it cannot distribute state.

Check yourself

Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.

Your balancer probes each server every 5 seconds and removes one after 3 failed probes in a row. Traffic is 300 requests a second, round-robin over three servers. One server crashes just after passing a probe. About how many requests fail before it is removed?

  1. This balancer learns only from its probes, not from real traffic, so it keeps routing to the dead server until the third probe fails. Some balancers also watch real responses (passive health checks) precisely to close this gap.

  2. That would be true if one failed probe removed the server. Requiring three in a row protects against a single unlucky timeout, and the price is three intervals of failures, not one.

  3. The dead server keeps its third of the traffic, 100 requests a second, until the probes at 5, 10 and 15 seconds have all failed. 100 × 15 is 1,500.

  4. Only the dead server's share fails. The other two keep answering their turns, so two requests in three still succeed.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

A pool uses least-connections. Server B holds 900 idle WebSocket connections. Servers A and C hold 40 connections each, all busy requests, and their CPUs are running hot. Where do the next requests go?

  1. Least-connections compares counts, and 40 is less than 900. The connections on B cost it almost nothing, but the balancer cannot see that, which is why pools like this count outstanding requests instead of open connections.

  2. B does have the most spare capacity, but least-connections never measures capacity. It counts open connections, and by that count B looks like the busiest server in the pool.

  3. That is round-robin. Least-connections always picks the lowest count, and B will not have it until A and C each pass 900.

Show the answer

The answer is A. The reasoning for each option is now shown under it.

Each server's health endpoint runs a query against the shared database. One afternoon the database slows to a crawl for a few minutes. What does the balancer do?

  1. The servers share the database, so they slow down together. There is no single slowest server to remove; every probe fails at once.

  2. That is what a check of the process alone would do. This endpoint checks the database, so it fails whenever the database is slow, however healthy the process is.

  3. A load balancer chooses between servers, not between their dependencies. All it knows is that every server answered its probe badly.

  4. Every server reports unhealthy at once and the balancer empties the pool, so even requests that never touch the database fail. The endpoint tested a shared dependency rather than whether this one server can serve; some balancers fail open when every server fails, to survive exactly this.

Show the answer

The answer is D. The reasoning for each option is now shown under it.

Carts live in server memory, so the balancer pins each client to one server with a cookie. Traffic grows and you add a fourth server. An hour later it is nearly idle. Why?

  1. A server failing its checks gets nothing at all. Nearly idle means it is in rotation and receiving a small share of the traffic.

  2. Only clients who arrive without a cookie can be sent to the new server; everyone already shopping stays where their cart is. Stickiness turns adding a server into a slow drift rather than a rebalance, which is one more reason to move carts into a shared store.

  3. A balancer adds a server to the pool once it passes its health checks, not an hour later. The new server is in the pool; few clients are free to be sent to it.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

Key takeaways

  • A load balancer turns one server’s ceiling into a pool’s ceiling by choosing, per request, which interchangeable server answers it.

  • Round-robin equalizes request counts, not work, so it distributes badly whenever request cost varies.

  • Least-connections reacts to real load using a signal the balancer already has, though open connections only approximate how busy a server is.

  • Health checks decide who is in the pool, and server-local state decides whether pooling works at all.