Skip to main content

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

1

Initialize index

The strategy maintains an internal counter starting at 0.
2

Calculate position

Uses modulo arithmetic: this.index % backends.length ensures the index wraps around when it exceeds the array size.
3

Select backend

Returns the backend at the calculated position.
4

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:
StrategyBest forTrade-offs
Round RobinStateless apps, equal backendsSimple but no session persistence
WeightedMixed backend capacitiesRequires capacity planning
Least ConnectionsLong-lived connectionsNeeds connection tracking
IP HashStateful apps needing persistenceUneven distribution if client IPs skewed
RandomStateless apps, simple setupNo predictability
Start with Round Robin for simplicity. Add complexity only when you have a specific requirement like session persistence or unequal backend capacities.

Build docs developers (and LLMs) love