A load balancer sits in front of multiple servers and spreads incoming traffic across them. It's what lets you run more than one copy of your app behind a single address — the foundation of both handling more traffic and surviving a server failure. If Cartara flagged this in your diff, you likely changed something related to how your app handles multiple instances, session state, or health checks.
Why you need one
A single server has two problems: it can only handle so much traffic, and if it dies, your app goes down. Running multiple servers solves both — but now something has to decide which server handles each request and stop sending traffic to any server that fails. That's the load balancer.
It delivers two things at once:
- Scalability — add more servers behind the balancer to handle more load
- Availability — if one server goes down, the balancer routes around it automatically
This only works because your app servers need to be stateless: any server can handle any request, because session data lives in shared storage (a database or Redis) rather than on one specific machine.
Layer 4 vs Layer 7
Load balancers operate at one of two levels:
- Layer 4 (transport) — routes by IP address and port, without looking at request content. Very fast, but can't make decisions based on URLs or headers
- Layer 7 (application) — understands HTTP. Can route
/api/*to one pool of servers and/images/*to another, terminate TLS, and inspect headers
Most web apps use Layer 7 because content-aware routing is so useful.
How the balancer picks a server
- Round robin — hands requests to servers in rotation. Works well when servers are equal and requests are similar in cost
- Weighted round robin — gives more powerful servers a larger share
- Least connections — sends the next request to whichever server is handling the fewest active requests. Better when request duration varies a lot
- IP hash — routes based on the client's IP, so a given user consistently hits the same server
Round robin and least connections cover most cases. Least connections is the safer default when request cost is uneven.
Health checks
A load balancer must know which servers are actually healthy — otherwise it'll keep sending traffic into a broken instance. It does this by periodically pinging each server (usually a /health endpoint) and removing any that fail from the rotation, adding them back when they recover.
Give your app a lightweight health endpoint that genuinely reflects whether it can serve requests — not just "the process is running" but "can I reach the database."
Sticky sessions: when and why to avoid them
Sometimes you want a given user to keep hitting the same server — for example if that server holds some in-memory session state. Sticky sessions (session affinity) do this.
The honest advice: prefer to avoid needing them. Sticky sessions undermine the stateless model — they make load distribution uneven and mean a server failure logs out affected users. The better approach is to keep session state in a shared store (Redis, a database) so any server can serve any user. Reach for sticky sessions only when you genuinely can't make the app stateless.
How this fits into the broader request path
A typical request path looks like:
```
CDN (edge cache) → Load balancer / API gateway → App servers → Database
```
- Reverse proxy — a server that receives client requests and forwards them to backends (Nginx, HAProxy). A load balancer is essentially a reverse proxy that distributes across many backends; the terms overlap heavily
- API gateway — adds API-specific concerns: authentication, rate limiting, request shaping. Common in multi-service setups
- CDN — serves cached content close to users before traffic ever reaches your load balancer
What You'll See in Your Code
Cartara often flags stateful patterns that break horizontal scaling. A common example is storing session data in memory:
```javascript
// Problematic: session lives in server memory
// Works fine with one server, breaks with multiple
const sessions = {}
app.post('/login', (req, res) => {
const sessionId = generateId()
sessions[sessionId] = { userId: req.body.userId } // stored in memory
res.cookie('sessionId', sessionId)
res.json({ ok: true })
})
// Better: session stored in Redis (shared across all servers)
import { redis } from './redis'
app.post('/login', async (req, res) => {
const sessionId = generateId()
await redis.set(session:${sessionId}, JSON.stringify({ userId: req.body.userId }), 'EX', 86400)
res.cookie('sessionId', sessionId)
res.json({ ok: true })
})
```
A health check endpoint the load balancer can call:
```javascript
app.get('/health', async (req, res) => {
try {
await db.$queryRawSELECT 1 // verify DB connectivity
res.json({ status: 'ok' })
} catch (err) {
res.status(503).json({ status: 'unhealthy', error: err.message })
}
})
```
For builders on managed platforms
If you're on Vercel, Railway, Render, or similar — you already have load balancing without thinking about it. These platforms run multiple instances of your app behind a single URL automatically. What's worth doing deliberately:
- Keep your app stateless so instances can scale horizontally
- Expose a real health-check endpoint
- Store sessions in a shared store, not in server memory
The infrastructure is handled; the architectural discipline is on you.