System Design · lesson 2 of 11
Beginner
4Intermediate
4Advanced
3Also part of System Design
12- + Scaling Patterns: Real Sharding vs a Table That Only Looks Sharded from Databases
- + Killing the Primary and Measuring the Real Failover Time from MongoDB
- + ACID vs BASE: the Lost Update Neither Engine Prevents by Default from Databases
- + Distributed Correctness in Interviews from Interview Preparation
- + Events vs Requests: Why the Order Service Doesn't Wait for Email from Event-Driven Architecture
- + Scaling, Queues, and Backpressure from Cloud Interviews
- + Cache-Aside with Redis: Stale Reads, Races and Stampedes from Redis
- + Circuit Breaker: Stop Calling a Service That's Already Down from Microservices
- + API Gateway: Aggregating Calls With a Fallback from Microservices
- + Observability Synthesis: Logs, Metrics, Traces, and Alerts as One Signal from DevOps
- + Design a URL Shortener: Sequential Codes vs Real Hash Collisions from Low-Level Design
- + Availability, Regions, and the Arithmetic of Nines from Cloud Interviews
Also part of: Networking
Load Balancing: Round Robin vs Least Connections
Round robin sends every server an equal share regardless of load; least connections adapts to which servers are actually busy right now.
By SkillByExample · Published
Round robin treats every server as equally free; least connections looks at who’s actually busy before deciding.
The same 9 requests, two very different outcomes
class RoundRobinBalancer:
def __init__(self, servers):
self.servers = servers
self.next_index = 0
def pick(self):
server = self.servers[self.next_index]
self.next_index = (self.next_index + 1) % len(self.servers)
return server
class LeastConnectionsBalancer:
def __init__(self, servers):
self.active = {s: 0 for s in servers}
def pick(self):
return min(self.active, key=self.active.get)
def start(self, server):
self.active[server] += 1
def finish(self, server):
self.active[server] -= 1
servers = ["server-a", "server-b", "server-c"]
# server-a is slow: its requests never "finish" during this simulation window
rr = RoundRobinBalancer(servers)
print("Round robin, 9 requests:")
counts = {s: 0 for s in servers}
for i in range(9):
server = rr.pick()
counts[server] += 1
print(f" request {i + 1} -> {server}")
print(f" distribution: {counts}")
print()
print("Least connections, server-a already has 4 slow in-flight requests:")
lc = LeastConnectionsBalancer(servers)
for _ in range(4):
lc.start("server-a")
counts = {s: 0 for s in servers}
for i in range(9):
server = lc.pick()
counts[server] += 1
lc.start(server)
print(f" request {i + 1} -> {server} (active now: {lc.active})")
print(f" distribution: {counts}")
$ python round_robin_vs_least_connections.py
Round robin, 9 requests:
request 1 -> server-a
request 2 -> server-b
request 3 -> server-c
request 4 -> server-a
request 5 -> server-b
request 6 -> server-c
request 7 -> server-a
request 8 -> server-b
request 9 -> server-c
distribution: {'server-a': 3, 'server-b': 3, 'server-c': 3}
Least connections, server-a already has 4 slow in-flight requests:
request 1 -> server-b (active now: {'server-a': 4, 'server-b': 1, 'server-c': 0})
request 2 -> server-c (active now: {'server-a': 4, 'server-b': 1, 'server-c': 1})
request 3 -> server-b (active now: {'server-a': 4, 'server-b': 2, 'server-c': 1})
request 4 -> server-c (active now: {'server-a': 4, 'server-b': 2, 'server-c': 2})
request 5 -> server-b (active now: {'server-a': 4, 'server-b': 3, 'server-c': 2})
request 6 -> server-c (active now: {'server-a': 4, 'server-b': 3, 'server-c': 3})
request 7 -> server-b (active now: {'server-a': 4, 'server-b': 4, 'server-c': 3})
request 8 -> server-c (active now: {'server-a': 4, 'server-b': 4, 'server-c': 4})
request 9 -> server-a (active now: {'server-a': 5, 'server-b': 4, 'server-c': 4})
distribution: {'server-a': 1, 'server-b': 4, 'server-c': 4}
Round robin sends server-a a full third of the requests (3 of 9) even though it’s already
stuck with 4 slow in-flight requests it never finishes — round robin has no idea server-a is
struggling, because it never looks. Least connections sends server-a only 1 of the 9, because
every other server starts at 0 active connections and stays cheaper to pick until its own count
catches up.
Practice
1. Run round robin with 10 requests across 3 servers and see how an uneven split happens even in the "equal" strategy.
class RoundRobinBalancer:
def __init__(self, servers):
self.servers = servers
self.next_index = 0
def pick(self):
server = self.servers[self.next_index]
self.next_index = (self.next_index + 1) % len(self.servers)
return server
servers = ["server-a", "server-b", "server-c"]
rr = RoundRobinBalancer(servers)
counts = {s: 0 for s in servers}
for i in range(10):
counts[rr.pick()] += 1
print(f"10 requests across 3 servers: {counts}")
$ python uneven_split.py
10 requests across 3 servers: {'server-a': 4, 'server-b': 3, 'server-c': 3}
10 doesn’t divide evenly by 3, so whichever server happens to be next when the count runs out gets one extra request — “equal” only means equal over a window that’s a multiple of the server count.
2. Call finish() on server-a's in-flight requests and confirm least connections picks it again once it's caught up.
class LeastConnectionsBalancer:
def __init__(self, servers):
self.active = {s: 0 for s in servers}
def pick(self):
return min(self.active, key=self.active.get)
def start(self, server):
self.active[server] += 1
def finish(self, server):
self.active[server] -= 1
servers = ["server-a", "server-b", "server-c"]
lc = LeastConnectionsBalancer(servers)
# server-a starts with 3 in-flight requests
for _ in range(3):
lc.start("server-a")
s1 = lc.pick()
lc.start(s1)
print(f"request 1 -> {s1} (active: {lc.active})")
# server-a's requests finish
for _ in range(3):
lc.finish("server-a")
s2 = lc.pick()
lc.start(s2)
print(f"request 2 -> {s2} (active: {lc.active})")
$ python rebalance_after_finish.py
request 1 -> server-b (active: {'server-a': 3, 'server-b': 1, 'server-c': 0})
request 2 -> server-a (active: {'server-a': 1, 'server-b': 1, 'server-c': 0})
The moment server-a’s 3 requests finish, it drops back to 0 active connections and becomes
eligible again — least connections has no memory of a server having been slow earlier, only
what its load looks like right now.
3. Implement weighted round robin so a bigger server gets 3x the share of a small one.
class WeightedRoundRobinBalancer:
def __init__(self, weighted_servers):
# weighted_servers: list of (server, weight)
self.schedule = []
for server, weight in weighted_servers:
self.schedule.extend([server] * weight)
self.next_index = 0
def pick(self):
server = self.schedule[self.next_index]
self.next_index = (self.next_index + 1) % len(self.schedule)
return server
# server-a is a bigger machine, gets 3x the share
wrr = WeightedRoundRobinBalancer([("server-a", 3), ("server-b", 1), ("server-c", 1)])
counts = {}
for _ in range(10):
s = wrr.pick()
counts[s] = counts.get(s, 0) + 1
print(f"10 requests, server-a weighted 3x: {counts}")
$ python weighted_round_robin.py
10 requests, server-a weighted 3x: {'server-a': 6, 'server-b': 2, 'server-c': 2}
Building the rotation schedule once (["server-a", "server-a", "server-a", "server-b", "server-c"]) turns weighting into the same plain round robin as before — the weight lives in
how many times a server appears in the list, not in any new logic in pick.
4. Skip an unhealthy server entirely instead of sending it any requests.
class HealthAwareRoundRobin:
def __init__(self, servers):
self.servers = servers
self.healthy = set(servers)
self.next_index = 0
def mark_unhealthy(self, server):
self.healthy.discard(server)
def pick(self):
for _ in range(len(self.servers)):
server = self.servers[self.next_index]
self.next_index = (self.next_index + 1) % len(self.servers)
if server in self.healthy:
return server
raise RuntimeError("no healthy servers")
servers = ["server-a", "server-b", "server-c"]
balancer = HealthAwareRoundRobin(servers)
balancer.mark_unhealthy("server-b")
counts = {}
for i in range(6):
s = balancer.pick()
counts[s] = counts.get(s, 0) + 1
print(f"request {i + 1} -> {s}")
print(f"distribution: {counts}")
$ python health_aware.py
request 1 -> server-a
request 2 -> server-c
request 3 -> server-a
request 4 -> server-c
request 5 -> server-a
request 6 -> server-c
distribution: {'server-a': 3, 'server-c': 3}
server-b never appears in the output at all — the for _ in range(len(self.servers)) loop
lets pick skip past an unhealthy server without an infinite loop, as long as at least one
server is still marked healthy.