Skip to content

⚖️ Load Balancing: The Traffic Director

A load balancer sits in front of a pool of servers and distributes incoming requests so that no single server becomes a bottleneck. It is the component that makes horizontal scaling actually work.


Why Load Balancing Exists

Without a load balancer, every client must know which server to talk to. That breaks the moment you add or remove a server and forces clients to deal with server failures directly.

Benefits at a glance:

Problem Without LBSolution With LB
One server gets crushedTraffic is spread evenly
A dead server kills usersLB detects failure and skips it
Adding servers is painfulLB pool grows transparently
Single point of failureActive-passive LB pairs remove that risk

Layer 4 vs Layer 7 Load Balancing

Load balancers operate at different layers of the network stack. This is the most important architectural decision.

FeatureLayer 4Layer 7
Routing basisIP + PortURL, headers, cookies
SpeedFasterSlightly slower
SSL terminationNoYes
Content-based rulesNoYes
Use caseRaw TCP, databasesHTTP APIs, microservices

Load Balancing Algorithms

1. Round Robin

Requests are handed out in sequence — first to Server 1, next to Server 2, and so on, cycling back.

javascript
class RoundRobinBalancer {
  constructor(servers) {
    this.servers = servers;
    this.index = 0;
  }

  next() {
    const server = this.servers[this.index];
    this.index = (this.index + 1) % this.servers.length;
    return server;
  }
}

const lb = new RoundRobinBalancer(["s1:3001", "s2:3002", "s3:3003"]);

// Simulate 6 requests
for (let i = 1; i <= 6; i++) {
  console.log(`Request ${i} → ${lb.next()}`);
}
// Request 1 → s1:3001
// Request 2 → s2:3002
// Request 3 → s3:3003
// Request 4 → s1:3001  ← cycle restarts
// Request 5 → s2:3002
// Request 6 → s3:3003

Best for: Homogeneous servers with similar processing times.


2. Weighted Round Robin

Servers with more capacity get proportionally more requests.

javascript
class WeightedRoundRobinBalancer {
  constructor(servers) {
    // servers = [{ host, weight }]
    this.pool = servers.flatMap((s) => Array(s.weight).fill(s.host));
    this.index = 0;
  }

  next() {
    const server = this.pool[this.index];
    this.index = (this.index + 1) % this.pool.length;
    return server;
  }
}

const lb = new WeightedRoundRobinBalancer([
  { host: "s1:3001", weight: 3 }, // gets 3/6 = 50% of traffic
  { host: "s2:3002", weight: 2 }, // gets 2/6 = 33%
  { host: "s3:3003", weight: 1 }, // gets 1/6 = 17%
]);

// Pool becomes: [s1, s1, s1, s2, s2, s3]
for (let i = 1; i <= 6; i++) {
  console.log(`Request ${i} → ${lb.next()}`);
}

3. Least Connections

Route each new request to the server currently handling the fewest active connections. Excellent for long-lived connections (WebSockets, file uploads).

javascript
class LeastConnectionsBalancer {
  constructor(hosts) {
    this.servers = hosts.map((host) => ({ host, connections: 0 }));
  }

  acquire() {
    const server = this.servers.reduce((min, s) =>
      s.connections < min.connections ? s : min
    );
    server.connections++;
    return server;
  }

  release(server) {
    server.connections = Math.max(0, server.connections - 1);
  }
}

const lb = new LeastConnectionsBalancer(["s1:3001", "s2:3002", "s3:3003"]);

async function handleRequest(requestId) {
  const server = lb.acquire();
  console.log(
    `Request ${requestId} → ${server.host} (${server.connections} conns)`
  );

  // Simulate work (variable duration)
  await new Promise((r) => setTimeout(r, Math.random() * 200));

  lb.release(server);
  console.log(`Request ${requestId} done — ${server.host} freed`);
}

// Fire 5 concurrent requests
Promise.all([1, 2, 3, 4, 5].map(handleRequest));

4. IP Hash (Sticky Routing)

A hash of the client's IP address deterministically selects the server. The same client always lands on the same server — critical for session data not stored externally.

javascript
function ipToInt(ip) {
  return (
    ip.split(".").reduce((acc, oct) => (acc << 8) + parseInt(oct), 0) >>> 0
  );
}

class IpHashBalancer {
  constructor(servers) {
    this.servers = servers;
  }

  next(clientIp) {
    const hash = ipToInt(clientIp);
    return this.servers[hash % this.servers.length];
  }
}

const lb = new IpHashBalancer(["s1:3001", "s2:3002", "s3:3003"]);

console.log(lb.next("192.168.1.10")); // Always same server for this IP
console.log(lb.next("192.168.1.20")); // Always same server for this IP
console.log(lb.next("192.168.1.10")); // Same as first call ✅

⚠️ Downside: Adding or removing a server re-maps most clients. Use Consistent Hashing to fix this.


5. Consistent Hashing

Maps both servers and requests onto a virtual ring. Adding or removing a server only remaps a small fraction of keys — the rest stay put.

javascript
const crypto = require("crypto");

class ConsistentHashRing {
  constructor(servers, virtualNodes = 150) {
    this.ring = new Map();
    this.sortedKeys = [];

    for (const server of servers) {
      for (let v = 0; v < virtualNodes; v++) {
        const key = this._hash(`${server}:${v}`);
        this.ring.set(key, server);
        this.sortedKeys.push(key);
      }
    }
    this.sortedKeys.sort((a, b) => a - b);
  }

  _hash(str) {
    const hex = crypto.createHash("md5").update(str).digest("hex");
    return parseInt(hex.slice(0, 8), 16);
  }

  getServer(requestKey) {
    const hash = this._hash(requestKey);
    // Walk the ring clockwise to find the first server >= hash
    for (const ringKey of this.sortedKeys) {
      if (hash <= ringKey) return this.ring.get(ringKey);
    }
    // Wrap around to first server
    return this.ring.get(this.sortedKeys[0]);
  }

  addServer(server, virtualNodes = 150) {
    for (let v = 0; v < virtualNodes; v++) {
      const key = this._hash(`${server}:${v}`);
      this.ring.set(key, server);
      this.sortedKeys.push(key);
    }
    this.sortedKeys.sort((a, b) => a - b);
  }

  removeServer(server, virtualNodes = 150) {
    for (let v = 0; v < virtualNodes; v++) {
      const key = this._hash(`${server}:${v}`);
      this.ring.delete(key);
    }
    this.sortedKeys = this.sortedKeys.filter((k) => this.ring.has(k));
  }
}

const ring = new ConsistentHashRing(["s1:3001", "s2:3002", "s3:3003"]);

// Same request key always routes to same server
console.log(ring.getServer("user:42")); // e.g. s2:3002
console.log(ring.getServer("user:42")); // s2:3002 — consistent ✅

// Add a server — only ~25% of keys remapped
ring.addServer("s4:3004");
console.log(ring.getServer("user:42")); // may stay s2:3002 or move to s4:3004

Health Checks

A load balancer is only useful if it stops sending traffic to dead servers.

javascript
class HealthAwareBalancer {
  constructor(servers, checkIntervalMs = 5000) {
    this.all = servers.map((host) => ({ host, healthy: true, failures: 0 }));
    this._startHealthChecks(checkIntervalMs);
  }

  get healthy() {
    return this.all.filter((s) => s.healthy);
  }

  next() {
    const pool = this.healthy;
    if (!pool.length) throw new Error("No healthy servers available");
    // Round-robin over healthy servers only
    this._rr = ((this._rr ?? -1) + 1) % pool.length;
    return pool[this._rr].host;
  }

  _startHealthChecks(intervalMs) {
    setInterval(async () => {
      await Promise.all(this.all.map((s) => this._check(s)));
    }, intervalMs);
  }

  async _check(server) {
    try {
      const res = await fetch(`http://${server.host}/health`, {
        signal: AbortSignal.timeout(2000),
      });
      if (res.ok) {
        server.failures = 0;
        server.healthy = true;
      } else {
        this._handleFailure(server);
      }
    } catch {
      this._handleFailure(server);
    }
  }

  _handleFailure(server) {
    server.failures++;
    if (server.failures >= 3) {
      server.healthy = false;
      console.warn(`[LB] ${server.host} marked unhealthy after 3 failures`);
    }
  }
}

Complete: Minimal HTTP Load Balancer in Node.js

This is a working Layer 7 proxy that combines round-robin, health checks, and retry logic in ~80 lines.

javascript
const http = require("http");
const { request: proxyReq } = require("http");

const SERVERS = [
  { host: "localhost", port: 3001, healthy: true, failures: 0 },
  { host: "localhost", port: 3002, healthy: true, failures: 0 },
  { host: "localhost", port: 3003, healthy: true, failures: 0 },
];

let rrIndex = 0;

function pickServer() {
  const pool = SERVERS.filter((s) => s.healthy);
  if (!pool.length) return null;
  const server = pool[rrIndex % pool.length];
  rrIndex++;
  return server;
}

function forward(req, res, server) {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: server.host,
      port: server.port,
      path: req.url,
      method: req.method,
      headers: {
        ...req.headers,
        "x-forwarded-for": req.socket.remoteAddress,
        host: `${server.host}:${server.port}`,
      },
    };

    const proxy = proxyReq(options, (upstream) => {
      res.writeHead(upstream.statusCode, upstream.headers);
      upstream.pipe(res);
      resolve();
    });

    proxy.on("error", reject);
    req.pipe(proxy);
  });
}

// Main proxy server
const lb = http.createServer(async (req, res) => {
  const server = pickServer();

  if (!server) {
    res.writeHead(503);
    return res.end("No healthy servers");
  }

  try {
    await forward(req, res, server);
    server.failures = 0;
  } catch (err) {
    server.failures++;
    if (server.failures >= 3) {
      server.healthy = false;
      console.error(`[LB] ${server.host}:${server.port} removed from pool`);
    }
    res.writeHead(502);
    res.end("Bad Gateway");
  }
});

// Health check loop
setInterval(async () => {
  for (const server of SERVERS) {
    try {
      await new Promise((resolve, reject) => {
        const req = proxyReq(
          {
            hostname: server.host,
            port: server.port,
            path: "/health",
            method: "GET",
          },
          (r) => (r.statusCode === 200 ? resolve() : reject())
        );
        req.on("error", reject);
        req.setTimeout(2000, () => {
          req.destroy();
          reject();
        });
        req.end();
      });
      if (!server.healthy) {
        server.healthy = true;
        server.failures = 0;
        console.log(`[LB] ${server.host}:${server.port} recovered ✅`);
      }
    } catch {
      server.healthy = false;
    }
  }
}, 5000);

lb.listen(8080, () => console.log("[LB] Listening on :8080"));

Real-World Architecture: Multi-Tier Load Balancing

Production systems typically stack two layers of load balancing.


Algorithm Comparison

AlgorithmBest ForDownside
Round RobinUniform servers, short requestsIgnores server load
Weighted Round RobinMixed server capacitiesWeights need manual tuning
Least ConnectionsLong-lived connections, file uploadsRequires shared state
IP HashSimple session stickinessUneven if few client IPs
Consistent HashingCaches, sharded data, microservicesMore complex to implement
RandomStateless services, large server poolsCan cause hot spots

Sticky Sessions vs. Stateless Design

Rule of thumb: Prefer stateless architecture backed by Redis or a database over sticky sessions. Stickiness is a workaround; statelessness is a design principle.


Common Load Balancer Tools

ToolLayerUse Case
NginxL4 + L7Most common, easy config
HAProxyL4 + L7High-performance TCP/HTTP balancing
AWS ALBL7AWS-native, path/header routing
AWS NLBL4Ultra-low latency TCP/UDP
CloudflareL7Global, with DDoS protection
EnvoyL4 + L7Service mesh (Istio, Consul)
TraefikL7Kubernetes-native, auto-discovery

✅ Checklist Before Moving On

  • [ ] I can explain the difference between L4 and L7 load balancing
  • [ ] I know when to use Round Robin vs Least Connections
  • [ ] I understand why Consistent Hashing is better than IP Hash for dynamic pools
  • [ ] I can describe how health checks keep a pool clean
  • [ ] I know why stateless services scale better than sticky sessions

➡️ Next: Level 3 — Databases

Released under the ISC License.