Scalingscaling ladderload balancerread replicacachesharding

Scale This System

Start with one server and one database and add exactly one component per measured problem — load balancer, cache and replicas, sharding decisions, CDN, queue and workers — noting at every step the symptom that forced it and the new problem it introduced, until the diagram every system-design answer draws has been earned box by box.

▶ InteractiveInterview questionDebug it
Progress
What problem does this solve?

Architecture diagrams are usually memorised as a target picture. This lesson rebuilds the standard picture from a single box, so each component is attached to the measurement that justified it — and so you know which box to leave out when the measurement is absent.

Step 0 — one server, one database, and the numbers to watch

A web application on one 4-CPU instance and a Postgres on another. It serves the first few thousand users comfortably. Four metrics tell you when it stops: application CPU and p99 latency, database CPU and read/write ratio, connection count, and queue depth for anything asynchronous — which, right now, does not exist. Nothing here should change until one of these moves. A missing index (Why Is This Query Slow? Indexes) is checked before every rung below, because it is a 100× win that adds no component at all.

Step 0: the whole system
HTTPSSQLBrowserApp serverPostgreSQL
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Step 1 — more traffic: load balancer and stateless copies

Symptom: app CPU at 90% during the daily peak, p99 from 200 ms to 1.4 s, one deploy per day means a minute of 502s. Fix: first, resize (vertical, see Horizontal vs Vertical Scaling); when the next resize doubles the bill or the single machine is an unacceptable failure domain, put a Load Balancing tier in front and run three instances. Sessions in process memory break immediately, so they move to Redis — the Stateless vs Stateful Services change comes with this rung, not later.

New problem: three instances each open a connection pool, and the database has 3× the connections and 3× the read traffic; nothing about the database got faster. Also new: a balancer to keep highly available, health checks to get right, and a Redis that is now tier-one because it holds every session.

Step 1: balanced, stateless
session3× connectionsBrowserLoad balancerApp ×3Redis (sessions)PostgreSQL
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Step 2 — read-heavy database: cache and read replicas

Symptom: database CPU at 80%, 95% of queries are reads, the top ten queries by frequency are the same product and profile lookups, pg_stat_activity shows connections waiting. Fix, cheapest first: a connection pooler (PgBouncer) so 3 × 50 app connections become 20 real ones; then a cache in front of the hot reads (Caching Architecture, patterns in Caching Patterns) — the same Redis, now holding product:{id} with a 60 s jittered TTL, which removes perhaps 90% of those reads; then a read replica (Replication and Read Scaling) for the reads that remain and for reporting queries that should never touch the primary.

New problem: two of them. The cache introduces staleness and the invalidation question — who deletes product:42 when the price changes (Cache Invalidation, Stampedes and Hot Keys)? — and it is now load-bearing: if Redis dies, the database receives 10× its normal reads. The replica introduces replication lag: a user saves their profile, the next request reads a replica 300 ms behind, and they see the old value. Route reads-after-writes to the primary, or wait for the replica’s LSN.

Step 2: cache and replica take the reads
hot readsmiss / writewrites, fresh readsreadsWAL, lag ~300 msLoad balancerApp ×3Redis (sessions + cache)PgBouncerPrimaryReplica
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Step 3 — write-heavy database: partitioning and the sharding decision

Symptom: the primary is at 70% CPU on writes alone; replicas do not help because every write is replayed on every replica; the events table is 2 TB and VACUUM no longer finishes overnight. Fix: before sharding, three cheaper moves — scale the primary vertically (more RAM, NVMe), partition the huge table by time so old partitions can be detached and indexes stay small, and move write-heavy, low-value data (analytics events, logs) out of Postgres entirely into a queue and a column store. Only when a single primary cannot absorb the core write rate does sharding (Partitioning and Sharding) enter: split users across N databases by a shard key — hashed with Consistent Hashing so adding a shard moves 1/N of the keys — and accept what that costs.

New problem: sharding is the most expensive rung on the ladder. Cross-shard joins become application-side merges, a transaction touching two users’ shards is a distributed transaction, global uniqueness needs a coordinator or a key scheme, and rebalancing is a project. This is why the capstone treats sharding as a *consideration*: the honest design records the shard key you would use and the write rate at which you would use it, and does not shard before that rate is measured.

The write-side options, cheapest first
MoveRelievesCostsDo it when
Bigger primaryCPU, RAM, I/OA restart; a ceilingAlways first
Table partitioningBloated indexes, VACUUM, old dataSchema and maintenance jobOne table dominates size
Move firehose data outWrite rate on the primaryA queue and a second storeEvents/logs outnumber business writes
ShardingWrite ceiling of one machineJoins, transactions, uniqueness, rebalancingMeasured write rate exceeds the largest primary

Step 4 — slow static content: CDN

Symptom: page load in Singapore is 2.8 s against 600 ms in the origin region; 70% of origin bandwidth is images, JS and fonts that never change; a product launch tripled bandwidth and the app instances spent their CPU serving files. Fix: hashed asset URLs with Cache-Control: immutable, uploads served from object storage, and a CDN Architecture in front of both, with origin shielding so a launch produces one origin fetch per asset instead of one per edge. Anonymous product pages get max-age=60, stale-while-revalidate, so the CDN absorbs their reads too.

New problem: cache keys and purge. A per-user page accidentally marked public leaks accounts; a deploy that switches HTML before assets are uploaded serves 404s; a purge-everything on deploy sends the whole miss storm to the origin. The CDN is also a second place where staleness lives, so a product price change now has to reach Redis *and* the edge.

Step 5 — background work: queue and workers

Symptom: POST /orders p99 is 3.2 s because the handler sends a confirmation email, renders a PDF invoice, calls a fraud-scoring API and updates the analytics warehouse before responding; a 30 s email-provider outage becomes a checkout outage. Fix: the request writes the order and enqueues the consequences (Message Queues, Background Jobs and Workers); a worker pool consumes them, retries with backoff, and dead-letters what fails five times. The response returns in 80 ms. Workers scale horizontally on queue depth, independently of the web tier.

New problem: asynchrony. The user sees "order placed" before the email exists; the job runs at least once, so a worker that crashes after sending must not send again on retry — the handler is made idempotent with a processed-job table or an idempotency key. The queue can also hide a capacity shortfall: if producers outrun workers, the oldest message ages into hours before anyone notices; queue age, not queue length, is the alert (Backpressure).

The earned picture: every box has a symptom behind it
assets, pagesmisssessions, cachewritesreadsWALenqueuePDFsoriginBrowserCDNLoad balancerApp ×NRedisPrimaryQueueReplicasWorkers ×MObject storageEmail / fraud API
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

The ladder, in one table

Read the table bottom-up as a diagnosis tool: given a symptom, which rung is next? Read it top-down as a review tool: for each box in a proposed design, which symptom justified it? A box with no symptom is a cost with no benefit. The same discipline, applied to the database alone, is Scaling from One User to Millions.

Symptom → fix → new problem
Symptom (measured)FixNew problem
App CPU 90%, p99 1.4 s, deploys cause 502sLoad balancer + stateless copies; sessions to RedisN× DB connections; Redis is tier-one; LB HA
DB CPU 80%, 95% reads, hot keys repeatPooler, cache, read replicaStaleness and invalidation; replication lag; cache is load-bearing
Primary 70% CPU on writes, 2 TB tableBigger primary, partitioning, firehose out; shard lastCross-shard joins/transactions; rebalancing
2.8 s load abroad, 70% bandwidth staticHashed assets + CDN + shieldCache keys, purge, second staleness layer
POST p99 3.2 s, provider outage = checkout outageQueue + workers, retries, DLQAt-least-once → idempotency; invisible backlog; eventual visibility

Key points

  • One component per measured symptom; a box with no symptom behind it is cost without benefit.
  • Order: index → resize → LB + stateless → pooler + cache + replicas → partition and only then shard → CDN → queue + workers.
  • Every rung buys capacity by adding a problem: connections, staleness, lag, cross-shard joins, purge, at-least-once delivery.
  • Sharding is recorded as a decision with a shard key and a trigger write rate, and executed only when that rate is measured.
  • The final diagram is the standard one — earned, so you know which boxes to omit when the numbers are smaller.

Scale this system

Scale this system
Start with one server and one database. Each step shows a measured symptom; pick the fix. The diagram grows only when the fix is right.
ClientsApp serverDatabase
1. The app server is saturated
App CPU
90%
p99 latency
900 ms
Traffic
800 rps
DB CPU
35%
Read the numbers before the options: which tier is saturated, and is the load reads, writes, CPU or distance? The right fix always lands on the saturated tier; the wrong ones usually add capacity somewhere that is already idle.
1/7 · The app server is saturated

How data moves through it

One request or event, hop by hop.

  1. 1Browser → CDN: assets and anonymous pages served at the edge; misses go to the origin.
  2. 2CDN → LB → app instance: any instance; session read from Redis.
  3. 3App → Redis: hot read served from cache, or miss recorded.
  4. 4App → pooler → replica: cache miss read; reads-after-writes go to the primary instead.
  5. 5App → pooler → primary: the write commits; a job message is enqueued in the same request.
  6. 6Queue → worker → external API / object storage: email sent, PDF rendered, idempotently, with retries and a dead-letter queue.

When to use — and when not

Use it when
  • Reviewing any proposed architecture: walk the ladder and demand the symptom behind each component.
  • System design interviews: build the picture from one box and narrate the measurement at each step — it is what the interviewer is scoring.
  • Planning capacity for the next 10×: identify which rung the next order of magnitude hits, and prepare that one only.
Avoid it when
  • As a target: deploying the final diagram on day one for a product with 200 users is a distributed system with no traffic to justify it.
  • Skipping rungs: sharding before caching, or a queue before measuring that the request path is slow because of side effects.
  • When the bottleneck is a query plan; no rung on this ladder fixes an unindexed WHERE on a 50 M-row table.

Tradeoffs

Complexity
low → high
Ops cost
low → high
Latency
low → high
Consistency
weak → strong
Scalability
poor → strong

The finished system scales reads, writes, assets and background work independently; it costs six components to run and a staleness model spanning browser, CDN, Redis and replicas. Stop at the rung your measurements reach.

How it fails

  • Redis outage after step 2: sessions gone and the database takes a 10× miss storm at the same moment — the cache became load-bearing without a plan for its absence.
  • Replication lag surfacing as "I saved it and it shows the old value" because reads-after-writes were not routed to the primary.
  • A queue that hides a throughput deficit until the oldest message is four hours old; alert on message age, not depth.
  • Sharded too early: a team of six maintaining cross-shard merges and a rebalancing tool for a write rate one primary could absorb.
  • Autoscaling the app tier on CPU while the primary is the bottleneck: more instances, more connections, worse p99.

How it scales

  • Stateless tier: linear with instance count behind the balancer, bounded by what it shares.
  • Reads: cache hit rate then replica count; each replica adds near-linear read capacity and one more lagging copy.
  • Writes: vertical then partitioning; sharding when a measured write rate exceeds the largest primary.
  • Assets and pages: CDN footprint, effectively unbounded; origin sees misses only.
  • Background work: workers scale on queue depth; the external APIs they call become the next ceiling and need rate limiting.

How it interacts with databases, queues, caches, APIs and external systems

  • Database: primary for writes and fresh reads, replicas for the rest, behind a pooler; partitioned before it is sharded.
  • Cache (Redis): sessions and hot reads, jittered TTLs, single-flight on miss; replicated because it is tier-one.
  • CDN: hashed assets and cacheable pages, origin shielding, tag-based purge on content change.
  • Queue + workers: every side effect of a write; idempotent handlers, backoff, dead-letter queue, alerts on message age.
  • External APIs: called only from workers, with timeouts, rate limits and a circuit breaker so their outage stays theirs.
Don't delegate understanding
The manifesto →