Skip to main content
The load balancer is composed of six core components, each with a single, well-defined responsibility. This separation of concerns makes the codebase maintainable, testable, and extensible.

Component overview

ComponentResponsibilityLocation
LoadBalancerOrchestrates backend selection using a pluggable strategysrc/balancer/loadBalancer.ts
BackendPoolManages backend server registry and health statesrc/balancer/pool.ts
RoundRobinImplements the round-robin routing algorithmsrc/balancer/roundRobin.ts
HealthCheckerPeriodically verifies backend availabilitysrc/healthchecker/healthChecker.ts
ProxyHandlerForwards requests and handles proxy errorssrc/proxy/proxyHandler.ts
LoggerStructured, categorized log outputsrc/utils/logger.ts

LoadBalancer

Purpose: Orchestrates backend selection by combining the BackendPool and a routing strategy.

Interface

// src/balancer/loadBalancer.ts
export class LoadBalancer {
  constructor (
    private backendPool: BackendPool,
    private strategy: RoundRobin
  ){}

  pickBackend(): Backend {
    const healthyBackends = this.backendPool.getHealthyBackends();
    return this.strategy.pick(healthyBackends)
  }
}

Responsibilities

  • Queries the BackendPool for healthy backends
  • Delegates the selection logic to the configured strategy
  • Throws an error if no backends are available (handled by ProxyHandler)

Interactions

  • BackendPool: Calls getHealthyBackends() to retrieve only backends marked as healthy
  • RoundRobin (or any strategy): Calls pick() to select one backend from the healthy list
  • ProxyHandler: Used by ProxyHandler to determine which backend should receive the request
The LoadBalancer class is agnostic to the specific routing algorithm. You can inject any strategy that implements a pick(backends: Backend[]): Backend method.

BackendPool

Purpose: Central registry for all backend servers and their health status.

Interface

// src/balancer/pool.ts
export class BackendPool {
  private backends: Backend[];

  constructor(urls: string[]){
    this.backends = urls.map((url: string) => ({
      url, health:true
    }))
  }

  getAllBackends(): Backend[] {
    return this.backends;
  }
  
  getHealthyBackends() : Backend[] {
    return this.backends.filter(backend => backend.health);
  }

  markUnhealthy(url: string): void {
    const backend = this.backends.find(b => b.url === url);
    if(backend){
      backend.health = false
    }
  }

  markHealthy(url: string): void {
    const backend  = this.backends.find(b => b.url === url);
    if(backend){
      backend.health = true
    }
  }
}

Backend data structure

// src/types/types.ts
export interface Backend {
  url: string,
  health: boolean
}

Responsibilities

  • Stores all backend servers with their URLs and health status
  • Provides filtered views of backends (all vs. healthy only)
  • Allows health status updates via markHealthy() and markUnhealthy()

Interactions

  • LoadBalancer: Calls getHealthyBackends() when selecting a backend for a request
  • HealthChecker: Calls getAllBackends() to get the full list, then updates health status
  • ProxyHandler: Calls markUnhealthy() when a proxy request fails
All backends start as healthy by default. The HealthChecker performs an initial check immediately on startup to verify actual health status.

RoundRobin

Purpose: Implements the round-robin load balancing strategy.

Interface

// src/balancer/roundRobin.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;
  }
}

Responsibilities

  • Maintains an internal index counter
  • Distributes requests evenly across all healthy backends
  • Uses modulo arithmetic to cycle through backends infinitely

Algorithm details

  1. Initialization: Starts with index = 0
  2. Selection: Returns backends[index % backends.length]
  3. Increment: Increments index after each selection
  4. Wraparound: When index exceeds backends.length, modulo wraps it back to 0

Interactions

  • LoadBalancer: Called by LoadBalancer.pickBackend() to select the next backend
  • Receives a filtered list of only healthy backends from LoadBalancer
The strategy pattern used here makes it easy to implement alternative algorithms like Least Connections, Weighted Round Robin, or IP Hash without modifying any other components.

HealthChecker

Purpose: Continuously monitors backend availability with parallel health checks.

Interface

// src/healthchecker/healthChecker.ts
export class HealthChecker {
  private intervalMs: number;
  private isRunning: boolean = false;
  private backendPool: BackendPool;  

  constructor(backendPool: BackendPool, intervalMs: number = 5000) {
    this.backendPool = backendPool;   
    this.intervalMs = intervalMs;
  }

  async checkAll() {
    const backends = this.backendPool.getAllBackends();

    const checks = backends.map(async (backend) => {
      try {
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), 3000);

        const res = await fetch(backend.url, { signal: controller.signal });
        clearTimeout(timeoutId);

        if (res.ok) {
          this.backendPool.markHealthy(backend.url);
          Logger.health(backend.url, true, `status: ${res.status}`);
        } else {
          this.backendPool.markUnhealthy(backend.url);
          Logger.health(backend.url, false, `status: ${res.status}`);
        }
      } catch (err) {
        this.backendPool.markUnhealthy(backend.url);
        const errorMsg = err instanceof Error ? err.message : 'Unknown error';
        Logger.health(backend.url, false, errorMsg);
      }
    });

    await Promise.all(checks);
  }

  start() { /* ... */ }
  stop() { /* ... */ }
}

Responsibilities

  • Runs health checks at a configurable interval (default: 5 seconds)
  • Uses AbortController to implement per-request timeouts (3 seconds)
  • Updates the BackendPool based on health check results
  • Logs all health status changes

Health check criteria

A backend is marked healthy if:
  • The HTTP request completes within 3 seconds
  • The response status is 2xx (checked via res.ok)

Interactions

  • BackendPool: Calls getAllBackends() to get the full list, then calls markHealthy() or markUnhealthy()
  • Logger: Reports health status changes for observability
  • Runs independently in the background, not triggered by requests
Health checks run in parallel using Promise.all(). This prevents a single slow backend from blocking health evaluation of other backends.

ProxyHandler

Purpose: Express middleware that forwards requests to selected backends and handles failures.

Interface

// src/proxy/proxyHandler.ts
export function ProxyHandler(
  loadBalancer: LoadBalancer,
  backendPool: BackendPool
) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const startTime = Date.now();
    let backend;

    try {
      backend = loadBalancer.pickBackend();
    } catch (err) {
      Logger.error("No healthy backends available");
      res.status(503).send("No healthy backends available");
      return;
    }

    Logger.request(req.method, req.path, backend.url);

    const proxyMiddleware = proxy(backend.url, {
      userResDecorator: (proxyRes, proxyResData, userReq, userRes) => {
        const duration = Date.now() - startTime;
        Logger.response(req.method, req.path, backend.url, proxyRes.statusCode || 0, duration);
        return proxyResData;
      },
      proxyErrorHandler: (err, res, next) => {
        const duration = Date.now() - startTime;
        Logger.error(`Backend failed after ${duration}ms`, backend.url);
        backendPool.markUnhealthy(backend.url);
        res.status(502).send("Bad gateway");
      }
    });
    return proxyMiddleware(req, res, next);
  };
}

Responsibilities

  • Asks LoadBalancer to pick a healthy backend
  • Measures request/response timing
  • Forwards the request using express-http-proxy
  • Handles proxy errors by marking backends unhealthy
  • Returns appropriate HTTP status codes (502 for backend failure, 503 for no backends)

Error handling strategy

No healthy backends:
  • Status: 503 Service Unavailable
  • Logged as: ERROR: No healthy backends available
Backend fails during proxy:
  • Status: 502 Bad Gateway
  • Backend marked unhealthy immediately
  • Logged as: ERROR: Backend failed after Xms (http://backend:port)

Interactions

  • LoadBalancer: Calls pickBackend() to get the target backend
  • BackendPool: Calls markUnhealthy() when a proxy request fails
  • Logger: Reports requests, responses, and errors
  • Express: Registered as middleware via app.use("/", ProxyHandler(...))

Logger

Purpose: Provides structured, categorized logging for observability.

Interface

// src/utils/logger.ts
export class Logger {
  static request(method: string, path: string, backendUrl: string): void {
    console.log(`REQUEST: ${method} ${path} -> ${backendUrl}`);
  }

  static response(method: string, path: string, backendUrl: string, statusCode: number, duration: number): void {
    console.log(`RESPONSE: ${method} ${path} <- ${backendUrl} [${statusCode}] ${duration}ms`);
  }

  static error(message: string, backendUrl?: string): void {
    const backend = backendUrl ? ` (${backendUrl})` : '';
    console.error(`ERROR: ${message}${backend}`);
  }

  static info(message: string): void {
    console.log(`INFO: ${message}`);
  }

  static health(url: string, isHealthy: boolean, details?: string): void {
    const status = isHealthy ? 'HEALTHY' : 'UNHEALTHY';
    const extraInfo = details ? ` - ${details}` : '';
    console.log(`HEALTH: ${status} ${url}${extraInfo}`);
  }
}

Log categories

Logged when a request is sent to a backend:
REQUEST: GET /api/users -> http://localhost:3001

Interactions

Used by all components for structured output:
  • ProxyHandler: Logs requests, responses, and errors
  • HealthChecker: Logs health status changes
  • index.ts: Logs startup information
The structured log format makes it easy to parse logs programmatically for monitoring, alerting, and analytics.

Build docs developers (and LLMs) love