SysDesignPrep.com
System design interview question

Design a Load Balancer

Spread 5 M requests a second over 50 k backends, terminate TLS, route by host and path, survive losing any balancer or data centre.

Last updated 2026-10-26. Difficulty: hard. Patterns: load-balancing, networking, consistent-hashing, health-checks, control-plane. Reported at Google, Amazon, Microsoft, Meta, Cloudflare, Uber.

Walk through a strong candidate's answer, turn by turn.

The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.

Or sit it in the style of Google · Amazon · Microsoft · Meta, run as that company’s round is reported to go.

The short answer

How to design Load Balancer in a system design interview, in five steps:

  1. Scope it. Spread traffic across healthy backends, terminate TLS for every public hostname, route by host, path and headers, survive the loss of any balancer, zone or region, drain and slow-start backends for deploys, send users to the nearest healthy region, self-service config for a few hundred services, internal service-to-service balancing, boundary features.
  2. Set the targets. Throughput: 5 M req/s · 10 M conns; Added latency: L4 µs · L7 p99 < 2 ms; Availability: 99.995 %; Connection stability: ≤ 1/N reset per box lost.
  3. Do the numbers. Requests per region: ~1.25 M/s · 1.7 M/s after a region loss; Inbound packets at the L4 tier: ~50 M pps · ~17 M per region in a failover; L4 boxes per region: ~5 needed · run 8.
  4. Draw the design. Authoritative DNS, Global traffic controller, Edge routers, L4 balancers, L7 proxies, Key service, Rate limit store, Service backends, Per-host proxies.
  5. Go deep where it is hard.
    • The L4 tier: ECMP, consistent hashing, DSR → Stateless L4 fleet: Maglev-style lookup table, local connection table, DSR.
    • Choosing a backend at L7 → Power of two choices on requests in flight, weighted, with slow start.
    • Health checks that cannot empty the pool → Central active probes, passive outlier detection, fail-open guards.
    • The control plane and safe config pushes → xDS-style streaming: incremental, versioned, ACK/NACK, staged rollout for routes.
    • TLS termination at scale → Keys pushed to proxies in memory, ECDSA first, shared rotating ticket keys.
    • Steering users and losing a region → Anycast front door, DNS for deliberate moves, N+1 capacity.

Functional requirements

  • Spread traffic across healthy backends. Per connection at L4, per request at L7. A failing backend leaves rotation within seconds, and nothing new is sent to a backend that is draining.
  • Terminate TLS for every public hostname. A few hundred hostnames, certificates renewed automatically, private keys never on disk. Re-encrypt to backends with mTLS.
  • Route by host, path and headers. api.example.com/v1/orders to the orders service, weighted splits for canaries (95 / 5), header matches for internal testing.
  • Survive the loss of any balancer, zone or region. Losing an L4 or L7 box may reset at most that box’s own share of connections. Losing a region moves its users to the others within a minute.
  • Drain and slow-start backends for deploys. Stop sending new requests, let in-flight ones finish, then remove. Ramp a new instance up over a minute instead of handing it a full share while it is cold.
  • Send users to the nearest healthy region. Anycast for the front door; DNS for deliberate moves such as draining a region or keeping an account in its home region for residency.
  • Self-service config for a few hundred services. Teams own their routes. Changes are reviewed, versioned, rolled out in stages and reverted in one step.
  • Internal service-to-service balancing. The same routing, health and config model, applied by a proxy on the caller’s host, so an internal call takes no extra hop through a shared tier.
  • Boundary features. Per-client rate limits (the design from the Rate Limiter question), request ids, access logs and per-route metrics, all at the L7 tier.
  • Out of scope. Volumetric DDoS scrubbing beyond SYN cookies and drop rules, the CDN and static caching, WAF rules, and the service registry itself, which the compute platform owns.

Non-functional requirements

  • Throughput (5 M req/s · 10 M conns). Global peak over 4 regions: ~1.25 M requests a second per region, 1.7 M when a region is lost. Capacity grows by adding boxes; nothing central sits on the request path.
  • Added latency (L4 µs · L7 p99 < 2 ms). L4 is a table lookup and an encapsulation. L7 parses HTTP and picks a backend. Full TLS handshakes are the expensive part, so the resumption rate matters more than proxy code speed.
  • Availability (99.995 %). Everything sits behind the balancer, so its availability caps every service’s. That is about 26 minutes a year for all services combined.
  • Connection stability (≤ 1/N reset per box lost). Adding or losing one of N boxes may disturb only that box’s share of connections, never reshuffle all of them. This drives the hardest trade-off in the design: L4 boxes must agree on where every flow goes without sharing any state.
  • Failure detection (backend < 10 s · region < 30 s). Passive outlier detection reacts in under a second on real traffic, active probes catch idle backends, and regional failover is bounded by detection, not by DNS TTLs.
  • Config propagation (endpoints p99 < 5 s). Endpoint changes from deploys and autoscaling must reach every proxy fast. Route changes are riskier and roll out in stages over ~30 minutes. If the control plane is down, the data plane keeps its last good config.
  • Capacity headroom (N+1 regions). Each region is provisioned a third above its normal peak, so losing one region at peak does not overload the other three and cascade into a global outage.
  • Key custody (no private keys on disk). TLS keys exist only in memory on proxies, or stay in an HSM-backed key service. A stolen disk, image or backup must expose nothing.

Back-of-envelope estimates

  • Requests per region: ~1.25 M/s · 1.7 M/s after a region loss. 5 M/s at peak ÷ 4 regions = 1.25 M/s. Lose one and the other three carry 5 M ÷ 3 ≈ 1.7 M/s each, so every region is sized for 1.7 M: a third above its normal peak. That headroom is the price of N+1.
  • Inbound packets at the L4 tier: ~50 M pps · ~17 M per region in a failover. About 10 inbound packets per request: request segments, ACKs for a ~10 KB response, amortised handshakes and teardown. 5 M × 10 = 50 M pps globally, and 50 M ÷ 3 ≈ 17 M pps per region with one region down. Responses never cross L4 (direct server return), which keeps 5 M × 10 KB ≈ 400 Gbps of response traffic off it.
  • L4 boxes per region: ~5 needed · run 8. 17 M pps ÷ 4 M pps per box (a conservative planning number for XDP or DPDK forwarding on a 2 × 25 GbE server) ≈ 4.3, so 5. Run 8: two spare for maintenance and failure, and room for a SYN flood. The L4 tier is small and is sized by attack headroom more than by legitimate load.
  • L7 proxies per region: ~34 needed · run 40. 1.7 M req/s ÷ 50 k req/s per proxy (TLS mostly resumed, keep-alive, 32 cores at ~60 % CPU) ≈ 34. Run 40 per region, 160 globally. Per box, L4 moves about 8× the requests L7 can parse (4 M pps ≈ 400 k requests), which is why L7 is the tier that scales out and changes often.
  • Connections per L7 proxy: ~83 k · ~3.3 GB of buffers. 10 M connections ÷ 4 regions = 2.5 M; with a region down, 3.3 M per region ÷ 40 proxies ≈ 83 k per proxy. At ~40 KB per TLS connection (socket buffers, TLS and HTTP/2 state) that is ~3.3 GB, easy for a 128 GB box. The L4 tier tracks the same flows at ~100 B each: 3.3 M ÷ 8 boxes × 100 B ≈ 40 MB per box.
  • TLS handshakes: ~170 k new conns/s · ~50 k full handshakes/s. 10 M connections lasting ~60 s on average = ~170 k new connections a second globally. With 70 % resumed from session tickets, ~50 k full handshakes a second. At ~1 ms of CPU each with RSA-2048 that is ~50 cores; with ECDSA P-256 at ~0.1 ms, ~5. Steady state is cheap: the risk is a failover moving 2.5 M connections at once, where resumption decides whether it is a blip or a CPU wall.
  • Health check probes: 8 M/s naive · 75 k/s central. If all 160 edge proxies probed all 50 k backends once a second: 160 × 50 k = 8 M probes/s, 160 a second landing on every backend, before counting the ~5 k per-host proxies. Instead, 3 central checkers per backend (one per zone) every 2 s: 50 k × 3 ÷ 2 = 75 k probes/s, 1.5 per backend per second.
  • Config size and push: ~6 MB snapshot · ~30 GB per full push. 50 k endpoints × ~100 B = 5 MB, plus ~1 MB of routes and clusters for 300 services. Sending the whole 6 MB to ~5 k proxies is 30 GB per change, and deploys and autoscaling change about 100 k endpoints a day, tens a second during a big deploy. So pushes must be incremental: one endpoint change is ~100 B, sent only to the proxies that route to that service.

Components

  • Users and apps: Browsers, mobile apps and partner servers on the internet. They resolve one hostname and open TLS connections that live about a minute on average. A reset connection shows up to them as an error or a retried request, which is why the design counts how many connections each failure moves.
  • Authoritative DNS (anycast · health-aware): Answers queries for the company’s hostnames, itself served from anycast addresses in every region. Normally it returns the global anycast VIP with a 60 s TTL; for a deliberate move (draining a region, residency) it returns per-region addresses instead. It is the steering wheel for planned changes, not the failover mechanism.
  • Global traffic controller (region health · BGP policy): Decides which regions take traffic. Probes each region from ~40 outside vantage points, reads each region’s capacity, and tells the region’s edge routers to announce, prepend or withdraw the anycast prefixes, and DNS what to answer. Guardrail: it never withdraws a second region without a human, and fails back more slowly than it fails over.
  • Edge routers (BGP · ECMP): Border routers in each region that peer with ISPs and announce the anycast prefixes. Inside the region they spread packets for a VIP over the L4 boxes announcing it, by ECMP on a hash of the 5-tuple. They keep no per-connection state, and when an L4 box disappears they rehash, which can move flows from one L4 box to another.
  • L4 balancers (XDP · Maglev hash · DSR): About 8 boxes per region that forward packets and never terminate them. Each picks an L7 proxy from a consistent-hash lookup table built from the same versioned proxy list on every box, so any L4 box sends a given flow to the same proxy. A small connection table keeps flows in place while the proxy list changes; packets are encapsulated to the proxy, and replies bypass L4 entirely (direct server return).
  • L7 proxies (Envoy-style · TLS · HTTP): ~40 proxies per region that terminate TCP and TLS, parse HTTP, match host, path and header routes, apply rate limits, and pick a backend per request with power of two choices. They keep pooled HTTP/2 connections with mTLS to backends, retry within a budget, and eject failing backends on their own. All of their behaviour is config pushed by the control plane.
  • Key service (HSM · certs · ticket keys): Issues and rotates certificates (public ones through ACME from a public CA, internal ones from our own CA) and delivers private keys to proxies over mTLS, held in memory only. Also distributes the session-ticket keys that every proxy in every region shares, rotated every 12 hours. For edge sites we do not fully control it keeps the key and signs handshakes itself (keyless).
  • Rate limit store (Redis · token buckets): Shared counters for per-client and per-route limits at the boundary: the design from the Rate Limiter question. Proxies check local buckets first and the shared store only for limits that must hold across proxies, with a short timeout and fail-open. A rejected request costs a 429 and never reaches a backend.
  • Service backends (50 k instances · 300 services): The instances behind every route, on ~5 k hosts. Each exposes a cheap readiness endpoint that says whether it can serve, not whether every dependency is perfect, and handles SIGTERM by failing readiness, finishing in-flight work and closing keep-alive connections. Membership comes from the scheduler and health from the checkers, on purpose.
  • Per-host proxies (internal calls · same xDS): One proxy on each of ~5 k hosts handles that host’s outgoing internal calls with the same routes, P2C balancing, outlier detection and mTLS as the edge, programmed by the same control plane. Internal traffic takes no extra hop and no shared tier, so a broken proxy affects only the callers on its own host.
  • Control plane (xDS · versioned snapshots): Watches the registry and the health checkers, reads routes from the config store, compiles versioned snapshots and streams incremental updates to ~5 k proxies, and the proxy list to the L4 boxes. Endpoints take a fast path of seconds; route changes are validated and rolled out in stages. It is off the request path: if it dies, every proxy keeps serving its last accepted config.
  • Config store (versioned · reviewed): Source of truth for listeners, routes, clusters, weights and limits, one versioned document per service, changed through reviewed commits. Every applied version is kept, so rollback means re-pushing an earlier version. Small: about 1 MB for 300 services.
  • Service registry (owned by the scheduler): Which instances exist for each service, with their addresses and labels, maintained by the compute platform (Kubernetes-style endpoints). The balancer consumes it and does not own it. Membership and draining come from here, health comes from the checkers, and the control plane combines the two.
  • Health checkers (3 per backend · every 2 s): A fleet in each zone that probes every backend’s readiness endpoint every 2 s, three checkers per backend, and reports a transition after 3 consecutive results agreed by 2 of 3 checkers. It replaces every proxy probing every backend, which would be 8 M probes a second. Only transitions are reported, so a healthy steady state costs the control plane nothing.
  • Telemetry pipeline (Kafka → metrics, logs): Access logs and per-route, per-backend metrics from every proxy, batched and shipped asynchronously, never on the request path. Feeds dashboards, canary analysis for weighted rollouts and the controller’s view of each region’s load. Metrics are complete; access logs are sampled for successes and kept for every error.

User flows

  1. An HTTPS request from a user to a backend. The main path through every layer. What matters is where each decision is made, which layers hold state, and that the response skips the L4 tier on its way back.
    1. The client resolves the hostname and gets the global anycast address. One address for every region, so the answer does not depend on where the resolver is and a regional failure needs no DNS change. TTL 60 s. DNS is kept for deliberate moves, where it hands out per-region addresses instead.
    2. The SYN reaches the nearest region over BGP, and an edge router hashes it to one of the L4 boxes. ECMP hashes the 5-tuple (source IP and port, destination IP and port, protocol) over the 8 boxes announcing the VIP, so every packet of a flow takes the same path while the set of boxes is stable. The router keeps no per-flow state, which is how it does this at line rate.
    3. The L4 box looks the flow up, picks an L7 proxy from its lookup table, and forwards the packet encapsulated. A SYN misses the connection table, so the box indexes its 65,537-entry Maglev table by hash(5-tuple) and records the choice. Every L4 box builds the same table from the same proxy list, so a later packet of this flow that lands on a different L4 box still picks the same proxy. The outer header is addressed to the proxy; the inner packet still says VIP:443.
    4. The proxy decapsulates, completes TCP and TLS, and resumes the client’s session from its ticket. The proxy has the VIP on a loopback interface, so its kernel accepts the inner packet as its own. Resumption works on any proxy in any region because ticket keys are shared, which matters: a new connection has a new source port and usually hashes to a different proxy. A full handshake signs with an ECDSA key held only in memory.
    5. The proxy matches a route on host and path, then checks the client’s rate limit. Routes are compiled into a lookup by host and then longest path prefix: microseconds per request. This route says cluster orders, split 95 / 5 between v1 and v2 for a canary. Rate limiting is a boundary feature: local token buckets first, the shared store only for limits that must hold across proxies, fail open on a timeout.
    6. The proxy picks a backend with power of two choices and sends the request on a pooled connection. Two random healthy endpoints of orders-v1; the one with fewer requests in flight from this proxy wins, adjusted by weight and slow start. The upstream connection is a long-lived HTTP/2 connection over mTLS, so there is no handshake per request. The proxy adds a request id, the client address and the remaining deadline.
    7. The response goes from the proxy straight out through the edge router to the client, skipping the L4 tier. The proxy replies with the VIP as the source address and routes it out directly. Responses are ~10× the bytes of requests, so direct server return keeps ~400 Gbps off the L4 tier globally. The cost: L4 never sees the return path, so it cannot track TCP state and expires flows by idle timeout.
    8. The proxy records the request asynchronously for logs and metrics. Counters per route and per backend live in memory and are flushed every few seconds; access logs are batched. Per-backend latency and error rates are also what canary analysis compares, so they must be broken down by version.
  2. An L4 box dies at peak. The failure the L4 design exists for. One of 8 boxes disappears with ~400 k flows on it, the routers rehash, and every flow must still reach the proxy that holds its TCP state.
    1. An L4 box loses power; the edge routers notice in under a second and drop it from the ECMP group. BFD on the box’s BGP session misses three 100 ms hellos, the route through it is withdrawn, and the routers hash over 7 next hops instead of 8. Routers with resilient hashing move only the dead box’s flows; plain modulo hashing moves most flows between the surviving boxes too.
    2. Packets of established flows start arriving at L4 boxes that have never seen them. These are mid-connection packets, not SYNs, and the receiving box has no connection-table entry for them. A design that picked proxies by local round robin, or from a table each box built its own way, would now send them to a proxy with no TCP state, and the client would get a reset.
    3. The new box computes the same lookup and sends the flow to the same proxy as before. Every box built its table from the same versioned proxy list, and the lookup depends only on the 5-tuple, so l4-05 gets the answer l4-03 would have given. The proxy carries on with the connection without noticing anything, and l4-05 records the flow so later packets hit its table.
    4. Flows whose proxy changed since they started get a second chance. If a proxy was added or drained during the flow, the current table version can point at a proxy with no state for it. That proxy forwards non-SYN packets it has no socket for to the owner under the previous table version, the same idea as the second-chance step in GitHub’s GLB Director. Only flows hit by an L4 move and a proxy-list change at once are at risk, and second chance saves most of them.
    5. The surviving boxes absorb the load, and a replacement joins only after loading the current table. Even at failover peak, 17 M pps over 7 boxes is 2.4 M pps each, under the 4 M planning number, which is why the region runs 8 when 5 would do. A new box fetches the proxy list and builds its table before it announces the VIP, so it never forwards with a stale view.
  3. A backend starts failing requests. One instance of a 2,000-instance service starts returning 503 in a millisecond. A host that fails fast looks lightly loaded, so balancing alone would send it more traffic; ejection has to beat that without ever emptying the pool.
    1. An instance starts answering every request with a fast 503. Its in-flight count stays near zero because it fails in a millisecond, so least-request balancing favours it: the black hole effect. Power of two choices limits the damage, since it is picked only when it is one of the two sampled, but every proxy still sends it a steady trickle of requests to fail.
    2. Each proxy ejects it after 5 consecutive failures, edge and internal alike. Passive outlier detection reacts on real traffic within a few hundred milliseconds, without waiting for a probe. Ejection lasts 30 s and doubles each time the same host is ejected again, and no proxy ejects more than 20 % of a cluster on its own. Each proxy decides independently, so a host that fails only for some callers is ejected only by them.
    3. The request that hit it is retried once on another backend, within the retry budget. A 503 returned before the request was processed is safe to retry on a different host. Retries are capped at 10 % of requests per cluster, so a whole-service failure does not double the load on whatever is left.
    4. Central health checkers confirm the failure from three zones and report the transition. 3 failed probes in a row from 2 of 3 checkers, about 6 s. Active checks matter for hosts that get little traffic, where passive detection never sees enough requests, and they decide when an ejected host may come back. Only the transition is reported, not every probe.
    5. The control plane checks the change against its fail-open guard, then pushes it. One host in 2,000 is safe. If an update would mark more than a third of a cluster unhealthy at once, the control plane holds it and pages, because a broken checker or a bad network path is likelier than a third of a fleet failing in the same second. Proxies have their own guard: below 50 % healthy they ignore health and spread over every host, which Envoy calls the panic threshold.
    6. When the instance recovers, probes pass and it returns with slow start. It needs 3 consecutive passes, then its weight ramps from 10 % to 100 % over 60 s. Returning it at full weight hands it a full share of traffic with cold caches at once, which is how a flapping host keeps flapping.
  4. A canary deploy with slow start and draining. A team rolls v2 out to 2,000 instances without a single failed request. Endpoint changes take the fast path through the control plane; the traffic split is a route change and takes the slow, staged path.
    1. New v2 instances start and appear in the service registry, not yet ready. Membership comes from the scheduler: the instance exists, with its address and its labels (version v2). The control plane watches the registry, but sends an endpoint to proxies only once the health checkers say it is ready, so a slow-booting process never receives traffic.
    2. Probes pass, and the control plane pushes the new endpoints to the proxies that use them. An incremental update of ~100 B per endpoint goes only to proxies that route to orders, and reaches them in about a second, p99 under 5 s. Each proxy ramps a new endpoint’s weight over 60 s, so JIT warm-up and empty caches do not meet a full share of traffic.
    3. The deploy tool commits a route change that sends 5 % of traffic to v2. The split is a route, owned by the team and reviewed like code. The control plane validates the new version before any proxy sees it: the schema, that the referenced clusters exist and have healthy endpoints, and that no route claims another team’s host.
    4. The control plane rolls the route out in stages: canary proxies, one zone, one region, everywhere. Each stage waits a few minutes and compares 5xx and latency on proxies running version 319 against those still on 318; a regression stops the rollout and re-pushes 318. Proxies ACK a version they loaded or NACK one they rejected, so the control plane knows exactly which version every proxy runs.
    5. Canary analysis compares v2 with v1, and the split steps up to 25, 50 and 100 %. Per-backend metrics from the proxies give v2’s error rate and latency against v1 on the same traffic mix. Each step is another route version, so stepping back is exactly as fast as stepping forward.
    6. The v1 instances are marked draining, and proxies stop sending them new requests. Draining is an endpoint state, so it takes the fast path. Proxies send no new requests to a draining host, send HTTP/2 GOAWAY or Connection: close on its idle pooled connections, and let requests already in flight finish normally.
    7. After the drain window, the scheduler stops the old instances. The window is config propagation p99 plus the longest request: 5 s + 30 s = 35 s. Stopping sooner kills requests on proxies that have not yet heard about the drain; the instance’s SIGTERM handler, which fails readiness and finishes in-flight work, is the second line of defence.
  5. A whole region fails at peak. One of four regions goes dark with 1.25 M requests a second and 2.5 M connections on it. Anycast moves new packets within seconds; whether users notice depends on headroom in the other three and on whether TLS resumes.
    1. The global controller’s probes to the region fail from most vantage points. Probes run every 5 s from ~40 outside locations against the region’s own unicast probe address, through L4, L7 and a canary backend. Acting needs a majority of vantage points failing for 3 rounds, about 15 s, so one ISP’s routing problem does not move a region.
    2. The region’s anycast routes disappear from the internet. If the region is dark, its BGP sessions drop and the routes vanish on their own; if it is up but broken, the controller tells its edge routers to withdraw. Either way ISPs converge on the next-nearest region in seconds to a minute, with no DNS change and no tail of resolvers holding an old answer.
    3. Clients reconnect, and their packets now reach routers and L4 boxes in the surviving regions. The 2.5 M open connections are gone, since their TCP state lived on the failed region’s proxies. Clients see a reset or a timeout and reconnect, roughly 2.5 M over 30 s: ~80 k new connections a second on top of normal, ~28 k per surviving region.
    4. Most TLS sessions resume, because ticket keys are shared across regions. A ticket issued in eu-west decrypts in us-east, so a reconnect costs a key exchange, not a full handshake with a signature. With per-region ticket keys every reconnect would be a full handshake: fine with ECDSA at ~0.1 ms each, most of a core per proxy with RSA-2048.
    5. Each surviving region now carries 1.7 M requests a second, which it was sized for. N+1 means every region already had a third of headroom at peak: proxies go from ~31 k to ~42 k requests a second each, under the 50 k planning number. If a region were short, proxies would shed the lowest-priority routes first (batch APIs, prefetch) rather than let latency climb for everything.
    6. Sticky sessions pinned to the failed region are lost; stateless sessions carry on. An affinity cookie naming a backend in eu-west now points at nothing, so the proxy picks a new backend and rewrites the cookie. Users notice only where a service kept session state in that backend’s memory, which is the argument for sessions in a shared store or a signed token. WebSocket clients reconnect with jitter.
    7. When the region recovers, the controller brings it back gradually. Re-announcing everything at once would hit a cold region with a flood. The controller first sends a small share through DNS weights to the region’s unicast address, then announces the anycast prefixes with AS-path prepending, then without it, watching errors at each step. Failover is fast on purpose; failback is slow on purpose.

Deep dives

The L4 tier: ECMP, consistent hashing, DSR

Eight L4 boxes with no shared state, and routers that reshuffle flows whenever a box changes. How does every packet of a connection still reach the same proxy?

The routers spread packets over the L4 boxes with ECMP, hashing the 5-tuple over the current set of next hops. When a box fails or joins, many routers simply rehash over the new count, and packets of established flows start arriving at boxes that have never seen them. A region has ~3.3 M flows at failover peak; if an L4 change broke even a tenth of them, every L4 deploy would reset 330 k connections.

The insight is to make the choice of proxy a pure function of the packet and a shared list, not of the box. If every L4 box computes the same answer for the same 5-tuple, it does not matter which box a packet lands on. A local connection table then becomes an optimisation that keeps flows stable while the proxy list itself changes, not the source of truth.

  • Stateless L4 fleet: Maglev-style lookup table, local connection table, DSR chosen
  • ECMP straight to the L7 proxies situational: a dozen or so proxies that rarely change, or routers with resilient hashing in front of a small, stable fleet
  • Active-standby pair with a floating IP (VRRP) rejected
  • L4 boxes that replicate their connection tables to each other rejected

The answer: ECMP from the edge routers to 8 L4 boxes per region, each forwarding with XDP or DPDK. For every VIP each box builds a lookup table of 65,537 entries with Maglev hashing from the same versioned list of L7 proxies, pushed by the control plane: each of the 40 proxies takes about 1/40 of the entries, and removing one hands its entries to the others while leaving everyone else’s in place. A packet first checks a local connection table (5-tuple → proxy, 5 minute idle timeout); on a miss the box indexes the lookup table by hash(5-tuple), records the result, and encapsulates the packet to the proxy, which replies directly to the client. For the rare flow hit by an L4 move and a proxy change at once, a proxy that receives a non-SYN packet it has no socket for forwards it to the owner under the previous table version. This is the design in Google’s Maglev paper (NSDI 2016); Facebook’s Katran is an open-source XDP balancer built on the same ideas, and GitHub’s GLB Director uses a similar second-chance step.

Why not let the routers do consistent hashing and skip the L4 tier?

Some routers offer resilient hashing, which keeps flows on the surviving next hops when one is removed, and for a small, stable fleet that is enough. It falls short here on three counts: ECMP groups hold tens of next hops while we have 40 proxies per region that we deploy weekly, adding a next hop still remaps flows, and the router gives us no draining and no second chance. The L4 tier is where we put that logic in software we deploy ourselves.

Why not stop at L4 and let the backends terminate TLS?

Because most of what the system must do needs the request. Host and path routing, weighted canaries, retries, per-key rate limits and per-request metrics all need parsed HTTP. HTTP/2 makes L4-only balancing actively bad: one connection carries thousands of requests, so balancing connections can leave one backend with ten times the load of another. And TLS keys would live on 50 k backends instead of 160 proxies. L4-only is still right for non-HTTP protocols such as databases or game traffic, which the same L4 tier can serve.

A proxy needs to be drained for a deploy. Walk me through it.

The control plane publishes a new proxy list without it; its table entries go to other proxies, but existing flows keep reaching it through the connection tables, and second chance covers flows that move between L4 boxes. The proxy sends GOAWAY on HTTP/2 and Connection: close on HTTP/1.1, so clients reconnect and land elsewhere. After the drain window, a few minutes for most traffic and longer for WebSockets, it stops. New connections stop reaching it within seconds, once every L4 box has the new list.

HTTP/3 runs over UDP. Does any of this change?

Hashing per 5-tuple still works, but QUIC lets a client change its address mid-connection, so a phone moving from Wi-Fi to cellular would hash to a different proxy. The fix is to route on the QUIC connection ID instead: the proxy encodes its own identity in the connection ID it chooses, and the L4 tier decodes it, which is what the IETF QUIC-LB work describes. That needs QUIC awareness at L4 but keeps the tier stateless.

How would you know the L4 tier is breaking connections?

Count it at the proxies, the only place that can tell: non-SYN packets for unknown flows, split into those rescued by second chance and those answered with a reset. Graph both against L4 and proxy-list changes. Replacing an L4 box should show a bump in rescued packets and almost no resets; resets that track proxy deploys mean the drain or the table versioning is wrong.

Choosing a backend at L7

Each of 160 proxies picks a backend for every request. Why not just send each request to the backend with the fewest connections?

Least connections works beautifully behind a single balancer, because that balancer sees every request. Here 160 edge proxies and ~5 k per-host proxies each see only their own traffic, so each one’s idea of least loaded is a local guess. When a backend restarts it has zero requests in flight from every proxy’s point of view, and they all pick it at once: one cold instance gets the next request from 160 proxies, slows down or fails, and the herd moves on to the next one.

The fix keeps the load signal but adds randomness: sample two backends and take the better one. That avoids the worst choice almost always while making it very unlikely that many proxies pick the same backend at the same moment. The other half is time: a new backend should ramp up over a minute rather than receive its full share cold.

  • Power of two choices on requests in flight, weighted, with slow start chosen
  • Weighted round robin situational: small clusters of identical backends with short, uniform requests, or as the fallback when no load signal is available
  • Least connections over the whole cluster rejected
  • Consistent hashing on a key (ring hash or Maglev at L7) situational: a route that needs locality: a cache tier, per-user state, WebSocket sessions

The answer: The default for every cluster is least outstanding requests with power of two choices, which is what Envoy’s least-request balancer does. Each proxy tracks requests in flight per endpoint; for each request it samples two healthy endpoints at random, divides each count by the endpoint’s weight, and takes the lower. Weights come from config (instance size, canary share), and a new endpoint enters with slow start, its effective weight ramping from 10 % to 100 % over 60 s. Outlier detection runs beside it, so a backend that fails in a millisecond, and therefore looks idle, is ejected instead of rewarded. Routes that need locality switch to consistent hashing on a header or cookie with bounded load, route by route, never globally.

Why two choices, and not three or ten?

Going from one random choice to two gives most of the benefit: the busiest backend’s excess drops from about log n / log log n to log log n. Each extra sample adds less and makes proxies more likely to agree on the same best backend, which brings a little of the herding back. Two is the standard default, and I would only change it with measurements.

A backend fails every request in 1 ms. What does your balancer do?

On its own it would favour that backend, because a host that fails instantly has fewer requests in flight: the black hole effect. Power of two choices limits how often it is picked, and outlier detection ejects it after 5 consecutive 5xx, well under a second at our request rates. Counting errors as load, or scoring by the latency of successful requests only, is the belt-and-braces version.

Why do you need slow start if power of two choices already avoids herding?

P2C stops proxies stampeding a new backend in the same instant, but once it is in the pool it still gets a full share, and a fresh JVM with an empty cache cannot serve a full share at normal latency. A 60 s ramp lets it warm up on a fraction of the traffic. Without it, a deploy of a few hundred instances shows a latency spike on every wave.

Every proxy talks to all 2,000 backends of a big service. Is that a problem?

Yes, at this size: 160 edge proxies plus thousands of host proxies each holding connections to every backend means tens of thousands of mostly idle connections per backend, and every proxy tracking every host. Deterministic subsetting fixes it: each proxy uses a stable subset of, say, 50 backends, chosen so every backend sits in about the same number of subsets. The cost is coarser balancing per proxy, which P2C tolerates well.

How would you check that the balancing is actually even?

Plot requests in flight and CPU per backend within a cluster, and track the ratio of the busiest backend to the median; with P2C it should stay close to 1. Compare per-backend p99 latency too: one host consistently slower than its peers points at a hardware or noisy-neighbour problem that the balancer is quietly hiding. Look at edge and host proxies separately, since they see different traffic.

Health checks that cannot empty the pool

Your health checker has a bug and reports every backend of a service as down. What happens?

Health checking looks like the safe part of a load balancer, but it is one of the few places where a single bug takes everything down at once, because every proxy obeys it. And the obvious design does not scale: 160 edge proxies probing 50 k backends every second is 8 M probes a second, 160 per backend per second, before counting thousands of host proxies.

Two observations shape the answer. Real traffic is the best health check there is, so passive detection in each proxy should do most of the work, with active probes for hosts that get little traffic and for deciding when a host comes back. And when health data says most of a cluster died at the same moment, the data is more likely wrong than the cluster, so the system should fail open instead of obeying it.

  • Central active probes, passive outlier detection, fail-open guards chosen
  • Every proxy actively probes every backend rejected
  • Passive outlier detection only situational: clusters where every host gets steady traffic, such as a large internal service
  • Deep health checks that test every dependency rejected: for alerting, never for routing

The answer: Health checkers in each zone probe every backend’s readiness endpoint every 2 s, three checkers per backend, 75 k probes a second in total; a host turns unhealthy after 3 consecutive failures seen by 2 of 3 checkers, about 6 s, and healthy again after 3 passes. Readiness means this process can serve, not that every dependency is perfect. Inside every proxy, outlier detection ejects a host after 5 consecutive 5xx or connect failures for 30 s, doubling on repeats, never more than 20 % of a cluster per proxy. Two fail-open guards: the control plane holds any update that would mark more than a third of a cluster unhealthy at once and pages a human, and each proxy ignores health entirely when fewer than 50 % of a cluster are healthy (Envoy’s panic threshold, whose default is also 50 %). Membership from the scheduler and health from the checkers stay separate signals, so a registry hiccup cannot look like a health failure.

The checkers in one zone lose their network to the others. What happens?

Each backend is probed from three zones and a transition needs 2 of 3 checkers to agree, so one zone’s checkers seeing failures change nothing on their own. If the partition really cuts a zone’s backends off from the other zones, it probably cuts them off from proxies there too, and passive detection in those proxies reacts within a second. The two signals together handle partial partitions better than either alone.

Panic mode sends traffic to dead backends. Isn’t that worse?

When 60 % of a cluster is really dead, spreading over everything fails 60 % of requests, while obeying health would pile all the traffic onto the remaining 40 % and likely melt them too. When the health data is wrong, which is the more common reason most of a cluster looks down, panic mode means no outage at all. Retries on a different host recover part of the real-failure case.

The health checking service itself goes down. What do proxies do?

Nothing changes for traffic: proxies keep the last health state they were sent, and outlier detection keeps working on real requests. New endpoints cannot become ready until the checkers are back, so deploys and scale-ups pause, and the control plane alerts once health data is more than a minute stale. That is the right failure: the system freezes in a known-good state instead of guessing.

What should the readiness endpoint actually check?

That this process can serve a typical request now: it has finished booting, its thread pools are not saturated, and it can get a connection from its own datastore pool. Not whether every downstream dependency is healthy, since one shared dependency failing would fail every host together. It must also be cheap, because it is called 1.5 times a second per host, forever.

How do you tune outlier detection so it does not eject healthy hosts during a real incident?

Rely on the max-ejection cap: if every host returns errors, the problem is a dependency, not the hosts, and the cap stops proxies emptying the pool chasing it. Watch ejections per cluster, because many hosts ejected at once is an incident signal, not a balancing decision. For a new service, start outlier detection in log-only mode and compare its would-eject decisions with real failures before enforcing it.

The control plane and safe config pushes

Five thousand proxies need routes and endpoint lists that change every second, and one bad config can take every service down. How does config reach them safely?

Two kinds of config change at very different speeds and risks. Endpoints change constantly: deploys and autoscaling make about 100 k endpoint changes a day, tens a second during a big deploy, and a proxy that hears late sends traffic to instances that are already gone. Routes, listeners and certificates change a few hundred times a day, and one bad route (a typo in a host match, a weight of zero) can blackhole a service on every proxy at once. A full snapshot is ~6 MB, so pushing it to 5 k proxies on every change would be 30 GB.

So the control plane has to be fast for endpoints and careful for routes, incremental in what it sends, and never on the request path. The data plane, in turn, must be allowed to reject config, and must keep working on the last version it accepted if the control plane disappears.

  • xDS-style streaming: incremental, versioned, ACK/NACK, staged rollout for routes chosen
  • Proxies poll a versioned snapshot from object storage situational: route config that changes rarely, or as the bootstrap and fallback path when the streaming control plane is down
  • Every proxy watches the service registry directly rejected
  • Config baked into the proxy image and deployed rejected

The answer: A stateless control plane with replicas in every region; each proxy holds one gRPC stream to a nearby replica. It watches the registry and the health checkers, reads routes from the config store, and compiles per-proxy-type snapshots of listeners, routes, clusters, endpoints and secrets, the resource types of Envoy’s xDS APIs. Proxies subscribe only to the clusters their routes reference and receive incremental updates, each with a version; a proxy ACKs a version it loaded and NACKs one it rejected, so the control plane always knows what every proxy runs. Endpoints take the fast path, under 5 s at p99. Routes take the careful path: validated (schema, referenced clusters exist and have healthy endpoints, no host claimed by two teams), loaded by canary proxies serving synthetic traffic, then rolled out to 2 % of proxies, one zone, one region and everywhere over ~30 minutes, stopping and reverting on a rise in 5xx or latency. Proxies write the last accepted config to local disk and keep serving it if the control plane is gone, including across restarts.

A control plane bug computes an empty endpoint list for a big service. What stops it?

Two guards. The control plane refuses to push any change that removes more than a third of a cluster’s endpoints at once, holds it and pages; scale-downs that large are rare enough to need a human. And the proxies apply the panic threshold, so even an empty or mostly unhealthy list that got through would leave them spreading over the hosts they had. The worst case becomes stale config, not no config.

The whole control plane is down for an hour. What breaks?

Traffic keeps flowing everywhere on the last good config, and outlier detection keeps routing around failing hosts. What stops is change: new instances never receive traffic, so deploys and autoscaling stall, and draining instances stay in proxies’ lists, so the scheduler must not kill them until the control plane confirms the drain. An hour is survivable; a day becomes a capacity problem, which is why replicas run in every region.

A team pushes a route that is valid but wrong, sending /v1/orders to the payments cluster. How fast can you undo it?

The staged rollout catches most of these at the 2 % stage, because 4xx and 5xx jump on those proxies, and reverts automatically. If one slips through, rollback is re-pushing the previous version on the fast path: seconds, not 30 minutes. Undo is always fast; only forward changes are slow.

How do 5 k streams stay balanced across control plane replicas?

Proxies reach the replicas through ordinary load balancing, and each replica caps its streams and asks the excess to reconnect elsewhere. A replica deploy closes its streams gradually, a few percent a minute, because every reconnect asks for the full state and 5 k at once is a thundering herd. Proxies reconnect with jitter for the same reason.

How do you know config is propagating properly?

Per version: the fraction of proxies that ACKed it, how long that took, and every NACK with its error. Alert when any proxy NACKs a version, when any proxy is more than a few versions behind, and when endpoint propagation p99 passes 5 s. The same numbers answer the team asking why their change is not live yet.

TLS termination at scale

You terminate TLS on 160 proxies in four regions. Where do the private keys live, and what happens to TLS during a failover?

TLS is the most expensive thing an edge proxy does per connection and the most sensitive thing it holds. About 170 k connections open every second; a full handshake costs ~1 ms of CPU with RSA-2048 or ~0.1 ms with ECDSA P-256, plus a round trip, so the resumption rate decides both CPU and user latency. Every proxy holds private keys for a few hundred hostnames, and a stolen key lets anyone impersonate the company until it is revoked and replaced.

Two observations shape the design. Behind a 5-tuple hash a client’s next connection usually lands on a different proxy, so any per-proxy session cache misses; resumption has to work on every proxy, which means shared ticket keys. And a private key only needs to be where the signing happens, which does not have to be the box that terminates the connection.

  • Keys pushed to proxies in memory, ECDSA first, shared rotating ticket keys chosen
  • Keyless signing: keys stay in an HSM-backed key service situational: edge sites in facilities we do not fully control, or customer certificates whose owners will not hand over keys
  • Per-proxy session caches only rejected
  • Pass TLS through to the backends situational: a compliance rule that the balancer must never see plaintext, or non-HTTP protocols inside TLS

The answer: Certificates are issued automatically, public ones through ACME from a public CA and internal ones from our own CA, and the key service delivers them to proxies over mTLS, held in memory only and rotated without restarts. Serve ECDSA P-256 certificates to clients that support them and RSA-2048 to the rest, so the common full handshake costs ~0.1 ms of CPU. Resumption uses TLS 1.3 session tickets encrypted with keys shared by every proxy in every region, rotated every 12 hours with the previous key kept for decryption, so a ticket issued anywhere resumes anywhere, including during a regional failover. 0-RTT data is accepted only for GET, because it can be replayed. For edge sites outside our own data centres, the key service keeps the private key and signs handshakes remotely, the model Cloudflare published as Keyless SSL.

Shared ticket keys sound dangerous. Why accept that?

A ticket key can decrypt the resumption secrets of every session it protected, so stealing it weakens forward secrecy for sessions in its window. We bound the window by rotating every 12 hours and keeping keys only in memory, delivered over mTLS and never logged. The alternative, per-proxy caches, gives a resumption rate of a few percent behind 5-tuple hashing, which costs every user a round trip and turns failovers into handshake storms. It is a deliberate trade with a bounded window.

A region fails and 2.5 M clients reconnect within 30 seconds. Does TLS hold up?

That is ~80 k extra connections a second over three regions, about 700 per proxy per second. With shared tickets most of them resume, costing a key exchange and no signature. Even if none resumed, ECDSA full handshakes at ~0.1 ms each are under a tenth of a core per proxy; with RSA-2048 only, it would be most of a core per proxy, survivable but the reason ECDSA is the default.

How do you rotate a certificate on 160 proxies without dropping connections?

The key service pushes the new certificate and key alongside the old ones; proxies use the new pair for new handshakes at once, and existing connections are unaffected because they already have their session keys. Renew at two thirds of the certificate’s lifetime, so a failed renewal has weeks to be fixed, and alert on any certificate within 14 days of expiry on any proxy. Expired certificates are one of the most common self-inflicted edge outages.

Do you re-encrypt between the proxy and the backend?

Yes, with mTLS on long-lived pooled connections, so the cost is one handshake per connection, not per request. A compromised network segment inside the data centre then sees nothing, and backends can authorise callers by the proxy’s identity. Internal certificates are short-lived, a day or less, issued by our CA and delivered the same way as edge certificates.

Steering users and losing a region

How do users reach the nearest healthy region, and what happens when a whole region fails at peak?

Each region carries ~1.25 M requests a second and ~2.5 M connections at peak. When one fails, three things decide what users see: how fast new packets go elsewhere, whether the other regions can take the load, and what state was pinned to the failed region. DNS failover is bounded below by detection plus TTL plus resolvers that ignore TTLs, often minutes for a tail of users; withdrawing an anycast prefix over BGP converges in seconds to a minute.

The bigger risk is not the failed region but the survivors. Without headroom, the failover overloads them, they start failing health checks, and one regional outage becomes a global one. So capacity planning is part of the failover design, and so is a controller that refuses to make things worse.

  • Anycast front door, DNS for deliberate moves, N+1 capacity chosen
  • Latency-based DNS with health checks and 30 s TTLs situational: no own IP space or BGP, as in most cloud deployments, or per-region hostnames for clients that must reach one specific region
  • Client-side steering from a list of regional endpoints situational: our own mobile apps, as a second layer that retries another region after a failed connection
  • Active-passive with a standby region rejected

The answer: Every region announces the same anycast prefixes, so BGP takes users to the nearest region and a dead region’s routes vanish within seconds. A global traffic controller probes each region from ~40 outside vantage points and reads its load; it withdraws or prepends a region’s announcements when the region is unhealthy or overloaded, never withdraws a second region without a human, and never fails back faster than it failed over. DNS keeps the jobs anycast cannot do: weighted moves through per-region addresses for draining or canarying a region, and sending accounts to a home region for residency. Each region is provisioned for 1.7 M requests a second, a third above its normal peak, and proxies shed the lowest-priority routes first if that is not enough. Sticky sessions are a per-route option and are lost when their region fails, so services keep session state in a shared store or a signed token.

Anycast routes by BGP, not latency. Isn’t that sometimes badly wrong?

Yes: BGP prefers the shortest AS path, which can send a user to a farther region when a peering is odd. We measure it with client-side timing from our apps and real-user monitoring, and fix the worst cases with BGP communities or prepending towards specific ISPs. With four regions on well-connected networks the share sent to the wrong region is small, and the failover speed is worth it.

BGP flaps and a user’s packets move to another region mid-connection. What happens?

The other region’s L4 boxes send the packets to a proxy that has no state for the connection, which resets it; the client reconnects and resumes TLS from its ticket, costing a round trip and a retried request. WebSocket clients reconnect with jitter. Flaps between distant regions are rare, and when one ISP makes them frequent, that is a peering problem to fix, not a balancer one.

Why not run each region at 90 % and save the headroom?

Because losing a region at peak would then put 120 % of capacity on each survivor. They queue, time out and fail health checks, and the controller is tempted to move traffic again, which is how one regional outage becomes four. A third of headroom is the cost of surviving any single region at peak; off peak, the same headroom absorbs growth and lets us take a region down for maintenance.

A user’s data may live only in eu-west for residency, and eu-west fails. Now what?

Residency wins over availability for that data. Their requests can reach another region’s proxies, but the backends there cannot serve that data, so they get a clear error, or a read-only view if a compliant replica exists elsewhere in the EU. The controller must not move them silently. That is a product and legal decision to make before the outage; the routing layer only has to support “this account must reach that region”.

How do you test regional failover without waiting for an outage?

Drain a region on purpose, regularly and at peak: move its traffic away with the controller over minutes and watch the survivors’ latency, errors and CPU against the plan. Then do it abruptly in a game day with a real withdrawal. Failover that is not exercised every quarter does not work when it is needed, and the drill finds the services that quietly lost their headroom.

The theory behind it

  • Consistent hashing on a hash ring: an interactive playground. Add and remove servers on a consistent hashing ring with 300 keys, change the virtual nodes, and compare how many keys move with hash mod N.
  • Load balancing algorithms compared: an interactive playground. Route the same traffic with round robin, random, least connections and power of two choices, then add a slow server and watch the queues and p99 latency.
  • Health checks: an interactive playground. Break one of five servers so it fails fast, and see least connections funnel most traffic to it, shallow health checks miss it, and deep checks or outlier detection take it out.
  • DNS failover: an interactive playground. Fail one of two regions and compare DNS failover with long and short TTLs, multiple addresses with client retries and anycast, with and without resolvers that ignore the TTL.
  • Load balancing: Layer 4 vs layer 7 load balancers, routing algorithms, health checks, sticky sessions, global load balancing and how to talk about them in a system design interview.
  • Global traffic management, DNS and anycast: How users reach the nearest healthy region: DNS resolution and TTLs, GeoDNS and latency-based routing, anycast, global load balancers, health checks and failover, the limits of DNS-based failover, and steering traffic during incidents and migrations.
  • Consistent hashing: Why hash mod N breaks when nodes change, how a hash ring with virtual nodes fixes it, replication on the ring, rendezvous hashing and jump hash, fixed slots, and handling hot keys.

Related

Report an issue

Something wrong with this question? A number that does not add up, a step whose answer key looks off, a typo. It goes straight to the author with the page you are on.

Watch a strong candidate answer thisTurn by turn, with the design drawn as they talkWalk through →Mock interview