← Back to Tutorials
Node.js

Build a Load Balancer

Difficulty: Advanced Est. Time: ~5 hours

Introduction

Load balancers are critical infrastructure components that distribute incoming traffic across multiple servers. They ensure no single server becomes overwhelmed, improve application availability, and enable horizontal scaling.

In this tutorial, we'll build a production-ready load balancer with multiple load balancing algorithms, health checks, and request proxying.

What You'll Build
  • A reverse proxy load balancer
  • Multiple load balancing algorithms
  • Backend server management
  • Health check system
  • Connection pooling
  • Request/response proxying
What You'll Learn
  • Reverse proxy architecture
  • Load balancing algorithms
  • TCP/HTTP proxying
  • Health monitoring
  • Circuit breaker pattern
  • Server failover

Core Concepts

Load Balancing Algorithms

There are several algorithms for distributing requests:

  • Round Robin - Requests distributed sequentially to each server
  • Least Connections - Directs to server with fewest active connections
  • Weighted - Servers with higher weight get more requests
  • IP Hash - Consistent hashing based on client IP
  • Least Response Time - Chooses server with fastest response

Health Checks

Load balancers continuously monitor backend servers to detect failures. When a server fails health checks, it's removed from the pool until it recovers.

Reverse Proxy

A reverse proxy sits between clients and servers, forwarding client requests to appropriate backends and returning responses to clients. This hides the backend infrastructure from clients.

Project Overview

Our load balancer will include:

Feature Description
HTTP Proxy HTTP/HTTPS request forwarding
Algorithms Round Robin, Least Connections, Weighted
Health Checks Periodic ping to verify server health
Sticky Sessions Route requests to same backend
Request Timeout Configurable timeout for backends

Prerequisites

  • Node.js 14+ - Installed on your system
  • npm - Node package manager
  • Basic JavaScript/Node.js knowledge

Backend Management

Create backend.js to manage backend servers:

const http = require('http');

class Backend {
    constructor(options) {
        this.host = options.host;
        this.port = options.port;
        this.weight = options.weight || 1;
        this.maxFailures = options.maxFailures || 3;
        this.healthCheckInterval = options.healthCheckInterval || 30000;
        
        this.url = `http://${this.host}:${this.port}`;
        this.isHealthy = true;
        this.failureCount = 0;
        this.activeConnections = 0;
        this.totalRequests = 0;
        this.totalResponseTime = 0;
        
        this.healthCheckTimer = null;
        this.healthCheckCallback = null;
    }

    get address() {
        return `${this.host}:${this.port}`;
    }

    get averageResponseTime() {
        if (this.totalRequests === 0) return 0;
        return this.totalResponseTime / this.totalRequests;
    }

    recordRequest(responseTime) {
        this.activeConnections++;
        this.totalRequests++;
        this.totalResponseTime += responseTime;
    }

    releaseConnection() {
        if (this.activeConnections > 0) {
            this.activeConnections--;
        }
    }

    recordSuccess() {
        this.failureCount = 0;
        this.isHealthy = true;
    }

    recordFailure() {
        this.failureCount++;
        if (this.failureCount >= this.maxFailures) {
            this.isHealthy = false;
        }
    }

    startHealthCheck(callback) {
        this.healthCheckCallback = callback;
        this.healthCheckTimer = setInterval(() => {
            this._performHealthCheck();
        }, this.healthCheckInterval);
        this._performHealthCheck();
    }

    stopHealthCheck() {
        if (this.healthCheckTimer) {
            clearInterval(this.healthCheckTimer);
            this.healthCheckTimer = null;
        }
    }

    _performHealthCheck() {
        const start = Date.now();
        
        http.get(`${this.url}/health`, (res) => {
            const responseTime = Date.now() - start;
            
            if (res.statusCode === 200) {
                this.recordSuccess();
                if (this.healthCheckCallback) {
                    this.healthCheckCallback(this, true);
                }
            } else {
                this.recordFailure();
                if (this.healthCheckCallback) {
                    this.healthCheckCallback(this, false);
                }
            }
        }).on('error', () => {
            this.recordFailure();
            if (this.healthCheckCallback) {
                this.healthCheckCallback(this, false);
            }
        });
    }

    toJSON() {
        return {
            address: this.address,
            url: this.url,
            weight: this.weight,
            isHealthy: this.isHealthy,
            activeConnections: this.activeConnections,
            totalRequests: this.totalRequests,
            averageResponseTime: this.averageResponseTime
        };
    }
}

module.exports = Backend;

Load Balancing Algorithms

Create algorithms.js to implement different algorithms:

class LoadBalancerAlgorithm {
    select(backends) {
        throw new Error('Not implemented');
    }
}

class RoundRobinAlgorithm extends LoadBalancerAlgorithm {
    constructor() {
        super();
        this.currentIndex = 0;
    }

    select(backends) {
        const healthy = backends.filter(b => b.isHealthy);
        if (healthy.length === 0) return null;
        
        const backend = healthy[this.currentIndex % healthy.length];
        this.currentIndex++;
        return backend;
    }

    reset() {
        this.currentIndex = 0;
    }
}

class LeastConnectionsAlgorithm extends LoadBalancerAlgorithm {
    select(backends) {
        const healthy = backends.filter(b => b.isHealthy);
        if (healthy.length === 0) return null;
        
        return healthy.reduce((min, backend) => {
            return backend.activeConnections < min.activeConnections ? backend : min;
        });
    }
}

class WeightedRoundRobinAlgorithm extends LoadBalancerAlgorithm {
    constructor() {
        super();
        this.currentIndex = 0;
        this.currentWeight = 0;
    }

    select(backends) {
        const healthy = backends.filter(b => b.isHealthy);
        if (healthy.length === 0) return null;
        
        let maxWeight = 0;
        for (const backend of healthy) {
            if (backend.weight > maxWeight) {
                maxWeight = backend.weight;
            }
        }
        
        if (this.currentWeight <= 0) {
            this.currentWeight = maxWeight;
            
            let totalWeight = 0;
            for (const backend of healthy) {
                totalWeight += backend.weight;
            }
            
            if (this.currentIndex >= totalWeight) {
                this.currentIndex = 0;
            }
        }
        
        let count = 0;
        for (const backend of healthy) {
            count += backend.weight;
            if (this.currentIndex < count) {
                this.currentIndex++;
                this.currentWeight--;
                return backend;
            }
        }
        
        return healthy[0];
    }

    reset() {
        this.currentIndex = 0;
        this.currentWeight = 0;
    }
}

class IPHashAlgorithm extends LoadBalancerAlgorithm {
    select(backends, clientIP) {
        const healthy = backends.filter(b => b.isHealthy);
        if (healthy.length === 0) return null;
        
        const hash = this._hash(clientIP);
        const index = hash % healthy.length;
        return healthy[index];
    }

    _hash(ip) {
        let hash = 0;
        for (let i = 0; i < ip.length; i++) {
            hash = ((hash << 5) - hash) + ip.charCodeAt(i);
            hash = hash & hash;
        }
        return Math.abs(hash);
    }
}

class LeastResponseTimeAlgorithm extends LoadBalancerAlgorithm {
    select(backends) {
        const healthy = backends.filter(b => b.isHealthy && b.totalRequests > 0);
        if (healthy.length === 0) {
            const allHealthy = backends.filter(b => b.isHealthy);
            if (allHealthy.length === 0) return null;
            return allHealthy[0];
        }
        
        return healthy.reduce((min, backend) => {
            return backend.averageResponseTime < min.averageResponseTime ? backend : min;
        });
    }
}

module.exports = {
    LoadBalancerAlgorithm,
    RoundRobinAlgorithm,
    LeastConnectionsAlgorithm,
    WeightedRoundRobinAlgorithm,
    IPHashAlgorithm,
    LeastResponseTimeAlgorithm
};

Health Check System

Create healthcheck.js for managing health monitoring:

const EventEmitter = require('events');

class HealthCheckManager extends EventEmitter {
    constructor(options = {}) {
        super();
        this.interval = options.interval || 30000;
        this.timeout = options.timeout || 5000;
        this.unhealthyThreshold = options.unhealthyThreshold || 3;
        this.healthyThreshold = options.healthyThreshold || 2;
        
        this.backends = new Map();
        this.healthStatus = new Map();
        this.timers = new Map();
    }

    addBackend(backend) {
        this.backends.set(backend.address, backend);
        this.healthStatus.set(backend.address, {
            healthy: true,
            consecutiveFailures: 0,
            consecutiveSuccesses: 0,
            lastCheck: null
        });
        
        this._startMonitoring(backend);
    }

    removeBackend(backend) {
        this._stopMonitoring(backend);
        this.backends.delete(backend.address);
        this.healthStatus.delete(backend.address);
    }

    getHealthyBackends() {
        return Array.from(this.backends.values()).filter(b => b.isHealthy);
    }

    getBackendStatus(address) {
        return this.healthStatus.get(address);
    }

    _startMonitoring(backend) {
        const timer = setInterval(() => {
            this._checkBackend(backend);
        }, this.interval);
        
        this.timers.set(backend.address, timer);
        this._checkBackend(backend);
    }

    _stopMonitoring(backend) {
        const timer = this.timers.get(backend.address);
        if (timer) {
            clearInterval(timer);
            this.timers.delete(backend.address);
        }
    }

    _checkBackend(backend) {
        const status = this.healthStatus.get(backend.address);
        const start = Date.now();
        
        const req = http.get(`${backend.url}/health`, { timeout: this.timeout }, (res) => {
            const responseTime = Date.now() - start;
            
            if (res.statusCode === 200) {
                this._handleSuccess(backend, status, responseTime);
            } else {
                this._handleFailure(backend, status);
            }
        });
        
        req.on('error', () => {
            this._handleFailure(backend, status);
        });
        
        req.on('timeout', () => {
            req.destroy();
            this._handleFailure(backend, status);
        });
    }

    _handleSuccess(backend, status, responseTime) {
        status.consecutiveFailures = 0;
        status.consecutiveSuccesses++;
        status.lastCheck = new Date();
        
        if (!backend.isHealthy && status.consecutiveSuccesses >= this.healthyThreshold) {
            backend.isHealthy = true;
            backend.failureCount = 0;
            this.emit('backendHealthy', backend);
        }
        
        this.emit('healthCheck', { backend, healthy: true, responseTime });
    }

    _handleFailure(backend, status) {
        status.consecutiveFailures++;
        status.consecutiveSuccesses = 0;
        status.lastCheck = new Date();
        
        if (backend.isHealthy && status.consecutiveFailures >= this.unhealthyThreshold) {
            backend.isHealthy = false;
            this.emit('backendUnhealthy', backend);
        }
        
        this.emit('healthCheck', { backend, healthy: false });
    }

    stop() {
        for (const timer of this.timers.values()) {
            clearInterval(timer);
        }
        this.timers.clear();
    }
}

const http = require('http');
module.exports = HealthCheckManager;

Request Proxy

Create proxy.js to handle request forwarding:

const http = require('http');
const https = require('https');

class ProxyHandler {
    constructor(options = {}) {
        this.timeout = options.timeout || 30000;
        this.proxyReqCallback = options.proxyReqCallback || null;
        this.proxyResCallback = options.proxyResCallback || null;
        this.errorHandler = options.errorHandler || null;
    }

    handleRequest(req, res, backend) {
        const proxyReq = this._createProxyRequest(req, backend);
        
        const timeout = setTimeout(() => {
            proxyReq.destroy();
            this._handleError(req, res, 'Gateway Timeout', 504);
        }, this.timeout);

        proxyReq.on('response', (proxyRes) => {
            clearTimeout(timeout);
            backend.recordRequest(Date.now() - req.startTime);
            
            if (this.proxyResCallback) {
                this.proxyResCallback(proxyRes, req, res);
            }
            
            res.writeHead(proxyRes.statusCode, proxyRes.headers);
            
            proxyRes.on('data', (chunk) => {
                res.write(chunk);
            });
            
            proxyRes.on('end', () => {
                backend.releaseConnection();
                res.end();
            });
        });

        proxyReq.on('error', (err) => {
            clearTimeout(timeout);
            backend.recordFailure();
            backend.releaseConnection();
            this._handleError(req, res, err.message, 502);
        });

        if (this.proxyReqCallback) {
            this.proxyReqCallback(proxyReq, req);
        }

        req.on('data', (chunk) => {
            proxyReq.write(chunk);
        });

        req.on('end', () => {
            proxyReq.end();
        });
    }

    _createProxyRequest(req, backend) {
        const options = {
            hostname: backend.host,
            port: backend.port,
            path: req.url,
            method: req.method,
            headers: this._buildHeaders(req.headers),
        };

        const protocol = backend.port === 443 ? https : http;
        return protocol.request(options);
    }

    _buildHeaders(headers) {
        const newHeaders = { ...headers };
        delete newHeaders['host'];
        delete newHeaders['connection'];
        newHeaders['x-forwarded-for'] = newHeaders['x-forwarded-for'] || '';
        newHeaders['x-forwarded-proto'] = 'http';
        return newHeaders;
    }

    _handleError(req, res, message, statusCode) {
        if (this.errorHandler) {
            this.errorHandler(req, res, message, statusCode);
        } else {
            res.writeHead(statusCode, { 'Content-Type': 'text/plain' });
            res.end(message);
        }
    }
}

module.exports = ProxyHandler;

Server Implementation

Create loadbalancer.js to tie everything together:

const http = require('http');
const EventEmitter = require('events');
const Backend = require('./backend');
const {
    RoundRobinAlgorithm,
    LeastConnectionsAlgorithm,
    WeightedRoundRobinAlgorithm,
    IPHashAlgorithm,
    LeastResponseTimeAlgorithm
} = require('./algorithms');
const HealthCheckManager = require('./healthcheck');
const ProxyHandler = require('./proxy');

class LoadBalancer extends EventEmitter {
    constructor(options = {}) {
        super();
        
        this.port = options.port || 8080;
        this.algorithm = options.algorithm || 'round-robin';
        this.healthCheckOptions = options.healthCheck || {};
        
        this.backends = [];
        this.algorithmInstance = this._createAlgorithm();
        this.healthCheck = new HealthCheckManager(this.healthCheckOptions);
        this.proxyHandler = new ProxyHandler({
            timeout: options.timeout || 30000,
            errorHandler: (req, res, msg, code) => this._handleError(req, res, msg, code)
        });
        
        this.server = null;
        this._setupHealthCheckEvents();
    }

    _createAlgorithm() {
        switch (this.algorithm) {
            case 'least-connections':
                return new LeastConnectionsAlgorithm();
            case 'weighted':
                return new WeightedRoundRobinAlgorithm();
            case 'ip-hash':
                return new IPHashAlgorithm();
            case 'least-response-time':
                return new LeastResponseTimeAlgorithm();
            default:
                return new RoundRobinAlgorithm();
        }
    }

    _setupHealthCheckEvents() {
        this.healthCheck.on('backendHealthy', (backend) => {
            this.emit('backendUp', backend);
            console.log(`Backend ${backend.address} is now healthy`);
        });
        
        this.healthCheck.on('backendUnhealthy', (backend) => {
            this.emit('backendDown', backend);
            console.log(`Backend ${backend.address} is now unhealthy`);
        });
    }

    addBackend(host, port, options = {}) {
        const backend = new Backend({
            host,
            port,
            weight: options.weight || 1,
            maxFailures: options.maxFailures || 3,
            healthCheckInterval: options.healthCheckInterval || 30000
        });
        
        this.backends.push(backend);
        this.healthCheck.addBackend(backend);
        
        return backend;
    }

    removeBackend(address) {
        const index = this.backends.findIndex(b => b.address === address);
        if (index !== -1) {
            const backend = this.backends[index];
            this.healthCheck.removeBackend(backend);
            this.backends.splice(index, 1);
        }
    }

    getBackend(req) {
        const healthy = this.backends.filter(b => b.isHealthy);
        
        if (healthy.length === 0) {
            return null;
        }
        
        if (this.algorithm === 'ip-hash') {
            const clientIP = req.socket.remoteAddress || req.headers['x-forwarded-for'];
            return this.algorithmInstance.select(healthy, clientIP);
        }
        
        return this.algorithmInstance.select(healthy);
    }

    start() {
        this.server = http.createServer((req, res) => {
            req.startTime = Date.now();
            
            const backend = this.getBackend(req);
            
            if (!backend) {
                this._handleError(req, res, 'No healthy backends available', 503);
                return;
            }
            
            req.backend = backend;
            this.proxyHandler.handleRequest(req, res, backend);
        });

        this.server.on('error', (err) => {
            this.emit('error', err);
        });

        this.server.listen(this.port, () => {
            console.log(`Load balancer running on port ${this.port}`);
            console.log(`Algorithm: ${this.algorithm}`);
            console.log(`Backends: ${this.backends.map(b => b.address).join(', ')}`);
        });

        return this;
    }

    stop() {
        if (this.server) {
            this.server.close();
        }
        this.healthCheck.stop();
    }

    _handleError(req, res, message, statusCode) {
        res.writeHead(statusCode, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: message }));
    }

    getStats() {
        return {
            algorithm: this.algorithm,
            totalBackends: this.backends.length,
            healthyBackends: this.backends.filter(b => b.isHealthy).length,
            backends: this.backends.map(b => b.toJSON())
        };
    }
}

module.exports = LoadBalancer;

Create index.js to run the load balancer:

const LoadBalancer = require('./loadbalancer');

const lb = new LoadBalancer({
    port: 8080,
    algorithm: 'least-connections',
    timeout: 10000,
    healthCheck: {
        interval: 10000,
        timeout: 5000,
        unhealthyThreshold: 3,
        healthyThreshold: 2
    }
});

lb.addBackend('localhost', 3001, { weight: 1 });
lb.addBackend('localhost', 3002, { weight: 1 });
lb.addBackend('localhost', 3003, { weight: 1 });

lb.on('backendUp', (backend) => {
    console.log(`Backend UP: ${backend.address}`);
});

lb.on('backendDown', (backend) => {
    console.log(`Backend DOWN: ${backend.address}`);
});

lb.start();

console.log('Load balancer started. Stats:', lb.getStats());

Testing the Load Balancer

First, create simple backend servers:

// backend-server.js
const http = require('http');

const PORT = process.argv[2] || 3000;

const server = http.createServer((req, res) => {
    console.log(`[${PORT}] Received request:`, req.url);
    
    if (req.url === '/health') {
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ status: 'ok', port: PORT }));
        return;
    }
    
    res.writeHead(200, { 
        'Content-Type': 'application/json',
        'X-Backend-Port': PORT 
    });
    res.end(JSON.stringify({ 
        message: `Response from server ${PORT}`,
        timestamp: new Date().toISOString()
    }));
});

server.listen(PORT, () => {
    console.log(`Backend server running on port ${PORT}`);
});

Run the setup:

# Terminal 1: Start backend servers
node backend-server.js 3001
node backend-server.js 3002
node backend-server.js 3003

# Terminal 2: Start load balancer
node index.js

# Terminal 3: Test with curl
curl http://localhost:8080/
curl http://localhost:8080/
curl http://localhost:8080/

Summary

Congratulations! You've built a complete load balancer. Here's what you learned:

  • Backend Management - How to track and manage backend servers
  • Load Balancing Algorithms - Round Robin, Least Connections, Weighted, IP Hash
  • Health Checks - How to monitor server health
  • Request Proxying - How to forward HTTP requests to backends
  • Connection Tracking - How to track active connections
  • Circuit Breaker - How to detect and handle failures

Possible Extensions

  • Add HTTPS/TLS termination
  • Implement sticky sessions with cookies
  • Add request/response caching
  • Implement rate limiting per client
  • Add SSL certificate management
  • Build a management dashboard