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:
Question
Why it changes the design
How many daily active users, and how active?
Sets QPS, which sets fleet size and whether you shard at all.
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:
Hop
Budget
Note
Client → edge (TLS + network)
40 ms
Fixed by geography; only a CDN/PoP helps.
Edge → app server
10 ms
Same region.
Auth / rate limit
5 ms
Must be a cache hit, never a DB call.
Cache lookup
2 ms
Redis in the same AZ.
Database (on miss)
30 ms
Indexed query, one round trip.
Serialisation + response
15 ms
JSON size matters more than people think.
Headroom
98 ms
For 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 / JSON
GraphQL
gRPC
Best for
Public APIs, CRUD, anything cacheable
Clients with varied, nested data needs
Internal service-to-service
Transport
HTTP/1.1 or 2, text
HTTP POST, text
HTTP/2, binary Protobuf
Caching
Free — HTTP caches, CDNs, ETags
Hard: one URL, POST body varies
Manual only
Payload size
Baseline
Smaller (client picks fields)
~30–50% of JSON
Streaming
No (SSE bolted on)
Subscriptions
Native, bidirectional
Browser support
Native
Native
Needs grpc-web + proxy
Cost
Over/under-fetching, N+1 round trips
Query cost is unbounded; server complexity; easy to DoS yourself
Opaque 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 number
Cursor (keyset)
Query
LIMIT 20 OFFSET 10000
WHERE (created_at, id) < (?, ?) LIMIT 20
Cost at page 500
Database scans and discards 10,000 rows
Index seek — same cost as page 1
Items inserted mid-scroll
Rows shift; users see duplicates or skips
Stable — the cursor pins a position
Jump to page 47
Easy
Not supported
Total count
Easy
Needs a separate (expensive) query
Use when
Small admin tables where users pick pages
Feeds, 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
Method
Safe
Idempotent
Notes
GET
Yes
Yes
Cacheable. Never mutate in a GET.
POST
No
No
Needs an idempotency key to be retry-safe.
PUT
No
Yes
Full replace — sending it twice is the same as once.
PATCH
No
Not inherently
{"count": 5} is idempotent; {"count": "+1"} is not.
DELETE
No
Yes
Second 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.
Nothing much — but it costs more CPU and adds a hop of latency
Typical
AWS NLB, IPVS, HAProxy in TCP mode
AWS ALB, NGINX, Envoy, Traefik
Use when
Non-HTTP protocols, extreme throughput, you want end-to-end TLS untouched
Almost all HTTP services — the routing features are worth the hop
The balancer's real job: knowing app-3 is dead before your users do.
Algorithms
Algorithm
How
Good when
Fails when
Round robin
Next server in order
Uniform requests, uniform servers
Request cost varies — a slow server keeps getting work
Weighted round robin
Bigger servers get more turns
Heterogeneous fleet, canary rollouts
Weights go stale as instances change
Least connections
Fewest in-flight requests wins
Variable request duration — the safe default
A server that fails instantly looks "free" and attracts everything
Least response time
Latency-weighted
Mixed hardware and noisy neighbours
Needs good measurement; can oscillate
Power of two choices
Sample 2 at random, pick the less loaded
Very large fleets — near-optimal with almost no coordination
Rarely; this is what modern meshes do
Consistent hashing
Hash the key to a server on a ring
Cache affinity, session locality, sticky-by-key
Hot keys concentrate load on one node
IP hash
Hash the client IP
Poor-man's stickiness
Carrier 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
Caches compose multiplicatively. This is why the read path is usually the easy part once you accept staleness.
Patterns
Pattern
Read path
Write path
Tradeoff
Cache-aside (lazy loading)
App checks cache; on miss reads DB and populates
App writes DB, then deletes the key
Simplest 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-through
Cache library fetches from DB on miss
Same as above
Same behaviour, cleaner app code, but you're tied to the cache library's semantics.
Write-through
Always a hit for written keys
Write cache and DB synchronously
Cache is never stale; every write pays both latencies, and you cache data nobody reads.
Write-behind (write-back)
From cache
Write cache now, flush to DB asynchronously
Fastest 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-ahead
From cache
Cache proactively refreshes hot keys before TTL
Hides 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
Policy
Evicts
Good for
Weakness
LRU
Least recently used
General purpose — the default
A big scan (a batch job) walks through and evicts everything useful
LFU
Least frequently used
Stable hot sets
Old-but-once-popular items never leave; needs decay
FIFO
Oldest inserted
Trivially cheap
Ignores popularity entirely
TTL
Anything past its age
Bounding staleness explicitly
Synchronised expiry causes stampedes
Random
Any key
Surprisingly decent, O(1), no bookkeeping
No 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.