Build a Load Balancer
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.
- A reverse proxy load balancer
- Multiple load balancing algorithms
- Backend server management
- Health check system
- Connection pooling
- Request/response proxying
- 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