Scaling: load balancing, autoscaling and stateless design
Horizontal versus vertical scaling, sessions and shared state, target-tracking autoscaling, draining connections, and load testing before launch day.
Horizontal, vertical and stateless
| Approach | How | Ceiling | Complexity |
|---|---|---|---|
| Vertical | A bigger machine | The largest instance available | None until the ceiling |
| Horizontal | More instances behind a balancer | Effectively none | Shared state, sticky sessions, cache coherence |
| Read replicas | Send reads to copies | Limited by replication lag tolerance | Routing reads and accepting staleness |
| Cache layer | Serve from memory at the edge | Redis or a CDN | Invalidation |
| Async | Move work off the request path | Queue depth becomes the limit | Idempotency and retries |
The order that works: make the application stateless, add a cache, move slow work to a queue, and only then add instances. Scaling a slow application horizontally multiplies the slowness and the cost.
State that breaks horizontal scaling
sessions in a process's memory -> move to a shared store or a signed cookie
uploaded files on local disk -> move to object storage
in-process cache as the source of truth -> use it as a cache, not as a store
a scheduled job in the web process -> one scheduler, or a distributed lock
local temp files between requests -> no assumption survives with two instancesBalancing and autoscaling
# target-tracking autoscaling: keep one metric near a target
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: app
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: app
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 65
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # ignore a brief dip before removing capacity
policies:
- type: Percent
value: 25
periodSeconds: 60- Three instances is a better minimum than one: a single instance is a single point of failure, and two means losing one halves capacity.
stabilizationWindowSecondsprevents flapping. Without it a workload that oscillates between 60 and 80 percent utilisation adds and removes instances continuously.- Scale on the metric that reflects the bottleneck. If the database is the limit, scaling the web tier makes the database slower.
- Set a maximum that the database can actually serve. Autoscaling into a connection limit is a self-inflicted outage.
# graceful removal: stop sending new requests, then wait for in-flight ones
# in nginx, the equivalent is a slow shutdown with keepalive drained
upstream app {
server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
server 10.0.1.12:3000 max_fails=3 fail_timeout=30s;
keepalive 32;
}Draining, caches and load testing
- On shutdown, mark the instance unhealthy so the balancer stops routing to it.
- Wait one or two health-check intervals, then stop accepting new connections.
- Let in-flight requests finish, up to a bounded grace period.
- Close the process. A shutdown that takes longer than the grace period gets killed mid-request.
- On start-up, do not mark ready until the pool is warm and the first request would succeed.
const server = app.listen(3000);
let shuttingDown = false;
process.on("SIGTERM", async () => {
shuttingDown = true;
health.markNotReady(); // fail readiness immediately
await sleep(2000); // let the balancer notice
server.close(async () => { // stop accepting new connections
await db.end();
process.exit(0);
});
setTimeout(() => process.exit(1), 15000).unref(); // hard bound
});| Symptom | Cause | Fix |
|---|---|---|
| Users logged out on deploy | Sessions in process memory | Shared session store or signed cookies |
| Duplicate emails after a deploy | Two instances ran the scheduler | One scheduler plus a lock |
| Growing 5xx during a deploy | Instances killed before draining | Readiness gate plus a grace period |
| Cache returns stale data after a write | No invalidation on the key | Invalidate on write, or version the key |
| Load test passes, production fails | Tested a single instance or excluded the database | Test the whole path with realistic data volume |
| Scaling does not improve latency | The database is the bottleneck | Index, cache or split the query |
⚠️
A load test that only exercises a cached anonymous page proves nothing about the path that matters. Test the heaviest realistic mix - authenticated writes, search, and the checkout - against a database with production-sized data. A test on an empty database is a lie that costs a launch.
FAQ
Should I use sticky sessions?
Avoid them. They hide state problems until an instance fails and takes those users' sessions with it. Fix the state, then balancing is trivial.
How do I know when to scale?
When a measured resource is the bottleneck and the application is already efficient. Adding capacity to an unindexed query or a missing cache buys a week and costs money every month.
Related
Monitoring, uptime and log management Containers on a budget: Docker and managed container hosts
Last refreshed 2026-09-18.