SSystem Design

Block A · Topics 1–4

Fundamentals

The four layers every design touches, in the order you meet them: how big is it, what does it promise callers, how does traffic reach a server, and how do you avoid doing the same work twice.

01

Requirements & Scale

DAUQPSstoragelatencybandwidth
Why it exists

Every later decision is downstream of a number. Whether you need one database or a thousand shards, a cache or no cache, sync or async — none of it can be argued without an estimate. Skipping this step is why candidates end up defending choices they cannot justify: they never established the constraint the choice was meant to satisfy.

Functional vs non-functional

Functional requirements are what the system does — "users can post photos", "followers see them in a feed". Non-functional requirements are the properties that shape the architecture — latency, availability, consistency, durability, cost. Functional requirements determine your API; non-functional requirements determine your architecture. Interviewers mostly grade the second.

The clarifying questions that actually change the design

Ask five, not fifteen. These five change the answer more than any others:

QuestionWhy it changes the design
How many daily active users, and how active?Sets QPS, which sets fleet size and whether you shard at all.
What's the read:write ratio?Read-heavy → caches, replicas, denormalisation. Write-heavy → partitioning, batching, LSM-tree stores.
How stale can data be?Seconds of staleness unlocks caching, async replication, fan-out on write. Zero staleness forbids all three.
What's the latency target, and at which percentile?p50 is easy; p99 forces timeouts, hedging, and cutting slow dependencies out of the critical path.
What must never be lost or double-counted?Money and messages need durability and idempotency. Likes and view counts don't.

Back-of-envelope arithmetic

Round aggressively. The interviewer wants the order of magnitude and the reasoning, not the digits.

# The one conversion to memorise
1,000,000 / day  ≈  12 / second        # 86,400s/day, round to 100k
1 / second       ≈  86,400 / day       # ~2.6M/month

# Peak is never average
peak QPS = average QPS × 2 to 10       # use ×3 unless told otherwise

# Storage
bytes/day = writes/day × bytes/write
5 years   = bytes/day × 365 × 5 × replication_factor (usually 3)

# Bandwidth
bandwidth = QPS × payload_size         # do reads and writes separately

# Fleet size
servers = peak QPS / per-server QPS    # then × 1.5 for headroom + failures

Worked example — photo sharing, 100M DAU

Users                100M DAU
Photos posted        1 per user per 10 days  →  10M photos/day
Feed views           20 per user per day     →  2B reads/day

WRITES   10M/day        ≈  116 /s   →  peak ×3  ≈  350 /s
READS    2B/day         ≈  23,000 /s → peak ×3  ≈  70,000 /s
Ratio    ~200:1 read-heavy  →  cache + read replicas, obviously

STORAGE  photo 2 MB + thumbnails 0.5 MB  ≈  2.5 MB
         10M × 2.5 MB   =  25 TB/day
         × 365 × 5      ≈  45 PB over 5 years  (before replication)
         metadata: 10M × 1 KB = 10 GB/day → 18 TB in 5y  ← trivially small

BANDWIDTH  egress  70,000 /s × 300 KB (a feed page of thumbs)  ≈  21 GB/s
           → this is a CDN problem, not an origin problem

Conclusion in one line: metadata is small enough for a sharded SQL cluster;
blobs go to object storage behind a CDN; the read path is entirely cache-shaped.
The move that scores

Notice how the arithmetic produced the architecture. 45 PB rules out storing photos in a database. 21 GB/s rules out serving them from origin. A 200:1 read ratio rules out a design with no cache. Say those implications out loud — the numbers are only worth points when you connect them to a decision.

Latency: budget it, and always at a percentile

A 200 ms p99 target is a budget you spend across hops. Write it down and each component has to fit:

HopBudgetNote
Client → edge (TLS + network)40 msFixed by geography; only a CDN/PoP helps.
Edge → app server10 msSame region.
Auth / rate limit5 msMust be a cache hit, never a DB call.
Cache lookup2 msRedis in the same AZ.
Database (on miss)30 msIndexed query, one round trip.
Serialisation + response15 msJSON size matters more than people think.
Headroom98 msFor retries, GC pauses, and the one slow dependency.
Tail latency amplification

If one service call has a 1% chance of being slow, a request that fans out to 100 services is almost certain to hit at least one slow call (1 − 0.99100 ≈ 63%). Your p99 becomes everyone's p50. Fixes: fan out less, hedge requests (send a duplicate after p95 elapses and take the first response), or return partial results.

Say this

"Before I draw anything: roughly how many daily actives, and is this read-heavy or write-heavy? …OK, 100M DAU with a 200:1 read ratio. That's about 350 writes per second at peak — small enough that a single sharded cluster handles it — and 70,000 reads per second, which is where all the engineering goes. So I'm going to optimise the read path hard and keep the write path boring."

Where candidates lose points

  • Estimating for a scale nobody asked for. If they said 1M users, designing for 1B is not ambition, it's not listening.
  • Quoting an average and forgetting peak. Traffic is diurnal and spiky.
  • Forgetting replication and indexes when sizing storage — 3× replication plus indexes can double or triple the raw number.
  • Averaging latency. "Average 50 ms" hides a p99 of 4 seconds, and the p99 is what your users feel.
02

APIs & Contracts

RESTGraphQLgRPCpaginationidempotencyversioning
Why it exists

The API is the only part of your system that other teams and clients can't refactor around. Internals can be rewritten on a Tuesday; a published contract is forever, because a mobile app from two years ago is still calling it. Design it as the durable surface and everything behind it stays cheap to change.

Choosing a protocol

REST / JSONGraphQLgRPC
Best forPublic APIs, CRUD, anything cacheableClients with varied, nested data needsInternal service-to-service
TransportHTTP/1.1 or 2, textHTTP POST, textHTTP/2, binary Protobuf
CachingFree — HTTP caches, CDNs, ETagsHard: one URL, POST body variesManual only
Payload sizeBaselineSmaller (client picks fields)~30–50% of JSON
StreamingNo (SSE bolted on)SubscriptionsNative, bidirectional
Browser supportNativeNativeNeeds grpc-web + proxy
CostOver/under-fetching, N+1 round tripsQuery cost is unbounded; server complexity; easy to DoS yourselfOpaque on the wire; harder to debug; schema deployment coupling
Say this

"REST at the edge because it caches for free at the CDN and every client speaks it; gRPC between internal services because it's binary, multiplexed and has generated clients. I'd only reach for GraphQL if we had many client types with genuinely different data shapes — and then I'd add persisted queries and a depth/complexity limit, because an open GraphQL endpoint is a self-service denial-of-service."

Pagination — offset vs cursor

This is the single most common API design question, and the answer is almost always cursor.

Offset / page numberCursor (keyset)
QueryLIMIT 20 OFFSET 10000WHERE (created_at, id) < (?, ?) LIMIT 20
Cost at page 500Database scans and discards 10,000 rowsIndex seek — same cost as page 1
Items inserted mid-scrollRows shift; users see duplicates or skipsStable — the cursor pins a position
Jump to page 47EasyNot supported
Total countEasyNeeds a separate (expensive) query
Use whenSmall admin tables where users pick pagesFeeds, timelines, logs, anything infinite-scroll

The cursor should be an opaque, encoded token — base64 of {last_sort_key, last_id} — not a raw offset. Opaque means you can change the underlying sort later without breaking old clients. Always tie-break on a unique column: sorting by created_at alone silently drops or repeats rows when timestamps collide.

Idempotency at the API layer

Any unsafe operation a client might retry needs an idempotency key. The client generates a UUID per logical intent and sends it as a header; the server stores the key with the response and replays that response on a repeat.

POST /v1/payments
Idempotency-Key: 8f14e45f-ea6c-4a1e-9c5b-2b31a1f7d0aa
{ "amount": 4999, "currency": "INR", "source": "card_9x2" }

# Server logic
1. INSERT key with state=IN_PROGRESS   # unique constraint = the lock
   - conflict + IN_PROGRESS  → 409 (client retries later)
   - conflict + COMPLETED    → return the stored response, do nothing
2. do the work
3. store the response body + status against the key, state=COMPLETED
4. expire the key after 24h

Note the key is stored with the response, not just as a "seen" flag — a retry must return the same body, or the client can't tell success from a new failure. See topic 14 for the deeper treatment.

Verbs, status codes, and errors

MethodSafeIdempotentNotes
GETYesYesCacheable. Never mutate in a GET.
POSTNoNoNeeds an idempotency key to be retry-safe.
PUTNoYesFull replace — sending it twice is the same as once.
PATCHNoNot inherently{"count": 5} is idempotent; {"count": "+1"} is not.
DELETENoYesSecond call returns 404 or 204 — pick one and document it.

Codes worth getting right: 400 malformed, 401 not authenticated, 403 authenticated but not allowed, 404 absent (also use it to hide existence from unauthorised callers), 409 conflict/version mismatch, 422 semantically invalid, 429 rate limited (always with Retry-After), 503 overloaded — retry, 504 upstream timeout — retry carefully.

Return machine-readable errors: a stable code string clients can branch on, a human message, and a request_id for support. Never make clients regex your prose.

Versioning and compatibility

  • URL versioning (/v1/) — ugly, obvious, and it works. Best default for public APIs.
  • Header versioning — cleaner URLs, but easy for clients to omit and hard to see in logs and caches.
  • Additive-only evolution — the real answer for internal services. Add fields, never remove or repurpose them; make new fields optional with a sensible default. Protobuf enforces this by design if you never reuse field numbers.

Rule of thumb: adding an optional field is safe; removing a field, renaming one, tightening validation, or changing a default is a breaking change even if the type is unchanged.

Watch for
  • Unbounded list endpoints. Every collection endpoint needs a default page size and a hard maximum. Without them, one client's limit=1000000 takes the service down.
  • Chatty APIs. If rendering one screen takes 30 calls, the p99 is network round trips, not your code. Add a purpose-built composite endpoint (BFF) rather than making the client loop.
  • Leaking internals. Auto-incrementing IDs disclose volume and enable enumeration. Use UUIDs or Snowflake IDs on public surfaces.
  • No deprecation path. Ship a Sunset header and usage metrics per version, or you will never turn v1 off.
03

Load Balancing

L4L7round robinhealth checkssticky sessions
Why it exists

One server is a capacity ceiling and a single point of failure. A load balancer turns N servers into one address, and — just as importantly — it is the thing that notices a server has died and stops sending it traffic. Distribution is the advertised feature; failure detection is the one that saves you.

L4 vs L7

L4 (transport)L7 (application)
SeesIP + port. Forwards packets/connections.Full HTTP: path, headers, cookies, method.
Can doFast, protocol-agnostic, very high throughputPath routing, header rewriting, TLS termination, retries, compression, WAF, canaries
Cannot doAnything content-awareNothing much — but it costs more CPU and adds a hop of latency
TypicalAWS NLB, IPVS, HAProxy in TCP modeAWS ALB, NGINX, Envoy, Traefik
Use whenNon-HTTP protocols, extreme throughput, you want end-to-end TLS untouchedAlmost all HTTP services — the routing features are worth the hop
Clients global DNS / Anycast picks a region L7 balancer TLS termination least-connections app-1 healthy · 40 conns app-2 healthy · 38 conns app-3 failed 3 checks drained health check: GET /healthz every 5s · 3 strikes out, 2 to return
The balancer's real job: knowing app-3 is dead before your users do.

Algorithms

AlgorithmHowGood whenFails when
Round robinNext server in orderUniform requests, uniform serversRequest cost varies — a slow server keeps getting work
Weighted round robinBigger servers get more turnsHeterogeneous fleet, canary rolloutsWeights go stale as instances change
Least connectionsFewest in-flight requests winsVariable request duration — the safe defaultA server that fails instantly looks "free" and attracts everything
Least response timeLatency-weightedMixed hardware and noisy neighboursNeeds good measurement; can oscillate
Power of two choicesSample 2 at random, pick the less loadedVery large fleets — near-optimal with almost no coordinationRarely; this is what modern meshes do
Consistent hashingHash the key to a server on a ringCache affinity, session locality, sticky-by-keyHot keys concentrate load on one node
IP hashHash the client IPPoor-man's stickinessCarrier NAT puts thousands of users on one server

Health checks

  • Active — the balancer polls /healthz on an interval. Cheap and predictable. Use hysteresis: 3 consecutive failures to eject, 2 successes to return, so one blip doesn't flap the fleet.
  • Passive — infer health from real traffic (5xx rate, timeouts) and eject outliers. Catches failures a synthetic probe misses.
  • Shallow vs deep — a shallow check proves the process is up. A deep check pings the database too. Deep checks are a trap: when the shared database hiccups, every server fails its check simultaneously and the balancer removes the entire fleet, converting a degradation into an outage. Keep the load-balancer check shallow; monitor dependencies separately.
  • Connection draining — on deploy or scale-in, stop sending new requests but let in-flight ones finish (typically 30 s). Without it, every deploy is a small burst of 502s.
The classic failure

Deep health checks plus aggressive ejection is how a slow database becomes a total outage. Prefer fail-open: if all backends are unhealthy, send traffic anyway. A degraded response beats no response.

Sticky sessions

Stickiness pins a client to one server, usually via a cookie. It exists because someone put session state in server memory. It costs you: uneven load, a full session loss when that server dies, no clean scale-in, and harder deploys.

The better answer is almost always to remove the need for it — put session state in Redis or a signed JWT and make every server interchangeable. Legitimate remaining uses: in-memory upload buffers, long-lived WebSocket connections (which are inherently sticky), and local caches you want to keep warm (use consistent hashing rather than cookies for that).

Above the load balancer

  • DNS round robin — cheap multi-region spreading, but clients and resolvers cache TTLs and ignore failures, so failover is slow and partial.
  • GeoDNS / latency-based routing — return the nearest healthy region's IP. Common front door for multi-region.
  • Anycast — one IP announced from many locations; the network routes to the closest. Instant failover, no DNS TTL problem. This is how CDNs and public DNS resolvers work.
Say this

"Anycast or latency-based DNS to pick a region, then an L7 balancer per region doing TLS termination and least-connections. Shallow health checks with 3-strike ejection and 30-second connection draining. No sticky sessions — session state goes in Redis so any server can serve any request, which also means autoscaling and deploys are non-events."

04

Caching

CDNRediscache-asidewrite-throughevictionstampede
Why it exists

Because the same expensive answer gets computed over and over. A cache trades freshness for latency and load — that is the whole deal, and every cache decision is a restatement of it. If your data cannot tolerate any staleness, you cannot cache it; if it can tolerate a lot, you can cache it everywhere.

The layers, from cheapest to most expensive

Browser 0 ms · free Cache-Control CDN edge 10–40 ms static + media In-process <0.1 ms per-server, tiny Redis 1–2 ms shared, coherent Database 10–100 ms source of truth each layer only sees the misses of the one before it a 95% hit rate at each of two layers means the database sees 0.25% of traffic — that is the whole reason 70,000 reads/second is survivable
Caches compose multiplicatively. This is why the read path is usually the easy part once you accept staleness.

Patterns

PatternRead pathWrite pathTradeoff
Cache-aside (lazy loading)App checks cache; on miss reads DB and populatesApp writes DB, then deletes the keySimplest and most common. Cache only holds what's actually requested. First request after a write is always a miss; a race can leave a stale entry.
Read-throughCache library fetches from DB on missSame as aboveSame behaviour, cleaner app code, but you're tied to the cache library's semantics.
Write-throughAlways a hit for written keysWrite cache and DB synchronouslyCache is never stale; every write pays both latencies, and you cache data nobody reads.
Write-behind (write-back)From cacheWrite cache now, flush to DB asynchronouslyFastest writes and absorbs bursts; you will lose data if the cache node dies before the flush. Only for tolerable losses — view counts, not payments.
Refresh-aheadFrom cacheCache proactively refreshes hot keys before TTLHides the miss latency on predictably hot keys; wastes work on keys that would have expired unused.
Delete, don't update

On a write, invalidate the key rather than writing the new value into it. Two concurrent writers that both update the cache can commit to the database in one order and to the cache in the other, leaving the cache permanently wrong. A delete is safe under any interleaving — the next reader re-reads the truth.

And do it in the right order: write the database first, then delete the key. Deleting first leaves a window where a concurrent reader repopulates the cache with the old value just before the DB write lands.

Eviction policies

PolicyEvictsGood forWeakness
LRULeast recently usedGeneral purpose — the defaultA big scan (a batch job) walks through and evicts everything useful
LFULeast frequently usedStable hot setsOld-but-once-popular items never leave; needs decay
FIFOOldest insertedTrivially cheapIgnores popularity entirely
TTLAnything past its ageBounding staleness explicitlySynchronised expiry causes stampedes
RandomAny keySurprisingly decent, O(1), no bookkeepingNo locality awareness

Redis specifics worth naming: allkeys-lru for a pure cache, volatile-lru when the same instance also holds non-expiring data, and noeviction (write errors when full) when losing keys is unacceptable — which usually means it wasn't a cache.

The four failure modes, and their fixes

Stampede / thundering herd

A hot key expires and 10,000 concurrent requests all miss and all hit the database at once.

Fix: a per-key lock or single-flight so exactly one request recomputes while the rest wait or serve stale; plus jittered TTLs (300s ± 10%) so keys never expire in lockstep; plus probabilistic early expiry — refresh a little before the TTL with a probability that rises as expiry approaches.

Cache penetration

Requests for keys that don't exist anywhere miss the cache and the database, every time. Easy to weaponise.

Fix: cache the negative result with a short TTL, and/or put a Bloom filter in front to answer "definitely not present" without a lookup.

Hot key

One celebrity key gets 40% of traffic and saturates the single shard that owns it.

Fix: replicate the key across N shards with a suffix (key#0..key#9) and have clients pick at random; or add a small local in-process cache in front of Redis, which absorbs the repeats before they leave the box.

Cache avalanche

The cache tier restarts (or a large TTL cohort expires together) and the full unfiltered load lands on a database that has never seen it.

Fix: jittered TTLs, warm the cache before taking traffic, and rate-limit or shed at the database boundary so the recovery doesn't take the database with it.

Sizing and measuring

Hit rate needed to protect the DB:
  DB QPS = total QPS × (1 − hit rate)
  70,000 QPS at 95% hit  →  3,500 QPS to the DB     # fine
  70,000 QPS at 80% hit  →  14,000 QPS to the DB    # not fine
  # Going 95% → 99% cuts DB load 5×. The last few points matter most.

Memory:
  hot set = items × avg size × (1 + overhead ~0.2)
  1M sessions × 2 KB × 1.2  ≈  2.4 GB   # one node, comfortably

Watch, in order: hit rate, p99 latency, eviction rate, memory %.
A rising eviction rate is the leading indicator — it precedes the hit-rate drop.
Say this

"Cache-aside in Redis with a 5-minute jittered TTL, and on write I delete the key rather than updating it — writing it risks two writers committing in different orders and leaving the cache permanently stale. I'd expect around 95% hit rate, so the database sees roughly 3,500 QPS instead of 70,000. The risk is a stampede when a hot key expires, so I'd single-flight the recompute. And I'd only cache the feed, not the account balance — stale money is a bug, stale likes aren't."

When not to cache
  • Data that must be exactly correct on read (balances, inventory at checkout, auth revocation).
  • Data with no reuse — if every key is read once, a cache is pure overhead.
  • As a fix for a missing index. Cache the answer to a query that already runs well; never use one to hide a table scan, because the miss path is still there and it will find you.