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.
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 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 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 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.
| Move | Relieves | Costs | Do it when |
|---|---|---|---|
| Bigger primary | CPU, RAM, I/O | A restart; a ceiling | Always first |
| Table partitioning | Bloated indexes, VACUUM, old data | Schema and maintenance job | One table dominates size |
| Move firehose data out | Write rate on the primary | A queue and a second store | Events/logs outnumber business writes |
| Sharding | Write ceiling of one machine | Joins, transactions, uniqueness, rebalancing | Measured 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 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 (measured) | Fix | New problem |
|---|---|---|
| App CPU 90%, p99 1.4 s, deploys cause 502s | Load balancer + stateless copies; sessions to Redis | N× DB connections; Redis is tier-one; LB HA |
| DB CPU 80%, 95% reads, hot keys repeat | Pooler, cache, read replica | Staleness and invalidation; replication lag; cache is load-bearing |
| Primary 70% CPU on writes, 2 TB table | Bigger primary, partitioning, firehose out; shard last | Cross-shard joins/transactions; rebalancing |
| 2.8 s load abroad, 70% bandwidth static | Hashed assets + CDN + shield | Cache keys, purge, second staleness layer |
| POST p99 3.2 s, provider outage = checkout outage | Queue + workers, retries, DLQ | At-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
How data moves through it
One request or event, hop by hop.
- 1Browser → CDN: assets and anonymous pages served at the edge; misses go to the origin.
- 2CDN → LB → app instance: any instance; session read from Redis.
- 3App → Redis: hot read served from cache, or miss recorded.
- 4App → pooler → replica: cache miss read; reads-after-writes go to the primary instead.
- 5App → pooler → primary: the write commits; a job message is enqueued in the same request.
- 6Queue → worker → external API / object storage: email sent, PDF rendered, idempotently, with retries and a dead-letter queue.
When to use — and when not
- 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.
- 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
WHEREon a 50 M-row table.
Tradeoffs
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.