Animated guide · System Design

System Design from first principles

The heuristics behind scalable, reliable systems — plus the two contracts that hold them together: REST APIs and events. Sixteen mechanisms, one figure each, all clickable. Scroll — the diagrams start by themselves.

basics, no hand-waving 🧠
scroll ↓

How to read this guide

rules before recipes

System design isn't a list of buzzwords to memorize — it's a small set of heuristics that keep repeating: spread load, tolerate failure, pick your trade-off consciously, make retries safe. Once you see the pattern, every "new" system is a remix of the same fifteen ideas.

Each chapter below is one mechanism, with one thing you can click and break. Do the breaking — that's where the lesson lives, not in the paragraph above it.

🧠 Brain power

Why does almost every "design X" interview question start with "how many users, how many requests per second"? Keep this question in mind — the finale ties it together.

Scale out, not just up

the first heuristic

Vertical scaling means a bigger machine — more CPU, more RAM. It's simple and it works, but it has a ceiling (the biggest machine money can buy) and a single point of failure. Horizontal scaling means more machines doing the same job behind a load balancer. It has (almost) no ceiling, but it only works if any request can be handled by any machine — which is why later chapters spend so much effort making services stateless.

Before scaling anything, estimate the load: requests per second, data per day, read/write ratio. A back-of-envelope number tells you whether you need three servers or three thousand — and whether the bottleneck is CPU, network, or disk.

▶ Scale-out simulator watch latency climb as traffic outruns capacity
Utilization
Latency (illustrative)
Press "+50 req/s traffic" to load the cluster.
Illustrative queueing model: latency rises sharply as utilization approaches 100%. Vertical upgrades cap out after 3 steps (diminishing returns); horizontal add is unbounded here.
🤓 Deep dive

This is a simplified M/M/1-style intuition: latency ≈ base / (1 − utilization). As utilization creeps toward 100%, queueing time explodes even though average load looks "almost fine" — this is why healthy systems run at 60–70% utilization, not 95%.

📌 Bullet points
  • Vertical scaling: simple, has a hard ceiling, stays a single point of failure.
  • Horizontal scaling: needs statelessness, scales further, needs a load balancer.
  • Always start from an estimate — requests/sec and data/day decide the architecture.
  • Latency doesn't rise linearly with load — it rises sharply near saturation.

Redundancy & failover

nothing runs on one box

Reliability is what happens after something breaks — and something always breaks. The fix is redundancy: no component that matters should exist as a single copy. A single point of failure (SPOF) is any one thing whose death takes the whole system down with it.

Availability is usually expressed in "nines": 99.9% ("three nines") allows about 8.8 hours of downtime a year; 99.99% allows about 52 minutes. Each extra nine costs real engineering effort, so pick a target the business actually needs — not the highest number that sounds impressive.

▶ Failover kill the leader, watch a replica take over
Three replicas, one leader. Client traffic goes to whoever is leader.
Simplified single-datacenter failover; real systems add a consensus protocol to agree on the new leader safely.
⚠️ Watch out

Redundancy that shares a failure domain isn't redundancy. Three replicas in the same rack, same power circuit, same availability zone can all die together. Spread copies across zones/regions to survive correlated failures.

📌 Bullet points
  • No single copy of anything that matters — that's redundancy.
  • A SPOF is any component whose failure takes the whole system down.
  • Losing the majority of replicas is different from losing one — the survivors can't safely elect a leader.
  • Spread redundancy across real failure domains (racks, zones, regions).

CAP & the consistency spectrum

pick two, but only during a partition

The CAP theorem says: when a network Partition happens between nodes that hold the same data, you must choose between Consistency (every read sees the latest write, so you refuse writes on the cut-off side) and Availability (every request gets a response, even if it might be stale). Outside of a partition, you can have both — CAP is specifically about what happens during a network split, not a permanent trade-off.

A related, more practical framing is PACELC: if there is a Partition, choose Availability or Consistency; Else (normal operation), choose Latency or Consistency. Strongly consistent systems pay a latency tax even when nothing is broken, because they must coordinate before answering.

▶ Partition simulator same write, two philosophies
Node Av0
Node Bv0
Not partitioned — a write on either side reaches both.
Illustrative two-node system. Real CP/AP databases (e.g. a strongly-consistent SQL cluster vs. a Dynamo-style store) implement this choice with quorums, not a mode switch.
❓ There are no dumb questions
Q:
So is CAP saying my database is either "consistent" or "available" forever?
A:
No — it's a statement about one moment: a network partition. A CP system stays consistent by refusing some requests during that partition; an AP system stays available by accepting writes on both sides and reconciling them once the partition heals.
📌 Bullet points
  • CAP only forces a choice while a partition exists.
  • CP: refuse requests on the minority side, stay correct.
  • AP: answer everywhere, accept that copies can diverge, reconcile later.
  • PACELC: even without a partition, strong consistency costs latency.

Load balancing

who gets the next request

A load balancer sits in front of a pool of identical servers and picks one for each request. Round robin cycles through servers in order — simple, ignores current load. Least connections sends the request to whichever server has the fewest in-flight requests — better when requests take unequal time. Consistent hashing maps both servers and requests onto a ring by hash, so a given key (e.g. a user ID) usually lands on the same server — useful for cache locality — and when a server dies, only its slice of the ring remaps, not everything.

Load balancers also work at different layers: L4 balances raw TCP/UDP connections (fast, protocol-blind); L7 reads HTTP (host, path, headers) and can route /api/* to one pool and /static/* to another. Either way, a load balancer must stop sending traffic to a node that fails its health check.

▶ Routing algorithms send requests, then kill a node
Pick an algorithm, then send a few requests.
Node load shown as request count (illustrative). Consistent hashing keeps most keys on their original node after a kill; round robin and least-connections simply redistribute across survivors.
📌 Bullet points
  • Round robin: fair by turn, blind to actual load.
  • Least connections: fair by current load, needs live state.
  • Consistent hashing: stable placement, minimal remap on node changes.
  • L4 = fast and protocol-blind; L7 = content-aware routing.
  • Health checks remove dead nodes from rotation automatically.

Caching

remember what's expensive to recompute

A cache trades a little staleness for a lot of speed. Cache-aside (lazy loading): the app checks the cache first; on a miss, it reads the database and writes the result into the cache. Write-through: every write goes to the cache and the database together, so the cache is never stale but writes are slower. Write-behind: writes land in the cache first and are flushed to the database asynchronously — fast, but can lose data if the cache dies before flushing.

Every cache entry needs an eviction rule — a TTL (time to live) or a policy like LRU (evict the least recently used entry) — otherwise the cache grows forever. And watch for a cache stampede: when a hot key expires, hundreds of requests miss at once and all hammer the database simultaneously.

▶ Cache-aside hits are fast, misses cost a database round trip
Client
→
Cacheempty
→
Database
Database load
Illustrative timings. The singleflight lock lets only one request repopulate an expired key while the rest wait for that result, instead of all of them hitting the database.
🤓 Deep dive

"Singleflight" (a term from Go's golang.org/x/sync/singleflight) collapses concurrent identical requests into one in-flight call and shares its result — a general fix for stampedes, not just caches.

📌 Bullet points
  • Cache-aside: app manages the cache, simplest, can serve stale data.
  • Write-through: always fresh, slower writes.
  • Write-behind: fastest writes, risk of loss on cache failure.
  • Always set a TTL or eviction policy — unbounded caches are a memory leak with extra steps.
  • Guard hot keys against stampedes with a lock or early refresh.

Partitioning & sharding

one database is not enough

When one database can't hold or serve all the data, split it into shards, each on its own machine. Range-based sharding (e.g. users A–M on shard 1, N–Z on shard 2) keeps ranges queryable together but can create uneven load. Hash-based sharding (hash the key, mod by shard count) spreads load evenly but makes range queries expensive. Directory-based sharding keeps an explicit lookup table of key → shard, which is flexible but adds a lookup hop.

A hot key is a single key (a viral post, a celebrity account) that gets so much traffic it overwhelms its one shard, no matter how many shards exist. Fixes include splitting the key's traffic further (e.g. appending a random suffix and merging results) or caching it aggressively.

▶ Hash-based sharding add a shard, then create a hot key
Four keys, two shards. Add a shard and see how few keys move.
Illustrative key set. Consistent hashing (not plain "hash mod N") is what keeps remapping minimal in real systems — plain modulo reshuffles almost every key when the shard count changes.
📌 Bullet points
  • Range sharding: easy range queries, uneven load risk.
  • Hash sharding: even load, expensive range queries.
  • Directory sharding: flexible, adds a lookup hop.
  • A hot key overloads one shard regardless of shard count — fix the key, not the shard.

Replication & quorums

how many copies must agree

Replication keeps multiple copies of the same data for durability and read scale. In leader–follower replication, writes go to the leader and are streamed to followers; reads can go to either, trading freshness for read capacity. Replication lag is the delay before a follower catches up — read a follower during lag and you can see stale data.

Quorum-based systems make this tunable: with N replicas, a write must be acknowledged by W of them, and a read must query R of them. If W + R > N, every read overlaps with the latest write on at least one replica, guaranteeing you see it — this is strong consistency via quorum overlap, not via a single leader.

▶ Quorum reads/writes N=3 replicas — tune W and R
N=3. Try W=1,R=1 (fast, can read stale) vs W=2,R=2 (W+R>N, always fresh).
Illustrative replica set with simulated propagation delay between the write and each follower catching up.
📌 Bullet points
  • Leader–follower: simple, followers can lag behind the leader.
  • W = replicas that must ack a write; R = replicas a read must query.
  • W + R > N guarantees the read overlaps the latest write.
  • Lower W/R = faster, higher chance of staleness; higher W/R = slower, stronger guarantees.

Timeouts, retries, circuit breakers, back-pressure

failing on purpose, safely

Every network call needs a timeout — otherwise a slow dependency can hold a thread (or a connection) forever, and enough stuck threads can take down a healthy service. A retry (ideally with exponential backoff and jitter) handles transient failures, but blind retries can turn one slow dependency into a traffic avalanche.

A circuit breaker tracks failures to a dependency: in the Closed state, calls go through normally; once failures cross a threshold, it trips Open and fails fast without even calling the dependency, giving it time to recover; after a cooldown it goes Half-Open and lets one test call through — success closes the circuit, failure re-opens it. Back-pressure is the general idea behind all of this: a system tells its upstream "slow down" (via a bounded queue, a 429 response, or dropping the connection) instead of silently accepting more work than it can do and collapsing.

▶ Circuit breaker closed → open → half-open
Threshold: 3 consecutive failures trip the breaker (illustrative). Real breakers use a rolling window, not a raw counter.
⚠️ Watch out

Retries without backoff, or without a circuit breaker, are how a five-minute blip in one dependency becomes a five-hour outage everywhere that calls it — the retries themselves become the overload.

📌 Bullet points
  • Timeouts bound how long a bad dependency can hurt you.
  • Retries need backoff + jitter, or they synchronize into a thundering herd.
  • A circuit breaker fails fast and gives a struggling dependency room to recover.
  • Back-pressure: push overload signals upstream instead of absorbing them silently.

Idempotency & statelessness

the two properties that make retries and scaling safe

An operation is idempotent if doing it once has the same effect as doing it many times. By the HTTP spec, GET, PUT, and DELETE are idempotent — sending the same PUT twice leaves the resource in the same state. POST is not — sending "create an order" twice, naively, creates two orders. Since retries are unavoidable (timeouts don't tell you whether the server actually finished), clients attach a unique idempotency key to unsafe operations, and the server stores "I already handled key X, here's that same response" instead of repeating the side effect.

Statelessness means a server keeps no client-specific state between requests — session data lives in a token (e.g. a JWT) the client holds, or in a shared store (e.g. Redis) any server instance can read. That's what makes horizontal scaling and load balancing trivial in Chapter 1: any server can handle any request.

▶ Retry a timed-out "create order" with and without an idempotency key
Orders actually created in the database
Send a request, then simulate the client retrying after a timeout.
Illustrative: the client can't tell "server never got it" from "server finished but the reply was lost" — so it must retry, and the server must be able to absorb that safely.
📌 Bullet points
  • Idempotent by spec: GET, PUT, DELETE. Not idempotent by default: POST.
  • Idempotency keys make unsafe operations safe to retry.
  • Statelessness: no per-client memory on the server — any instance can serve any request.
  • Statelessness is what makes horizontal scaling (Ch. 1) and load balancing (Ch. 4) work without sticky sessions.

REST: resources, verbs, status codes

the API is a contract too

REST models the world as resources named by nouns (/orders, /orders/42), not verbs (never /getOrder). The HTTP method says what to do to that resource: GET read, POST create (or trigger a non-idempotent action), PUT replace fully, PATCH update partially, DELETE remove.

The status code is part of the contract, not decoration: 200 OK, 201 Created (with a Location header), 204 No Content, 400 Bad Request (malformed input), 401 Unauthorized (no/bad credentials), 403 Forbidden (authenticated but not allowed), 404 Not Found, 409 Conflict (e.g. duplicate), 422 Unprocessable Entity (well-formed but semantically invalid), 429 Too Many Requests, 500 Internal Server Error, 503 Service Unavailable. A good error contract returns a consistent JSON shape (e.g. {"error":{"code":"...","message":"..."}}) every time, not a different shape per endpoint.

▶ Request builder pick a scenario, get the response
···POST /orders
// pick a scenario above
Illustrative response bodies — shape (not exact fields) is the point: always the same envelope.
📌 Bullet points
  • Resources are nouns and URLs; verbs live in the HTTP method.
  • Status codes are part of the contract — clients branch on them.
  • 409 = conflict with current state; 422 = the request itself is invalid semantically.
  • Keep the error body shape identical across every endpoint.

REST: versioning, pagination, HATEOAS

APIs live longer than you plan

APIs need versioning because clients update on their own schedule: a URI version (/v2/orders) is explicit and cacheable; a header version (Accept: application/vnd.api+json;version=2) keeps URLs stable but is easy to miss. Either way, never break a version that clients already depend on — add a new one instead.

Offset pagination (?offset=20&limit=10) is simple but drifts under concurrent writes: if an item is inserted before page 2 is fetched, page 2 can repeat or skip a row. Cursor (keyset) pagination (?after=<last_id>&limit=10) anchors to a stable position and doesn't drift, and it's cheaper for the database at high offsets. Filtering belongs in query parameters (?status=shipped), never in the path. HATEOAS means responses include links to the actions available on that resource (e.g. a cancel link on an order that can still be cancelled) so clients discover capabilities instead of hardcoding URLs — the most-cited but least-implemented REST constraint in practice.

▶ Pagination under concurrent inserts offset drifts, cursor doesn't
Page 1 fetched (items 1–4, newest first). Now insert an item, then fetch page 2.
Illustrative 8-item feed, page size 4. Offset counts positions from the top, so a new item at the top shifts everything down by one.
📌 Bullet points
  • Version explicitly (URI or header); never break a shipped version.
  • Offset pagination: simple, drifts under concurrent writes.
  • Cursor pagination: stable, cheaper at scale, needs a sortable unique key.
  • HATEOAS: let responses tell the client what it can do next.

Contracts & compatibility

a schema is a promise to every caller

An API contract defines the shape of requests and responses (e.g. an OpenAPI spec); a data contract defines the shape of stored or transmitted data (e.g. a database schema or an event schema). Both need a compatibility rule so producers and consumers can change independently. A change is backward compatible if new code can still read data written by old code (a new consumer handles old messages). It's forward compatible if old code can still read data written by new code (an old consumer survives new messages, usually by ignoring fields it doesn't recognize).

Safe changes: adding an optional field, adding a new enum value if consumers already ignore unknown ones, loosening a validation rule. Breaking changes: removing a field still in use, renaming a field, changing a field's type, or adding a new required field with no default. Contract testing (e.g. a consumer-driven contract, or a schema registry that rejects incompatible changes) catches these breaks in CI, before a deploy — not after a downstream service starts throwing errors in production.

▶ Compatibility checker pick a schema change, get the verdict
Producernew schema
→
Old consumer
Pick a change and check it.
Illustrative rule of thumb, not a substitute for your schema registry's actual compatibility mode (e.g. Avro/Protobuf BACKWARD, FORWARD, FULL).
📌 Bullet points
  • Backward compatible: new code reads old data.
  • Forward compatible: old code survives new data.
  • Adding optional fields is almost always safe; removing or retyping used fields almost never is.
  • Enforce compatibility in CI with contract tests or a schema registry, not by convention.

Event, command, or notification?

naming the message shapes the system

An event is a fact about something that already happened (OrderShipped) — the producer doesn't know or care who reacts to it; any number of consumers can subscribe. A command tells one specific receiver to do something (ShipOrder) — it implies an expected outcome and usually one intended handler. A notification is a lightweight event carrying little or no payload (just "something changed, go fetch it if you care") — cheap to send, but every consumer pays a round trip to find out what changed.

Whichever shape you pick, the message's schema evolves over time just like an API's, and the same compatibility rules from Chapter 12 apply — plus one more: put a version identifier in every event so consumers can tell old shapes from new ones, and never silently repurpose a field.

▶ Event schema evolution publish v1, then evolve it
Consumer Abuilt for v1
Consumer Bbuilt for v2
Two consumers subscribed to the same topic, built at different times.
Illustrative. In practice a schema registry (e.g. for Avro/Protobuf/JSON Schema) enforces this at publish time, before a bad event ever reaches a consumer.
📌 Bullet points
  • Event: a fact, any number of subscribers, producer doesn't care who listens.
  • Command: an instruction, one intended handler, implies an outcome.
  • Notification: cheap signal, consumers fetch details themselves.
  • Version every event; treat schema changes with the same rigor as an API contract.

Delivery guarantees & the outbox pattern

the DB write and the event publish must not disagree

Most message brokers give you at-least-once delivery by default: a message might be delivered more than once (after a retry, a rebalance, or a crash before an acknowledgment), but never zero times. Exactly-once processing — as an observed effect — is achieved on top of at-least-once delivery by making consumers idempotent (the same idempotency-key idea from Chapter 9: track processed message IDs and skip duplicates), not by a broker magically deduplicating for you.

The classic bug is the dual-write problem: a service writes to its database, then separately publishes an event — but a crash between those two steps leaves the database changed with no event ever sent (or vice versa). The outbox pattern fixes this: write the business row and an outbox row in the same database transaction (so both commit or neither does), then a separate relay process reads the outbox and publishes to the broker, retrying until it succeeds. This guarantees the event is eventually published if and only if the transaction committed.

▶ Outbox pattern compare with a naive dual write
DB: — Event published: —
Save an order normally, then try crashing right after the commit.
Illustrative timeline. "Crash after commit" models a process dying between the DB write and the publish call — the exact gap the outbox pattern closes.
📌 Bullet points
  • At-least-once is the realistic default; build idempotent consumers, don't assume exactly-once.
  • Dual write = DB write + separate publish call = a gap where a crash loses one of them.
  • Outbox: write data + outbox row in one transaction, relay publishes asynchronously.
  • Consumers still need dedup — the outbox guarantees "at least one publish," not "exactly one delivery."

Design a system, step by step

putting all fifteen mechanisms to work

Worked example: design a URL shortener. Below is the order that turns a vague prompt into a defensible design — click each stage.

▶ Design walkthrough click a stage to expand it
Illustrative numbers throughout — the point is the order of decisions, not these exact figures.
🧩 Use case

Notice what didn't come first: the database schema. It came after the estimate and the API contract, because the estimate decides whether you need sharding at all, and the contract decides what the data model has to support.

📌 Bullet points
  • Estimate first — it decides everything downstream.
  • Contract before storage — the API is what callers actually depend on.
  • Pick your CAP/consistency trade-off deliberately, in writing, not by accident.
  • Design for failure (Ch. 2, 8) from the start — it's expensive to retrofit.

Exam + cheat sheet

check what stuck
Score: 0 / 0

Cheat sheet

Parameter / conceptWhat it doesUse when
Horizontal scalingAdd more machines behind a load balancerNeed to scale past one box; state is externalized
Vertical scalingBigger machine, same countQuick fix, low complexity, you accept a ceiling
CAP (during partition)Choose Consistency or AvailabilityDeciding a data store's failure behavior
Consistent hashingStable key→node mapping, minimal remapLoad balancing or sharding with elastic node count
Cache-asideApp checks cache, falls back to DB on missDefault caching strategy for read-heavy data
W + R > NQuorum read overlaps latest writeNeed strong consistency without a single leader
Circuit breakerFails fast after a failure thresholdProtecting callers from a struggling dependency
Idempotency keyDedupes retried non-idempotent requestsAny POST that has a side effect and might be retried
409 ConflictRequest valid, but clashes with current stateDuplicate create, version mismatch
Cursor paginationAnchors to a stable key, not a positionFeeds/lists under concurrent writes, large offsets
Backward compatibleNew code reads old dataRolling out a consumer/schema change safely
Outbox patternDB write + event enqueue in one transactionPublishing an event that must match a DB write
At-least-once + idempotent consumerRealistic "exactly-once" effectAny consumer of a message broker
🎓 Congratulations

Back to the opening question: why "how many requests per second" first? Because every heuristic above — scale out vs up, which CAP side to pick, how many shards, what quorum — is a function of load. Estimate first, then design. Next: pick one real system you use daily and sketch its 15-mechanism checklist from Chapter 15.