Skip to content
Commitwise
Menu
System Design

Scaling and load balancing

Grow a backend from one server to many, keep servers stateless, spread traffic with a load balancer and health checks, and find the next bottleneck before it finds you, with examples in Kotlin, Java and TypeScript.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Your app is featured in the App Store, and traffic goes from 50 requests a second to 5,000 in an hour. The one server you have runs at 100% CPU, requests time out, and the app’s reviews start filling up with one-star ratings. “Add more servers” is the right answer, but it only works if the code was written for it, and if something in front of those servers knows how to share the work.

New to some of these? Open what you need, or skip ahead.

Basics:What are clients and servers?

A server is a computer (usually many of them) that runs your backend: it stores data and answers requests. A client is whatever sends the requests: your mobile app, a website, another server.

Big systems put many identical servers behind one address, with a load balancer in front that spreads requests between them. Data lives in a database, and frequently used data is often kept in a fast cache (in memory) so the database isn’t asked every time.

Basics:How do HTTP requests work?

Apps and servers talk with HTTP. A request has a method that says what to do (GET to read, POST to create, PUT to replace, DELETE to remove), a URL (https://api.example.com/books/42), headers (extra information, like who you are) and sometimes a body (the data, usually JSON).

The server sends back a response with a status code that says how it went (200 OK, 201 created, 404 not found, 500 server error), headers, and usually a body. Codes starting with 4 mean the client asked for something wrong; codes starting with 5 mean the server failed.

Basics:How do back-of-the-envelope estimates work?

System design interviews expect rough numbers, not exact ones. A few facts make them quick:

  • A day has about 86,400 seconds; round to 100,000 for easy division. A month is about 2.6 million seconds.
  • 1 KB ≈ a thousand bytes, 1 MB ≈ a million, 1 GB ≈ a billion, 1 TB ≈ a trillion.
  • “Per day ÷ 100,000” gives “per second”; multiply by 2 to 5 for the peak.

The point is the shape of the answer (is it read-heavy? does it fit on one machine?), not the third digit.

Part Not started

Bigger server, or more servers?

A busy café has two options when the queue gets long. It can hire a faster barista (but there’s a limit to how fast one person can work), or it can open more counters with more baristas. The second option has no real limit, but only if every barista can serve every customer: if your order is only known to the barista you spoke to, you’re stuck in their queue.

Servers are the same. Scaling up (vertical scaling) means a bigger machine: more CPU, more memory. Scaling out (horizontal scalingHorizontal scalingHandling more load by adding more machines that do the same job, instead of buying a bigger one.Open in glossary →) means more machines doing the same job. Scaling out needs servers that don’t remember anything between requests: stateless servers.

Vertical scaling is simple and often underrated: a single large instance handles a lot, with no distributed-systems complexity, until it hits the largest instance size, a single point of failure, or a cost cliff. Horizontal scaling adds capacity and redundancy linearly, but only for stateless tiers; state moves to purpose-built stores (databases, caches, object storage) that have their own, harder scaling story. In interviews, the expected move is: keep the app tier stateless and horizontally scaled behind a load balancer, then spend the discussion on the stateful tiers.

Scale up (vertical)Scale out (horizontal)
HowBigger machineMore machines
LimitThe biggest machine you can buyPractically none for stateless servers
If it failsEverything is downThe others keep serving
Code changesNoneServers must be stateless
CostGrows fast at the top endGrows roughly linearly

Stateless servers

Spot the bugCarts that disappear

Tap the line with the bug.

@RestController
class CartController {
    private val carts = ConcurrentHashMap<String, MutableList<Item>>()

    @PostMapping("/cart/items")
    fun add(@RequestHeader("X-User") user: String, @RequestBody item: Item) {
        carts.getOrPut(user) { mutableListOf() }.add(item)
    }

    @GetMapping("/cart")
    fun get(@RequestHeader("X-User") user: String) = carts[user].orEmpty()
}
@RestController
class CartController {
    private final Map<String, List<Item>> carts = new ConcurrentHashMap<>();

    @PostMapping("/cart/items")
    void add(@RequestHeader("X-User") String user, @RequestBody Item item) {
        carts.computeIfAbsent(user, u -> new CopyOnWriteArrayList<>()).add(item);
    }

    @GetMapping("/cart")
    List<Item> get(@RequestHeader("X-User") String user) { return carts.getOrDefault(user, List.of()); }
}
const carts = new Map<string, Item[]>();

app.post("/cart/items", (req, res) => {
  const user = req.header("X-User")!;
  carts.set(user, [...(carts.get(user) ?? []), req.body]);
  res.sendStatus(204);
});

app.get("/cart", (req, res) => {
  res.json(carts.get(req.header("X-User")!) ?? []);
});

What usually has to move out of a server’s memory or disk before you can add a second one:

  • Sessions and carts → a shared cache like Redis, or a database (or a signed token the client sends with each request).
  • Uploaded files → object storage (S3, Google Cloud Storage), not the server’s disk.
  • Scheduled jobs → run once, not once per server: a job scheduler, or a lock so only one server runs each job.
  • In-memory caches → fine as a speed-up, as long as the data can be rebuilt and slightly stale copies are acceptable.

Practice: how many servers?

  1. Peak load: 5,000 requests per second at peak.
  2. One server’s capacity: load tests show one server handles about 800 requests per second before response times get bad.
  3. Divide: 5,000 ÷ 800 ≈ 6.3, so at least 7 servers.
  4. Headroom: plan for one server failing and for traffic growing: 7 + 1 spare, rounded up to 8–10.
  5. Check the rest: 10 servers each holding 20 database connections is 200 connections. Can the database take that? (The last part of this lesson is about exactly this.)

Where state can live

StateOptionsTrade-off
Session/authSigned tokens (JWT) carried by the client; server-side sessions in RedisTokens scale freely but are hard to revoke before expiry; server sessions are revocable but add a lookup per request
Shopping cart, draftsRedis with TTL, or the databaseRedis is fast but needs persistence/replication if losing carts matters
FilesObject storage + CDN, with pre-signed upload URLsClients upload directly, servers never touch the bytes
WebSocket connectionsInherently per-serverRoute by user (consistent hashing) and fan out via pub/sub between servers
Local cachesIn-process, per serverN copies, each a little stale; fine for config and reference data

The usual exception to “stateless” is long-lived connections (WebSockets, gRPC streams): the connection is state. Systems keep the stateful connection layer thin and route messages to it through a pub/sub layer.

  • Vertical first is often fine (simple, no distribution) until size, availability or cost limits; horizontal for the stateless tier.
  • Stateless app servers: sessions in tokens or Redis, files in object storage, scheduled jobs leader-elected or in a scheduler, local caches only for rebuildable data.
  • Long-lived connections (WebSockets) are inherent state: route by user, fan out over pub/sub.

Check this part

  1. What is horizontal scaling?

  2. A server keeps shopping carts in its own memory. What happens with three servers behind a load balancer?

  3. What's the main trade-off between JWT sessions and server-side sessions in Redis?

Scaling up means a bigger machine; scaling out means more machines. Scaling out only works when servers are stateless: any server can handle any request, because sessions, carts and files live in shared stores, not in one server's memory.

Part Not started

Load balancers

A load-balanced backend

HTTPSClientsapps, browsersLoad balancerServer AstatelessServer BstatelessServer CunhealthyShared stateDB, Redis

Tap a box to see what it does.

A load balancerLoad balancerA component that spreads incoming requests across several servers.Open in glossary → sits in front of the servers and gives them all one address. For each request, it picks a server and forwards it. Two jobs matter most:

Layer 4 balancers route TCP/UDP connections (fast, protocol-agnostic, no request inspection); layer 7 balancers understand HTTP: path- and header-based routing, TLS termination, retries, per-request balancing over HTTP/2 multiplexed connections. Algorithms: round robin (equal servers, uniform requests), least connections / least outstanding requests (variable request cost), weighted variants (mixed instance sizes, canary releases), and hashing (affinity: same key → same server, needed for caches and connection-holding tiers). Separate liveness (restart me) from readiness (route to me) checks; conflating them turns a slow dependency into a restart storm.

// Spring Boot Actuator exposes these when management.endpoint.health.probes.enabled=true:
//   /actuator/health/liveness   → is the process alive?
//   /actuator/health/readiness  → should it receive traffic?
@Component
class DatabaseReadiness(private val db: DataSource) : HealthIndicator {
    override fun health(): Health =
        if (db.connection.use { it.isValid(1) }) Health.up().build() else Health.down().build()
}
// Spring Boot Actuator exposes these when management.endpoint.health.probes.enabled=true:
//   /actuator/health/liveness   → is the process alive?
//   /actuator/health/readiness  → should it receive traffic?
@Component
class DatabaseReadiness implements HealthIndicator {
    private final DataSource db;
    DatabaseReadiness(DataSource db) { this.db = db; }

    @Override public Health health() {
        try (var c = db.getConnection()) {
            return c.isValid(1) ? Health.up().build() : Health.down().build();
        } catch (SQLException e) {
            return Health.down(e).build();
        }
    }
}
let shuttingDown = false;

app.get("/health/live", (_req, res) => res.sendStatus(200));           // the process is alive

app.get("/health/ready", async (_req, res) => {                        // send me traffic?
  if (shuttingDown) return res.sendStatus(503);
  const dbOk = await db.query("SELECT 1").then(() => true, () => false);
  res.sendStatus(dbOk ? 200 : 503);
});

Predict the output

Server C crashes. The load balancer checks /health every 10 seconds and marks a server unhealthy after 3 failures in a row. Traffic is spread evenly over A, B and C. Roughly how long do users see errors, and how many requests fail?

Exerciseeasy

Pick a balancing rule

An API has two kinds of requests: listing products (about 20 ms each) and generating PDF invoices (about 3 seconds each). Requests arrive in random order over 4 identical servers. With round robin, users report that the product list is sometimes very slow. Explain why, and pick a better rule.

›

Round robin counts requests, not how much work each one is. What happens to a server that got three PDF requests in a row?

SolutionTry it yourself first

Round robin gives every server the same number of requests, but not the same amount of work. A server that happens to receive a few PDF jobs is busy for seconds, yet keeps getting its share of new requests, which queue behind the PDFs. The product list requests that land there wait, while other servers sit idle.

Least connections (or least outstanding requests) sends each new request to the server with the fewest requests in progress, so a server busy with PDFs gets fewer new ones automatically. Even better long-term: move PDF generation out of the request path, into a background job with a queue (see Message queues), so web servers only handle quick requests.

Hashing and rebalancing

Predict the output

A cache tier has 4 servers, and keys are assigned with hash(key) % 4. You add a 5th server and switch to hash(key) % 5. Roughly what share of keys now map to a different server?

Sticky sessions

Sticky sessions (affinity by cookie) send a user to the same server every time. They make stateful servers work behind a load balancer, at a cost: uneven load (one heavy user pins a server), lost sessions when that server dies or deploys, and slow draining. Treat them as a migration step toward statelessness, or as a deliberate choice for connection-oriented tiers, not as the default.

  • L4 (connections) vs L7 (HTTP: path routing, TLS, retries, per-request balancing over HTTP/2).
  • Algorithms: round robin, least outstanding requests (variable cost), weighted (canaries, mixed sizes), consistent/rendezvous hashing (affinity; adding a node moves ~1/n keys instead of ~all with modulo).
  • Liveness ≠ readiness: readiness gates traffic (dependencies, shutdown), liveness only restarts a wedged process. Detection time = interval × threshold; pair with passive checks and client retries for idempotent calls.
  • Sticky sessions: a crutch with uneven load and lost state on failure.

Check this part

  1. What does a load balancer's health check do?

  2. Some requests take 3 seconds, most take 20 ms. Which rule spreads the work better?

  3. Why separate liveness and readiness checks?

  4. Adding one node to a 4-node modulo-hashed cache moves about what share of keys?

A load balancer gives many servers one address, spreads requests with an algorithm (round robin, least connections, hashing), and stops sending traffic to servers that fail their health checks. Sticky sessions are a workaround for stateful servers, not a design.

Part Not started

The next bottleneck

Adding baristas helps until the café has only one espresso machine. Then ten baristas wait in line for it. In a backend, the espresso machine is usually the database: every app server talks to the same one, so adding servers adds load to it.

Horizontal scaling of a stateless tier pushes load onto shared stateful dependencies. The usual order of failure: database connections, then database CPU on reads, then write throughput, then a single hot key or row. Each has a standard response, and the interview signal is naming which one you’d hit first given the numbers.

BottleneckSymptomFirst responses
Too many database connections“too many clients” errors when you add serversConnection pools sized to the database’s limit, a pooler (PgBouncer)
Database readsHigh CPU, slow queriesIndexes, a cache, read replicas
Database writesWrite latency grows, replication lagBatch writes, queue them, partition (shard) the data
A slow external APIRequests pile up waitingTimeouts, circuit breakers, do it asynchronously
One hot itemOne key/row/partition at 100% while others idleCache it, split it, add randomness to the key

Practice: scaling a growing app, step by step

  1. One server with its database on the same machine. Simple, fine for the first users.
  2. Database on its own machine. The app and the database stop fighting over CPU and memory.
  3. Two or more app servers behind a load balancer. Requires stateless servers; gives you redundancy too.
  4. A cache in front of the database for the most frequent reads, and a CDN for images and static files.
  5. Read replicas for heavy read traffic; the primary database handles writes.
  6. Queues for slow work (emails, image processing), so web requests stay fast.
  7. Partitioning (sharding) the database when writes or data size outgrow one machine. This is the expensive step; most apps never need it.

Production puzzles

Spot the bugAutoscaling that took the database down

Tap the line with the bug.

# Kubernetes: scale the API between 4 and 40 pods on CPU
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  minReplicas: 4
  maxReplicas: 40
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 60 } }
---
# application.yml (each pod)
spring.datasource.hikari.maximum-pool-size: 25
# PostgreSQL: max_connections = 500

Exercisemedium

Deploys that drop requests

Every deploy, users see a burst of 502 errors for a few seconds. Pods are replaced one at a time. Logs show requests cut off mid-response when a pod receives SIGTERM. Design graceful shutdown for the API so a rolling deploy drops nothing.

›

Two problems overlap: the load balancer keeps sending new requests to a pod for a few seconds after it starts shutting down, and the pod kills in-flight requests instead of finishing them.

SolutionTry it yourself first

The sequence that works on Kubernetes and most load balancers:

  1. On SIGTERM, fail readiness first and keep serving: the load balancer (or endpoints controller) needs a few seconds to stop routing new requests to the pod. A short preStop sleep (5–10 s) covers propagation delay.
  2. Stop accepting new connections, finish in-flight requests with a deadline shorter than terminationGracePeriodSeconds.
  3. Stop background consumers (stop polling queues, finish the current message), then close pools.
  4. Exit.
// application.yml
// server.shutdown: graceful
// spring.lifecycle.timeout-per-shutdown-phase: 20s
// management.endpoint.health.probes.enabled: true
//
// Spring Boot then: marks readiness as REFUSING_TRAFFIC on shutdown, stops accepting
// new requests, waits up to 20 s for in-flight ones, and closes the context.
// Add a preStop hook in the pod spec: sleep 10, so the LB sees "not ready" before SIGTERM.
// application.yml
// server.shutdown: graceful
// spring.lifecycle.timeout-per-shutdown-phase: 20s
// management.endpoint.health.probes.enabled: true
//
// Spring Boot then: marks readiness as REFUSING_TRAFFIC on shutdown, stops accepting
// new requests, waits up to 20 s for in-flight ones, and closes the context.
// Add a preStop hook in the pod spec: sleep 10, so the LB sees "not ready" before SIGTERM.
process.on("SIGTERM", async () => {
  shuttingDown = true;                               // /health/ready now answers 503
  await new Promise((r) => setTimeout(r, 10_000));   // let the load balancer notice
  server.close(async () => {                         // stop accepting, finish in-flight requests
    await queueConsumer.stop();
    await db.end();
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 20_000).unref(); // hard deadline
});

Also make clients resilient: retry idempotent requests once on 502/503 with a short backoff, and use idempotency keys for writes (see API design), so the rare dropped request doesn’t become a user-visible error or a duplicate order.

  • Scaling the stateless tier moves load to shared dependencies: connections → read CPU → writes → hot keys.
  • Connection budget: max_pods × pool_size ≪ max_connections; small pools plus a pooler (PgBouncer, RDS Proxy); cap the autoscaler at dependency capacity; scale on latency/queue depth, not only CPU.
  • Graceful shutdown: on SIGTERM fail readiness, wait for LB propagation (preStop), drain in-flight requests within the grace period, stop consumers, close pools. Clients retry idempotent requests.

Check this part

  1. You add more app servers. What usually becomes the bottleneck next?

  2. What helps most with heavy database reads?

  3. 40 pods × pool size 25 against max_connections 500. What happens at peak?

  4. What's the first thing a pod should do on SIGTERM?

Adding app servers moves the bottleneck: usually to the database (connections, writes) and to shared dependencies. Scale reads with caches and replicas, cap connections with pools, autoscale on the right signal, and shut down gracefully so deploys don't drop requests.

Key takeaways

  • Scale up with a bigger machine, or out with more machines; scaling out needs stateless servers.
  • Move sessions, carts and files to shared stores so any server can handle any request.
  • A load balancer spreads requests and uses health checks to avoid broken servers; least connections handles uneven requests better than round robin.
  • More servers move the bottleneck to the database: cache, index, add replicas, and queue slow work.
  • Keep the app tier stateless; long-lived connections are the exception and need routing plus pub/sub.
  • L7 balancing with least-outstanding-requests; consistent hashing where affinity matters.
  • Separate liveness from readiness; budget database connections against autoscaler limits.
  • Graceful shutdown (fail readiness, drain, stop consumers) makes rolling deploys invisible.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.