A lightweight, configurable load balancer written in Go from scatch. It sits in front of multiple backend services, forwards requests using a weighted least-connections strategy, performs background health checks, and exposes simple admin endpoints for runtime server management.
Built as a part of learning systems and concurrency in Golang with a focus on why each piece work is neccessary.It is intentionally simple and educational, with three dummy backend servers under the servers folder so you can run the whole stack locally.
- Features
- Architecture
- Project Structure
- Getting Started
- Configuration
- Admin API
- Load Balancing Algorithm
- Load Testing
- Notes
- Verification
- Weighted load balancing with a least-connections algorithm
- Round-robin tie-breaking when multiple backends have the same score
- Reverse proxy forwarding for incoming traffic
- Background health checking for each backend
- Dynamic backend management through admin endpoints
- Config-driven setup from a JSON file
- Example k6 load tests for distribution proof, failure, and stress scenarios
The balancer is built around a few small components:
Client --> Load Balancer --> Reverse Proxy --> Backend Server
| |
| +--> health check loop
+--> Admin API
- Load balancer start: handles incoming traffic and picks up a backend
- Server pool: stores all configured backends and has thread-safe access
- Reverse proxy: forwards requests to the selected backend
- Health checker: periodically "pings" each backend and marks it down if it becomes unavailable and turns it on, after it is alive again
- Admin handler: adds, removes, or lists backends through authenticated HTTP endpoints
.
├── admin.go # Admin API handlers and server pool mutations
├── algorithm.go # Load balancing selection logic
├── health.go # Backend health probe and background checker
├── main.go # Core HTTP server, reverse proxy, and startup logic
├── config/
│ └── config.json # Balancer configuration
├── loadtest/
│ ├── scripts/ # k6 scripts for load testing
│ └── screenshots/ # Output images from the test runs
└── servers/
├── s1/main.go # Dummy backend 1
├── s2/main.go # Dummy backend 2
└── s3/main.go # Dummy backend 3
- Go 1.25 or newer
- k6 (optional, for load testing)
git clone https://github.com/Darshik924/load-balancer.git
cd load
go build ./...Run the three backend servers in separate terminals:
go run ./servers/s1 8081
go run ./servers/s2 8082
go run ./servers/s3 8083go run .The balancer will read its configuration from config/config.json and start on the configured port.
curl http://localhost:8080/You should receive a response from one of the configured backend servers.
The main configuration lives in config/config.json. This is the sample configuration, you can add/edit it as you prefer and duplicate servers in servers/
{
"port": "8080",
"health_check_interv": 10,
"admin_token": "your-secret-token",
"backends": [
{
"url": "localhost:8081",
"weight": 100
},
{
"url": "localhost:8082",
"weight": 50
},
{
"url": "localhost:8083",
"weight": 20
}
]
}- port: the port where the balancer listens
- health_check_interv: how often health checks run in seconds
- admin_token: bearer token required by the admin API
- backends: list of backend hosts and their weights
The balancer exposes a small admin interface for runtime server management.
All admin routes require a bearer token in the Authorization header:
Authorization: Bearer your-secret-token- GET /admin/servers
- returns the current backend list as JSON
- POST /admin/servers
- adds a new backend
- body example:
{
"url": "localhost:9090",
"weight": 30
}- DELETE /admin/servers/{url}
- removes a backend from the pool
curl -X GET http://localhost:8080/admin/servers \
-H "Authorization: Bearer your-secret-token"
curl -X POST http://localhost:8080/admin/servers \
-H "Authorization: Bearer your-secret-token" \
-H "Content-Type: application/json" \
-d '{"url":"localhost:9090","weight":30}'
curl -X DELETE http://localhost:8080/admin/servers/localhost:9090 \
-H "Authorization: Bearer your-secret-token"The current strategy uses a weighted least-connections approach.
For each healthy backend, the balancer computes:
The backend with the smallest score is selected. If multiple backends tie, the implementation falls back to round-robin selection to spread traffic more evenly among equal candidates.
This means:
- higher-weight backends receive proportionally more traffic
- busy backends are avoided when lighter ones are available
- ties are handled fairly using a counter-based rotation
The repository includes k6 test scripts under loadtest/scripts to exercise the balancer in a few scenarios.
- 3 backends configured in config/config.json
- k6 driven requests against the balancer at http://localhost:8080
- screenshots stored in loadtest/screenshots/output
Goal: confirm requests are distributed across all alive backends rather than concentrated on one.
// loadtest/scripts/loadDistriScript.js
import http from "k6/http";
import { check, sleep } from "k6";
const BASE_URL = "http://localhost:8080";
const options = {
vus: 3,
duration: "6s",
};
function vuCode() {
const res = http.get(BASE_URL);
check(res, { "status is 200": (r) => r.status === 200 });
console.log(`loadtester's body: ${res.body}`);
sleep(1);
}
export { options };
export default vuCode;Result: requests were distributed successfully across the available backends and the balancer returned HTTP 200 responses for the test workload.
Goal: To test this server by ramping traffic up, then simulates a backend failure during the run to confirm that the balancer detects the issue and keeps serving traffic through remaining healthy nodes.
// loadtest/scripts/failoverScript.js
import http from "k6/http";
import { check, sleep } from "k6";
const BASE_URL = "http://localhost:8080";
const options = {
stages: [
{ duration: "10s", target: 20 },
{ duration: "30s", target: 20 }, // Would kill a Backend Here
{ duration: "10s", target: 0 },
],
};
function vuCode() {
const res = http.get(BASE_URL);
check(res, { "status is 200": (r) => r.status === 200 });
sleep(1);
}
export { options };
export default vuCode;Procedure: started the test, killed backend 2 (Ctrl+C) partway through the sustained-load stage, observed recovery.
Result: 810 requests, 99.87% succeeded, 1 failure — the single in-flight request active at the exact kill instant. The reverse proxy's ErrorHandler finds that backend is dead immediately (passive detection) and rejects the specific request that was active on the killed backend. Every later requests correctly avoided the dead backend.
Goal: To test increasing concurrency in stages and observe how the system behaves under heavier load.
// loadtest/scripts/stressScript.js
import http from "k6/http";
import { check, sleep } from "k6";
const BASE_URL = "http://localhost:8080";
const options = {
stages: [
{ duration: "30s", target: 50 },
{ duration: "30s", target: 200 },
{ duration: "30s", target: 500 },
{ duration: "20s", target: 0 },
],
};
function vuCode() {
const res = http.get(BASE_URL);
check(res, { "status is 200": (r) => r.status === 200 });
sleep(1);
}
export { options };
export default vuCode;Result: the balancer stayed responsive under the staged stress profile and continued serving requests without collapsing the test run.
p95 latency across VU levels — measured via separate fixed-VU runs (k6 run --vus N --duration 30s stressScript.js) since the combined summary only reports an aggregate p95 across all stages:
| VUs | Requests | p95 latency | Error rate | Req/s |
|---|---|---|---|---|
| 50 | 1,500 | 6.39ms | 0.00% | 49.84 |
| 200 | 6,000 | 14.06ms | 0.00% | 198.78 |
| 500 | 15,000 | 27.66ms | 0.00% | 495.02 |
Result: 0% failed requests across every concurrency level tested — 50, 200, and 500 VUs. The p95 latency scaled sub-linearly with the load (6.39ms → 14.06ms → 27.66ms, roughly a 4x increase for a 10x increase in the concurrency), and throughput scaled almost proportionally with VUs (49.84 → 198.78 → 495.02 req/s), indicating the load balancer itself added very less overhead as compared to backend response time even as concurrent connections were growing.
This project is a basic starting point for building a reverse-proxy-based load balancer in Go. The implementation is deliberately simple, small and readable so you can extend it with more features.
The current project builds successfully with:
go build ./...




