What is a routing strategy?
A routing strategy determines which backend server should handle the next incoming request. The load balancer uses the strategy pattern to decouple routing logic from the core balancing functionality.
The strategy pattern allows you to swap routing algorithms without modifying the LoadBalancer class.
Strategy pattern architecture
The LoadBalancer depends on a strategy interface to select backends:
src/balancer/loadBalancer.ts
export class LoadBalancer {
constructor (
private backendPool: BackendPool,
private strategy: RoundRobin // Strategy injected via constructor
){}
pickBackend(): Backend {
const healthyBackends = this.backendPool.getHealthyBackends();
return this.strategy.pick(healthyBackends) // Delegates to strategy
}
}
This design enables:
- Flexibility - Change routing algorithms at runtime
- Extensibility - Add new strategies without modifying existing code
- Testability - Mock strategies for unit testing
Round Robin strategy
The current implementation uses Round Robin, which distributes requests evenly across all healthy backends:
src/balancer/roundRobin.ts
import { type Backend } from "../types/types.ts"
export class RoundRobin {
private index = 0;
pick (backends: Backend[]): Backend {
if (backends.length === 0){
throw new Error(" No backend is available")
}
const backend = backends[this.index % backends.length]!;
this.index++;
return backend;
}
}
How Round Robin works
Initialize index
The strategy maintains an internal counter starting at 0.
Calculate position
Uses modulo arithmetic: this.index % backends.length ensures the index wraps around when it exceeds the array size.
Select backend
Returns the backend at the calculated position.
Increment counter
Increments the index so the next call selects the next backend.
With 3 backends, requests go to backend 0, then 1, then 2, then back to 0, cycling continuously.
Error handling
The strategy throws an error if no backends are available:
if (backends.length === 0){
throw new Error(" No backend is available")
}
This happens when all backends fail health checks. Consider implementing fallback behavior or alerting for production systems.
Implementing custom strategies
You can create new routing strategies by following the same pattern. Here are examples:
Weighted Round Robin
Distribute traffic based on backend capacity:
export interface WeightedBackend extends Backend {
weight: number;
}
export class WeightedRoundRobin {
private currentIndex = 0;
private currentWeight = 0;
pick(backends: WeightedBackend[]): Backend {
// Implementation: Select backends proportionally to their weight
// A backend with weight=3 gets 3x more traffic than weight=1
}
}
Weighted strategies are useful when backends have different capacities (e.g., some servers have more CPU/RAM).
Least Connections
Route to the backend with fewest active connections:
export interface ConnectionBackend extends Backend {
activeConnections: number;
}
export class LeastConnections {
pick(backends: ConnectionBackend[]): Backend {
return backends.reduce((min, backend) =>
backend.activeConnections < min.activeConnections ? backend : min
);
}
}
This strategy requires tracking active connections per backend, which isn’t in the current implementation.
IP Hash
Route requests from the same client IP to the same backend (session persistence):
export class IPHash {
pick(backends: Backend[], clientIP: string): Backend {
const hash = this.hashIP(clientIP);
const index = hash % backends.length;
return backends[index];
}
private hashIP(ip: string): number {
// Simple hash function for demonstration
return ip.split('.').reduce((acc, octet) => acc + parseInt(octet), 0);
}
}
IP hash ensures the same client always reaches the same backend, useful for stateful applications.
Random selection
Simply pick a random backend:
export class Random {
pick(backends: Backend[]): Backend {
if (backends.length === 0) {
throw new Error("No backend is available");
}
const index = Math.floor(Math.random() * backends.length);
return backends[index];
}
}
Random selection is simple but effective for stateless applications with homogeneous backends.
Strategy interface pattern
To make strategies interchangeable, define a common interface:
export interface LoadBalancingStrategy {
pick(backends: Backend[]): Backend;
}
Then update the LoadBalancer to accept any strategy:
export class LoadBalancer {
constructor (
private backendPool: BackendPool,
private strategy: LoadBalancingStrategy // Interface, not concrete class
){}
}
All strategies implement the same interface:
export class RoundRobin implements LoadBalancingStrategy { /* ... */ }
export class Random implements LoadBalancingStrategy { /* ... */ }
export class LeastConnections implements LoadBalancingStrategy { /* ... */ }
This is a classic strategy pattern implementation - the context (LoadBalancer) delegates to the strategy interface.
Choosing a strategy
Different strategies suit different use cases:
| Strategy | Best for | Trade-offs |
|---|
| Round Robin | Stateless apps, equal backends | Simple but no session persistence |
| Weighted | Mixed backend capacities | Requires capacity planning |
| Least Connections | Long-lived connections | Needs connection tracking |
| IP Hash | Stateful apps needing persistence | Uneven distribution if client IPs skewed |
| Random | Stateless apps, simple setup | No predictability |
Start with Round Robin for simplicity. Add complexity only when you have a specific requirement like session persistence or unequal backend capacities.