Hey everyone, I welcome you to witness one more learning of mine:
The Load Balancer!!! (Balancaahhh!!! If you prefer Mikasa's voice)
But before we jump into this cool topic of the day, we gotta learn a few terminologies first.
(Get behind me, kitten, things are gonna get wild now [smirks])
First, What is a Proxy?
A proxy is something that sits between the client and the server.
Normally:
client → server
With a Proxy:
client → Proxy → server
The client thinks it's talking to the proxy. But the proxy quietly forwards it to the real server. The client never gets to know. Classic middleman behavior.
Now — What is a Reverse Proxy?
A reverse proxy is kinda similar to a proxy, but it sits in front of the servers instead of the clients. While a regular proxy hides the client, a reverse proxy hides the servers.
3... 2... 1... What is a Load Balancer?
In layman's terms, a load balancer is something that acts as a proxy and a traffic director. It helps the system choose the correct server by using different algorithms and performing regular health checks.
┌→ Server 1 (port 3001)
Client → Load ─────┼→ Server 2 (port 3002)
Balancer └→ Server 3 (port 3003)
In this article, I'll share my implementation of a load balancer. The code in production will vary — use this as a reference and a learning tool.
The Code
servers.js — The actual backend servers
These are the servers we want our client connections redirected to.
import express from 'express';
const PORTS = [3001, 3002, 3003];
PORTS.map((port) => {
const app = express();
app.get('/status', (req, res) => {
res.json({ message: `Server running on port ${port}`, port });
});
app.get('/health', (req, res) => {
res.json({ status: 'healthy', port });
});
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
});
I started 3 servers on ports 3001, 3002, 3003. You can choose your own ports.
Note: Running all 3 in one file means closing the program kills all servers. For a more production-like feel, create 3 separate files and run each independently.
loadBalancer.js — The main event
import express from 'express';
import http from 'http';
// Track our backend servers
const servers = [
{ host: 'localhost', port: 3001, isAlive: true, connections: 0 },
{ host: 'localhost', port: 3002, isAlive: true, connections: 0 },
{ host: 'localhost', port: 3003, isAlive: true, connections: 0 },
];
Our load balancer tracks each server. In production, this list might come from a service registry or config file, but the idea is the same.
The Round Robin Algorithm
let currentIndex = 0;
const roundRobin = () => {
const aliveServers = servers.filter(server => server.isAlive);
if (aliveServers.length === 0) throw new Error('No alive servers');
const server = aliveServers[currentIndex % aliveServers.length];
currentIndex++;
return server;
};
We filter dead servers first — no point routing to a corpse. Then currentIndex % aliveServers.length handles the rotation:
3 servers alive:
request 1 → 0 % 3 = 0 → server 3001
request 2 → 1 % 3 = 1 → server 3002
request 3 → 2 % 3 = 2 → server 3003
request 4 → 3 % 3 = 0 → server 3001 ← back to start
Infinite fair rotation. Simple but effective.
Health Checks — The Doctor in the House
async function checkHealth() {
for (const server of servers) {
const req = http.request(
{
host: server.host,
port: server.port,
path: '/health',
method: 'GET',
timeout: 2000,
},
(res) => {
const wasUnhealthy = !server.isAlive;
server.isAlive = res.statusCode === 200;
if (wasUnhealthy && server.isAlive) {
console.log(`Server ${server.port} is back online`);
}
}
);
req.on('error', () => {
if (server.isAlive) console.log(`Server ${server.port} is DOWN`);
server.isAlive = false;
});
req.on('timeout', () => {
req.destroy();
server.isAlive = false;
});
req.end();
}
}
// Run every 10 seconds
setInterval(checkHealth, 10000);
checkHealth(); // run immediately on startup
Every 10 seconds, the load balancer pings /health on each backend. Gets a 200? Alive. Times out or errors? Dead — removed from rotation automatically.
The beautiful part — was Unhealthy && server.isAlive` detects resurrection. When a dead server comes back online, we log it and start routing to it again. No manual intervention needed.
The Proxy — Where the Magic Happens
`js
const loadBalancer = express();
// Stats endpoint — must come BEFORE the catch-all
loadBalancer.get('/stats', (req, res) => {
res.json(servers);
});
// Catch-all — forward every request to a backend
loadBalancer.use((req, res) => {
let server;
try {
server = roundRobin();
server.connections++;
} catch (error) {
return res.status(503).json({ error: 'All servers are down' });
}
const options = {
host: server.host,
port: server.port,
method: req.method,
path: req.url,
headers: {
...req.headers,
'x-forwarded-for': req.socket.remoteAddress,
},
};
const proxyReq = http.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res); // backend → client
proxyRes.on('end', () => {
server.connections--;
});
});
req.pipe(proxyReq); // client → backend
proxyReq.on('error', () => {
server.connections--;
server.isAlive = false;
console.log(Server ${server.port} is down);
if (!res.headersSent) {
res.status(502).json({ error: 'Bad gateway' });
}
});
});
loadBalancer.listen(3000, () => {
console.log('Load balancer running on port 3000');
});
`
What's Actually Happening Here?
options — the blueprint for the forwarded request. We copy everything from the original — method, URL path, all headers. Plus, we add x-forwarded-for, which tells the backend the real client IP. Without it, every request looks like it came from 127.0.0.1 (the load balancer).
http.request(options, callback) — the load balancer makes a brand new HTTP request to the chosen backend. Like a middleman calling the restaurant on your behalf.
proxyRes.pipe(res) — when the backend responds, we don't buffer it. We pipe it — think of it like a water pipe connecting two ends. Data flows from the backend directly to the client, chunk by chunk. Memory efficient. Fast.
req.pipe(proxyReq) — same idea but the other direction. If the client sent a POST request with a body (JSON data), we pipe it to the backend. Without this line, POST request bodies arrive at the backend empty.
server.connections-- — when the response is fully sent, the connection counter drops. This server is free again.
The Stats Endpoint — Your Window Into the Matrix
bash
curl http://localhost:3000/stats
json
[
{ "host": "localhost", "port": 3001, "isAlive": true, "connections": 2 },
{ "host": "localhost", "port": 3002, "isAlive": true, "connections": 1 },
{ "host": "localhost", "port": 3003, "isAlive": false, "connections": 0 }
]
Real-time state of every server — alive or dead, active connections. Your load balancer's heartbeat monitor.
Test It
`bash
Terminal 1 — start all backend servers
node servers.js
Terminal 2 — start load balancer
node loadBalancer.js
Terminal 3 — fire 9 requests
for i in {1..9}; do curl http://localhost:3000/status; echo ""; done
`
You'll see requests rotating across 3001, 3002, and 3003 in perfect order.
Now kill server 3001. Wait 10 seconds. The load balancer logs Server 3001 is DOWN. Send more requests — they only go to 3002 and 3003.
Bring 3001 back. Wait 10 seconds. It's back in rotation. The system healed itself.
THAT is why load balancers exist. Not just traffic distribution — self-healing infrastructure.
Other Algorithms Worth Knowing
Least Connections — always pick the server with the fewest active connections. Better when requests have varying processing times. Just replace roundRobin() with:
js
const leastConnections = () => {
const aliveServers = servers.filter(s => s.isAlive);
if (aliveServers.length === 0) throw new Error('No alive servers');
return aliveServers.reduce((min, s) =>
s.connections < min.connections ? s : min
);
};
IP Hash — hash the client's IP to always route them to the same server. Useful for sticky sessions — but breaks if you store session data in memory.
Weighted Round Robin — give more powerful servers a higher weight so they receive proportionally more traffic.
Reality Check
This is a learning implementation. In production you'd use Nginx or AWS ALB — they do everything above plus SSL termination, DDoS protection, and handle millions of requests per second.
But now you know what they're doing under the hood.
And that's what matters.
Still documenting my backend journey publicly — next up is Circuit Breakers, Kafka, Docker, and system design at scale.
Follow along on X: [@theoblivied]
GitHub: [https://github.com/Rick13211]
Top comments (0)