Smart Stick Load Balancer
A lightweight sticky-session load balancer for Node.js with health checks, automatic failover, WebSocket support, configurable routing strategies, and optional email alerts.
Designed for developers who want a simple load-balancing solution directly inside the Node.js ecosystem - no Nginx, no HAProxy, no external configuration.
Why this package?
Most Node developers don't want to configure Nginx or HAProxy just to balance requests during development or for small deployments.
Smart Stick Load Balancer provides:
- sticky sessions
- health monitoring
- automatic failover
- WebSocket support
directly inside your Node.js application with a simple JSON configuration.
Table of Contents
- Why this package?
- Features
- Architecture
- Installation
- Project Structure
- Configuration
- Usage
- Routing Strategies
- Health Checks
- Weights
- How It Works
- Sticky Sessions
- Local Development Demo
- Health Endpoint
- Email Alerts
- Use Cases
- Notes
- License
Features
- Sticky sessions using cookies
- Three routing strategies:
round-robin,least-connections,random - Weight support - send more traffic to stronger backends
- Configurable health check path per backend
- Configurable health check algorithm (
http,http-status, or your own function) - Automatic backend health checks with configurable interval
- Automatic failover and recovery
- Active connection tracking per backend
- WebSocket and HTTP support
- Optional email alerts when backends go down or recover
- Simple JSON configuration
- Pure Node.js implementation with no dependency on external load balancers such as Nginx or HAProxy.
Architecture
Client Requests
│
▼
Smart Stick Load Balancer
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
Backend A Backend B Backend C
│ │ │
/health /ready /health
▲ ▲ ▲
└──── Health Checker ─────────┘
The load balancer continuously monitors backend health, routes new requests using the configured strategy, and preserves sticky sessions using cookies. The cookie stores the assigned backend's identifier and is automatically updated if failover occurs.
Installation
npm install smart-stick-loadbalancer
Requirements
- Node.js 18 or later
- npm
The package relies on modern Node.js features such as the built-in fetch() API.
Project Structure
Create a config.json and index.js inside your own project folder:
my-project/
├── config.json ← your configuration
└── index.js ← your entry point
Never commit
config.jsonto a public repository if it contains email credentials. Add it to your.gitignore:config.json
Configuration
config.json
{
"port": 3001,
"strategy": "round-robin",
"backends": [
{
"id": 0,
"url": "http://localhost:5000",
"weight": 2,
"healthPath": "/health",
"healthMethod": "GET",
"owner": "your-email@example.com"
},
{
"id": 1,
"url": "http://localhost:5001",
"weight": 1,
"healthPath": "/ready",
"healthMethod": "HEAD",
"healthTimeout": 5000,
"owner": "your-email@example.com"
}
],
"health": {
"interval": 10000,
"timeout": 2000,
"algorithm": "http-status"
},
"email": {
"service": "gmail",
"auth": {
"user": "your-email@gmail.com",
"pass": "your-app-password"
}
}
}
Config fields
| Field | Required | Default | Description |
|---|---|---|---|
port |
Yes | - | Port the load balancer listens on |
strategy |
No | "round-robin" |
Routing strategy: "round-robin", "least-connections", "random" |
backends |
Yes | - | Array of backend servers (minimum 1) |
backends[].id |
Yes | - | Unique integer ID for each backend |
backends[].url |
Yes | - | Full URL of the backend server |
backends[].weight |
No | 1 |
Relative traffic weight (higher = more requests) |
backends[].owner |
No | - | Email to notify when this backend changes state (requires email config) |
backends[].healthPath |
No | "/" |
Health check path - overrides global health.path |
backends[].healthMethod |
No | "GET" |
HTTP method for health checks - overrides global health.method |
backends[].healthAlgorithm |
No | "http-status" |
Health algorithm - overrides global health.algorithm |
backends[].healthTimeout |
No | 2000 |
Timeout in ms - overrides global health.timeout |
backends[].healthHeaders |
No | {} |
Headers sent with health check requests - overrides global health.headers |
backends[].healthCheck |
No | - | Custom check function for this backend - overrides global health.check (JS only) |
health.interval |
No | 10000 |
How often to run health checks, in ms (no per-backend override) |
health.path |
No | "/" |
Default health check path for all backends |
health.method |
No | "GET" |
Default HTTP method for all health checks |
health.algorithm |
No | "http-status" |
Default algorithm: "http" (any response) or "http-status" (2xx only) |
health.timeout |
No | 2000 |
Default timeout in ms for health check requests |
health.headers |
No | {} |
Default headers sent with all health check requests |
health.check |
No | - | Default custom check function for all backends (JS only) |
email |
No | - | Nodemailer config - omit entirely to disable email alerts |
Usage
index.js
const { createStickyProxy } = require("smart-stick-loadbalancer");
const config = require("./config.json");
const lb = createStickyProxy(config);
lb.start();
Routing Strategies
round-robin (default)
Requests cycle through backends in order. Weight controls how many slots each backend gets in the rotation.
Backend A (weight: 2), Backend B (weight: 1)
Request order: A → A → B → A → A → B → ...
Recommended for: general use, backends with similar but not identical capacity.
least-connections
Each request goes to the backend currently handling the fewest active connections.
"strategy": "least-connections"
Recommended for: backends where some requests take much longer than others - file uploads, long polling, slow queries. Naturally balances load without needing weights.
random
Picks a backend at random. Weight controls probability - a backend with weight: 3 is 3x more likely to be chosen than one with weight: 1.
"strategy": "random"
Recommended for: testing, simple distribution, situations where order doesn't matter.
Health Checks
Every health check setting can be set globally under health: and overridden per backend. Per-backend always wins.
Per-backend health path
By default the health checker hits / on each backend. Point each backend to its own endpoint:
"backends": [
{ "id": 0, "url": "http://localhost:5000", "healthPath": "/health" },
{ "id": 1, "url": "http://localhost:5001", "healthPath": "/ready" },
{ "id": 2, "url": "http://localhost:5002", "healthPath": "/ping" }
]
Or set a global default that all backends fall back to:
"health": { "path": "/health" }
Per-backend HTTP method
Some health endpoints respond only to HEAD (faster, no body). Others need GET. Set per backend or globally:
"backends": [
{ "id": 0, "url": "http://localhost:5000", "healthMethod": "HEAD" },
{ "id": 1, "url": "http://localhost:5001", "healthMethod": "GET" }
]
Per-backend algorithm
"http-status" (default) - healthy only if the response is 2xx. Recommended.
"http" - healthy if any response comes back at all, regardless of status code. Use this for backends that return non-2xx on their health path but are actually running.
"backends": [
{ "id": 0, "url": "http://localhost:5000", "healthAlgorithm": "http-status" },
{ "id": 1, "url": "http://localhost:5001", "healthAlgorithm": "http" }
]
Per-backend timeout
A slow database-heavy backend might need a longer timeout before being declared unhealthy:
"backends": [
{ "id": 0, "url": "http://localhost:5000", "healthTimeout": 1000 },
{ "id": 1, "url": "http://localhost:5001", "healthTimeout": 5000 }
]
Per-backend health headers
Some backends require an auth token or API key to respond to health checks:
"backends": [
{
"id": 0,
"url": "http://localhost:5000",
"healthHeaders": { "X-Health-Token": "secret123" }
}
]
Or set global headers for all backends:
"health": {
"headers": { "X-Internal": "true" }
}
Custom check function
For full control, provide a check function in JS config. Receives the backend object, returns true (healthy) or false (unhealthy). Can be set globally or per backend.
Global - applies to all backends that don't have their own healthCheck:
const config = {
port: 3001,
backends: [
{ id: 0, url: "http://localhost:5000" },
{ id: 1, url: "http://localhost:5001" }
],
health: {
interval: 10000,
check: async (backend) => {
const res = await fetch(`${backend.url}/health`);
const data = await res.json();
return data.status === "ok" && data.dbConnected === true;
}
}
};
Per-backend - override the global function for one specific backend:
const config = {
port: 3001,
backends: [
{
id: 0,
url: "http://localhost:5000",
healthCheck: async (backend) => {
// This backend needs a DB check
const res = await fetch(`${backend.url}/health`);
const data = await res.json();
return data.status === "ok" && data.dbConnected === true;
}
},
{
id: 1,
url: "http://localhost:5001",
// No healthCheck - falls back to global health.check or built-in algorithm
}
],
health: { interval: 10000 }
};
Custom check functions are only available when passing config as a JS object. They cannot be expressed in JSON.
Weights
The weight field controls how much traffic a backend receives relative to others.
"backends": [
{ "id": 0, "url": "http://localhost:5000", "weight": 3 },
{ "id": 1, "url": "http://localhost:5001", "weight": 1 }
]
Backend 0 receives 3x as many requests as backend 1. Useful when servers have different capacity.
Weight works with round-robin and random. For least-connections, traffic naturally flows to the least-busy backend without needing weights.
How It Works
- An incoming request arrives at the load balancer.
- If the client has a sticky session cookie pointing to a healthy backend, that backend is used.
- If no valid sticky cookie exists, a backend is selected using the configured strategy.
- A cookie is set so future requests from the same client go to the same backend.
- Health checks run in the background at the configured interval, hitting each backend's
healthPath. - When a backend goes down, it is removed from rotation and the weighted pool is rebuilt.
- When a backend recovers, it is added back to rotation.
- If all backends go down, the load balancer returns
503 No healthy backends available.
Sticky Sessions
To keep users connected to the same backend across multiple requests, the load balancer uses an HTTP cookie.
How it works
- A client's first request is routed using the configured strategy.
- The selected backend's ID is stored in a cookie.
- Future requests from the same client are sent to the same backend while it remains healthy.
- If that backend becomes unavailable, the client is automatically reassigned to another healthy backend and the cookie is updated.
Sticky sessions improve compatibility with applications that store user state in memory, such as login sessions, shopping carts, or WebSocket connections.
If all backends become unavailable, the load balancer returns 503 Service Unavailable until a healthy backend is detected.
Local Development Demo
Step 1 - Create two backend servers
// server5000.js
const express = require("express");
const app = express();
app.get("/", (req, res) => res.send("Hello from Server 5000"));
app.get("/health", (req, res) => res.json({ status: "ok" }));
app.listen(5000, () => console.log("Server 5000 running"));
// server5001.js
const express = require("express");
const app = express();
app.get("/", (req, res) => res.send("Hello from Server 5001"));
app.get("/health", (req, res) => res.json({ status: "ok" }));
app.listen(5001, () => console.log("Server 5001 running"));
Step 2 - Start the backends
node server5000.js
node server5001.js
Step 3 - Start the load balancer
node index.js
Step 4 - Open in your browser
http://localhost:3001
Your session stays on the same backend. Check the x-backend-id response header to see which backend handled each request.
Health Endpoint
This endpoint is intended for monitoring and debugging only. Check the live status of all backends:
curl http://localhost:3001/_lb/health
Example response:
{
"timestamp": "2025-12-28T12:00:00.000Z",
"strategy": "round-robin",
"total": 2,
"healthy": 2,
"unhealthy": 0,
"backends": [
{
"id": 0,
"url": "http://localhost:5000",
"healthy": true,
"weight": 2,
"requests": 10,
"activeConnections": 1,
"lastChecked": "2025-12-28T12:00:00.000Z",
"healthCheck": {
"path": "/health",
"method": "GET",
"algorithm": "http-status",
"timeout": 2000,
"hasCustomHeaders": false,
"hasCustomFunction": false
}
},
{
"id": 1,
"url": "http://localhost:5001",
"healthy": true,
"weight": 1,
"requests": 5,
"activeConnections": 0,
"lastChecked": "2025-12-28T12:00:00.000Z",
"healthCheck": {
"path": "/ready",
"method": "HEAD",
"algorithm": "http-status",
"timeout": 5000,
"hasCustomHeaders": true,
"hasCustomFunction": false
}
}
]
}
Email Alerts
When email is present in the config, an alert is sent to each backend's owner address when it goes down or comes back online.
- Email alerts are implemented using Nodemailer.
- Supports Gmail and other compatible SMTP providers.
- For Gmail, use an App Password rather than your account password
Attention
- Email alerts are optional.
- Some hosting providers may restrict SMTP connections.
- If email alerts are unavailable in your environment, simply omit the
emailconfiguration and the load balancer will continue to function normally.
Use Cases
- Learning how load balancers and routing strategies work
- Local development with multiple backend instances
- Internal tools and prototypes
- Small Node.js deployments that don't need Nginx
Notes
- Backend
idvalues must be unique integers. - Sticky sessions take priority over strategy - a client always returns to their assigned backend if it is still healthy.
- WebSocket proxying is supported out of the box.
- If all backends go down, the load balancer returns
503 No healthy backends available. - The
custom checkfunction is only available when passing config as a JS object - it cannot be expressed in JSON.
License
MIT