Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

24 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Load Balancer - Golang

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.

Contents

Features

  • 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

Architecture

The balancer is built around a few small components:

Client --> Load Balancer --> Reverse Proxy --> Backend Server
                 |                |
                 |                +--> health check loop
                 +--> Admin API

Main components

  • 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

Project Structure

.
├── 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

Getting Started

Prerequisites

  • Go 1.25 or newer
  • k6 (optional, for load testing)

1. Clone and build

git clone https://github.com/Darshik924/load-balancer.git
cd load
go build ./...

2. Start the dummy backends

Run the three backend servers in separate terminals:

go run ./servers/s1 8081
go run ./servers/s2 8082
go run ./servers/s3 8083

3. Start the load balancer

go run .

The balancer will read its configuration from config/config.json and start on the configured port.

4. Send a test request

curl http://localhost:8080/

You should receive a response from one of the configured backend servers.

Configuration

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
    }
  ]
}

Configuration fields

  • 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

Admin API

The balancer exposes a small admin interface for runtime server management.

Authentication

All admin routes require a bearer token in the Authorization header:

Authorization: Bearer your-secret-token

Endpoints

  • 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

Example requests

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"

Load Balancing Algorithm

The current strategy uses a weighted least-connections approach.

For each healthy backend, the balancer computes:

$$ score = \frac{connections}{weight} $$

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

Load Testing

The repository includes k6 test scripts under loadtest/scripts to exercise the balancer in a few scenarios.

Test setup

  • 3 backends configured in config/config.json
  • k6 driven requests against the balancer at http://localhost:8080
  • screenshots stored in loadtest/screenshots/output

1. Load distribution test

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;

Load distribution result

Result: requests were distributed successfully across the available backends and the balancer returned HTTP 200 responses for the test workload.

2. Failover resilience test

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.

Failover resilience result

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.

3. Stress test

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;

Stress test result

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

50 VU run

50 VU run summary

200 VU run

200 VU run summary

500 VU run

500 VU run summary

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.

Notes

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.

Verification

The current project builds successfully with:

go build ./...

About

A lightweight, configurable load balancer written in Go from scatch. He sits in front of multiple backend services, forwards requests using a efficient strategy and performs background health checks.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages