Load Balancer Algorithms: Decoded
How traffic is distributed across homogeneous and heterogeneous backends, and when each routing strategy fails.
The Traffic Dispatcher
In the world of distributed systems, no single server can withstand the brunt of internet-scale traffic. To scale horizontally, we introduce a fleet of servers and place a Load Balancer (LB) at the gateway.
The load balancer's task is simple to state but complex to execute: distribute incoming requests across a pool of backend servers in a way that maximizes throughput, minimizes latency, and prevents any single server from becoming a bottleneck.
Under the hood, the load balancer must make routing decisions in microseconds. The strategy it uses is defined by its Load Balancing Algorithm. These algorithms are generally divided into two classes:
- Static Algorithms: Hardcoded rules based purely on server identity, IP addresses, or weights, without querying the actual live performance state of the backends.
- Dynamic Algorithms: Stateful decisions based on real-time feedback loops, tracking active connection counts, latencies, or CPU load.
Here is the deep dive into the four foundational routing strategies, their mathematical formulations, and how to implement them.
1. Weighted Round Robin
Standard Round Robin is the simplest load balancing algorithm: it distributes requests sequentially () down the line. However, it operates under a flawed assumption: that all backend servers are identical in hardware capacity (homogeneous).
In real production environments, you often have a mix of instance types. If you route traffic equally between a 2-core VPS and a 64-core bare-metal machine, you will underutilize the giant and crash the small instance.
Weighted Round Robin solves this by assigning an integer weight to each server representing its capacity.

Mathematical Formulation
Implementation: Smooth Weighted Round Robin
A naive implementation might send requests to , then requests to . This creates "bursty" traffic patterns, where is bombarded with back-to-back requests while others sit idle.
To prevent this, production balancers like Nginx use a Smooth Weighted Round Robin algorithm, which interleaves the requests.
Java Implementation
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
class Server {
String id;
int weight;
int currentWeight;
Server(String id, int weight) {
this.id = id;
this.weight = weight;
this.currentWeight = 0;
}
}
public class SmoothWeightedRoundRobin {
private final List<Server> servers = new ArrayList<>();
public SmoothWeightedRoundRobin(Map<String, Integer> serverWeights) {
serverWeights.forEach((id, weight) -> servers.add(new Server(id, weight)));
}
public synchronized String next() {
int totalWeight = 0;
Server selectedServer = null;
// Step 1: Add effective weight to currentWeight of each server
for (Server server : servers) {
server.currentWeight += server.weight;
totalWeight += server.weight;
// Step 2: Find the server with the maximum currentWeight
if (selectedServer == null || server.currentWeight > selectedServer.currentWeight) {
selectedServer = server;
}
}
if (selectedServer == null) {
throw new IllegalStateException("No backend servers available.");
}
// Step 3: Decrement the selected server's currentWeight by totalWeight
selectedServer.currentWeight -= totalWeight;
return selectedServer.id;
}
}TypeScript Implementation
interface Server {
id: string;
weight: number;
currentWeight: number;
}
class SmoothWeightedRoundRobin {
private servers: Server[];
constructor(serverWeights: { [id: string]: number }) {
this.servers = Object.entries(serverWeights).map(([id, weight]) => ({
id,
weight,
currentWeight: 0,
}));
}
public next(): string {
let totalWeight = 0;
let selectedServer: Server | null = null;
// Step 1: Add effective weight to currentWeight of each server
for (const server of this.servers) {
server.currentWeight += server.weight;
totalWeight += server.weight;
// Step 2: Find the server with the maximum currentWeight
if (!selectedServer || server.currentWeight > selectedServer.currentWeight) {
selectedServer = server;
}
}
if (!selectedServer) {
throw new Error("No backend servers available.");
}
// Step 3: Decrement the selected server's currentWeight by totalWeight
selectedServer.currentWeight -= totalWeight;
return selectedServer.id;
}
}
// Example usage:
// Servers: A (w=4), B (w=2), C (w=1)
const balancer = new SmoothWeightedRoundRobin({ A: 4, B: 2, C: 1 });
const sequence = Array.from({ length: 7 }, () => balancer.next());
console.log(sequence); // Output: [ 'A', 'B', 'A', 'C', 'A', 'B', 'A' ] (Interleaved and smooth!)When it Fails
Weighted Round Robin is ideal for state-free web applications with homogeneous query complexities. It fails when request processing times are highly variable. If Server A receives three lightweight static asset requests and Server B receives three heavy database-reporting queries, Server B will lock up, even if their weights are identical.
2. Least Connections
To handle workloads where requests take varying times to complete, the load balancer must become dynamic. It needs to track active state.
The Least Connections algorithm routes incoming requests to the server with the fewest active transaction channels at that exact moment.

Mathematical Formulation
Implementation
Tracking active connections requires the load balancer to intercept both the request and the response lifecycle to increment and decrement counter states.
Java Implementation
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.List;
public class LeastConnectionsBalancer {
private final Map<String, Integer> activeConnections = new ConcurrentHashMap<>();
private final List<String> servers;
public LeastConnectionsBalancer(List<String> servers) {
this.servers = servers;
for (String server : servers) {
activeConnections.put(server, 0);
}
}
public String selectServer() {
String selectedServer = servers.get(0);
int minConnections = Integer.MAX_VALUE;
for (String server : servers) {
int connections = activeConnections.getOrDefault(server, 0);
if (connections < minConnections) {
minConnections = connections;
selectedServer = server;
}
}
activeConnections.put(selectedServer, minConnections + 1);
return selectedServer;
}
public void releaseServer(String serverId) {
activeConnections.computeIfPresent(serverId, (k, v) -> v > 0 ? v - 1 : 0);
}
}TypeScript Implementation
class LeastConnectionsBalancer {
private activeConnections: Map<string, number> = new Map();
constructor(private servers: string[]) {
for (const server of servers) {
this.activeConnections.set(server, 0);
}
}
public selectServer(): string {
let minConnections = Infinity;
let selectedServer = this.servers[0];
for (const server of this.servers) {
const connections = this.activeConnections.get(server) || 0;
if (connections < minConnections) {
minConnections = connections;
selectedServer = server;
}
}
// Simulate opening connection
this.activeConnections.set(selectedServer, minConnections + 1);
return selectedServer;
}
public releaseServer(serverId: string): void {
const current = this.activeConnections.get(serverId) || 0;
if (current > 0) {
this.activeConnections.set(serverId, current - 1);
}
}
}Best Use Cases
- Long-Lived Connections: Ideal for WebSockets, chat servers, SSH tunnels, or database connection pools where sessions stay open for minutes or hours.
- Varying Workloads: Prevents server starvation when requests have highly variable computational complexity.
When it Fails
Least Connections assumes all servers process requests at the same speed. If you have an instance pool with a slow server (e.g. 1 CPU core) and a fast server (e.g. 16 CPU cores), the slow server will queue up requests because it cannot flush them out fast enough. Despite being overloaded, its connection count might drop slower, or worse—if it crashes and rejects requests instantly, its connection count drops to zero, causing the balancer to flood it with even more traffic (the Dead Server Black Hole trap).
3. IP Hash
There are times when a client's state must remain bound to a specific backend server. For instance, if user session state is stored in server memory (rather than a shared Redis cache), a client must hit the same server for every subsequent API call. This is known as Session Stickiness or Sticky Sessions.
IP Hash accomplishes this by hashing the client's IP address and using it to map them to a specific backend slot.

Mathematical Formulation
The Catastrophe of Modulo: The Need for Consistent Hashing
While the modulo-based mapping is fast () and stateless, it suffers from a fatal flaw: scalability instability.
If you have servers and add a 5th server (), the divisor changes. Almost every client IP will resolve to a different index:
This causes nearly 100% of all sessions or caches to be invalidated and shifted, causing database thrashing as millions of users re-authenticate.
To solve this, modern production balancers implement Consistent Hashing using a Hash Ring.
Java Implementation
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.List;
public class ConsistentHashRing {
private final TreeMap<Long, String> ring = new TreeMap<>();
private final int virtualNodes;
public ConsistentHashRing(List<String> servers, int virtualNodes) {
this.virtualNodes = virtualNodes;
for (String server : servers) {
addServer(server);
}
}
private long hash(String key) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(key.getBytes(StandardCharsets.UTF_8));
// Read first 4 bytes as an unsigned 32-bit integer (stored in long)
return ((long) (digest[3] & 0xFF) << 24) |
((long) (digest[2] & 0xFF) << 16) |
((long) (digest[1] & 0xFF) << 8) |
((long) (digest[0] & 0xFF));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 algorithm not found", e);
}
}
public void addServer(String server) {
for (int i = 0; i < virtualNodes; i++) {
String virtualNodeKey = server + "-vn-" + i;
ring.put(hash(virtualNodeKey), server);
}
}
public void removeServer(String server) {
for (int i = 0; i < virtualNodes; i++) {
String virtualNodeKey = server + "-vn-" + i;
ring.remove(hash(virtualNodeKey));
}
}
public String getServer(String clientIp) {
if (ring.isEmpty()) {
throw new IllegalStateException("Hash ring is empty.");
}
long hashVal = hash(clientIp);
SortedMap<Long, String> tailMap = ring.tailMap(hashVal);
long nodeHash = tailMap.isEmpty() ? ring.firstKey() : tailMap.firstKey();
return ring.get(nodeHash);
}
}TypeScript Implementation
import * as crypto from "crypto";
class ConsistentHashRing {
private ring: Map<number, string> = new Map();
private sortedKeys: number[] = [];
constructor(servers: string[], private virtualNodes: number = 100) {
for (const server of servers) {
this.addServer(server);
}
}
private hash(key: string): number {
const hash = crypto.createHash("md5").update(key).digest();
// Read first 4 bytes as a 32-bit unsigned integer
return hash.readUInt32BE(0);
}
public addServer(server: string): void {
for (let i = 0; i < this.virtualNodes; i++) {
const virtualNodeKey = `${server}-vn-${i}`;
const hashVal = this.hash(virtualNodeKey);
this.ring.set(hashVal, server);
this.sortedKeys.push(hashVal);
}
this.sortedKeys.sort((a, b) => a - b);
}
public removeServer(server: string): void {
for (let i = 0; i < this.virtualNodes; i++) {
const virtualNodeKey = `${server}-vn-${i}`;
const hashVal = this.hash(virtualNodeKey);
this.ring.delete(hashVal);
}
this.sortedKeys = this.sortedKeys.filter(k => this.ring.has(k));
}
public getServer(clientIp: string): string {
if (this.ring.size === 0) {
throw new Error("Hash ring is empty.");
}
const hashVal = this.hash(clientIp);
// Find the first virtual node with a hash value >= client's hash
let idx = this.sortedKeys.findIndex(k => k >= hashVal);
// If we reach the end of the ring, wrap around to index 0
if (idx === -1) {
idx = 0;
}
return this.ring.get(this.sortedKeys[idx])!;
}
}By placing servers on a circular ring of points, adding or removing a server only invalidates a fraction () of keys, keeping the system stable.
4. Least Response Time
The most advanced dynamic balancing strategy is Least Response Time (also referred to as Least Processing Time or Least Time).
Instead of relying purely on connection counts, this algorithm monitors two parameters:
- Active concurrent connections.
- The response latency (usually calculated as a moving average of recent request-response cycles).
It routes incoming traffic to the server that yields the lowest expected latency coefficient.

Mathematical Formulation
Using strikes a perfect balance. If a server has low latency but a massive queue of connections, the product rises, diverting traffic. If a server has few connections but is extremely slow, the product also rises, protecting it.
Implementation
Here is a logic engine showing how the balancer tracks connection counts and exponentially decays response time measurements:
Java Implementation
import java.util.List;
import java.util.ArrayList;
class ServerPerformance {
String id;
int activeConnections;
double ewmaLatencyMs;
ServerPerformance(String id) {
this.id = id;
this.activeConnections = 0;
this.ewmaLatencyMs = 50.0; // Default baseline latency assumption
}
}
public class LeastResponseTimeBalancer {
private final List<ServerPerformance> servers = new ArrayList<>();
private final double alpha = 0.2; // EWMA decay factor (20% weight to new measurement)
public LeastResponseTimeBalancer(List<String> serverIds) {
for (String id : serverIds) {
servers.add(new ServerPerformance(id));
}
}
public synchronized String selectServer() {
double bestMetric = Double.MAX_VALUE;
ServerPerformance selected = null;
for (ServerPerformance server : servers) {
double metric = server.activeConnections * server.ewmaLatencyMs;
if (metric < bestMetric) {
bestMetric = metric;
selected = server;
}
}
if (selected == null) {
selected = servers.get(0);
}
selected.activeConnections++;
return selected.id;
}
public synchronized void recordResponse(String serverId, double actualLatencyMs) {
for (ServerPerformance server : servers) {
if (server.id.equals(serverId)) {
if (server.activeConnections > 0) {
server.activeConnections--;
}
// Calculate new EWMA latency
server.ewmaLatencyMs = (alpha * actualLatencyMs) + ((1 - alpha) * server.ewmaLatencyMs);
break;
}
}
}
}TypeScript Implementation
interface ServerPerformance {
id: string;
activeConnections: number;
ewmaLatencyMs: number; // Exponentially Weighted Moving Average
}
class LeastResponseTimeBalancer {
private servers: ServerPerformance[];
private alpha = 0.2; // EWMA decay factor (gives 20% weight to the latest measurement)
constructor(serverIds: string[]) {
this.servers = serverIds.map(id => ({
id,
activeConnections: 0,
ewmaLatencyMs: 50, // Start with a default baseline latency assumption
}));
}
public selectServer(): string {
let bestMetric = Infinity;
let selected: ServerPerformance | null = null;
for (const server of this.servers) {
const metric = server.activeConnections * server.ewmaLatencyMs;
if (metric < bestMetric) {
bestMetric = metric;
selected = server;
}
}
if (!selected) {
selected = this.servers[0];
}
selected.activeConnections++;
return selected.id;
}
public recordResponse(serverId: string, actualLatencyMs: number): void {
const server = this.servers.find(s => s.id === serverId);
if (server) {
// Decrement the active connection count
if (server.activeConnections > 0) {
server.activeConnections--;
}
// Calculate new EWMA latency
server.ewmaLatencyMs = (this.alpha * actualLatencyMs) + ((1 - this.alpha) * server.ewmaLatencyMs);
}
}
}Why it is the Best Defense Against Cascading Failures
In distributed systems, a failing server often slows down before crashing (due to memory leaks, database connection pool exhaustion, or CPU throttling).
- In Round Robin, traffic keeps hammering the slow server, worsening its condition.
- In Least Connections, if requests stack up on the slow server, it eventually stops receiving traffic, but only after its queue fills up.
- In Least Response Time, the moment a server's latency spikes from 20ms to 800ms, the routing metric increases by 40x, routing traffic away immediately. It isolates degraded servers in real-time.
Summary Comparison
| Algorithm | Decision State | Session Sticky | Best Used For | Primary Downfall |
|---|---|---|---|---|
| Round Robin | Static (none) | No | Homogeneous nodes, uniform workloads | Slow nodes get overloaded |
| Weighted Round Robin | Static (static weights) | No | Heterogeneous nodes, uniform workloads | Vulnerable to varying query complexity |
| Least Connections | Dynamic (connection counts) | No | Long-lived TCP/WebSocket sessions | Slow nodes build queues |
| IP Hash / Consistent Hash | Static (client IP) | Yes | Stateful applications, caching layers | Hard to balance if clients sit behind NAT/VPNs |
| Least Response Time | Dynamic (connections + latency) | No | Dynamic microservices, cloud deployments | High tracking and monitoring overhead |
By selecting the right load balancer algorithm, you align your traffic distribution with the physical capacity and logical requirements of your infrastructure.