Skip to main content
System Design · lesson 2 of 11
Open the System Design page →
System Design beginner Lesson 2 of 11

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

system-design load-balancing python

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.

Frequently Asked Questions

If least connections is smarter, why does round robin still get used?
Round robin is simpler, needs no shared state about server load, and works fine when every server has roughly equal capacity and every request costs roughly the same. Least connections needs the balancer to track in-flight request counts, which is more bookkeeping for a benefit that only shows up when load is genuinely uneven.
What does 'active connections' actually mean for an HTTP load balancer?
Typically the number of requests currently being processed by that backend that haven't returned a response yet. It's a proxy for how busy a server is right now, which is a better signal than 'how many requests have I sent it historically' (which round robin implicitly optimizes for).
Does least connections ever pick badly?
Yes — it doesn't know how expensive the *next* request will be, only how many are currently in flight. Two servers with one connection each could be running very different workloads; least connections can't see that difference, only the count.
What's weighted round robin, and how is it different from least connections?
Weighted round robin still ignores real-time load, but gives bigger servers a larger fixed share of the rotation — a server with weight 3 appears three times as often in the cycle as one with weight 1. It fixes round robin's 'every server is equal' assumption without needing to track live connection counts the way least connections does.