System Design Interview Prep
Curated system design interview questions, foundational concepts, distributed-system patterns, and end-to-end design problems.
Topics covered:
- Fundamentals: CAP, PACELC, ACID vs BASE, consistency models, latency vs throughput, back-of-envelope estimation
- Scalability: vertical vs horizontal, stateless services, load balancing, CDN
- Data: SQL vs NoSQL, indexing, sharding, replication, partitioning, secondary indexes
- Caching: layers, eviction, write strategies, invalidation, cache stampede
- Messaging: queues, pub/sub, brokers (Kafka, Rabbit, SQS), exactly-once
- Communication: REST, gRPC, GraphQL, WebSockets, Server-Sent Events, long-polling
- Distributed systems: consensus (Raft, Paxos), clocks (Lamport, vector), gossip, leader election
- Reliability: redundancy, retries, backoff, circuit breakers, bulkheads, idempotency
- Patterns: CQRS, event sourcing, saga, outbox, sidecar, strangler fig, BFF
- Security: AuthN/AuthZ, OAuth2/OIDC, JWT, rate limiting, OWASP
- Observability: metrics, traces, logs, SLO/SLI/SLA, error budgets
- Top design problems: URL shortener, news feed, chat, ride-share, rate limiter, distributed cache, search, notifications, video streaming, payments, ad-click pipeline, recommendation, file storage, web crawler
Q: What are CAP and PACELC, and how do they shape distributed-system design?
Answer:
CAP is a forced-choice theorem about distributed databases under network failure. PACELC extends it to the non-failure case, where latency vs consistency is the real trade. Together they describe almost every storage system on the market.
CAP Theorem (Brewer, 2000)
In the presence of a network partition, a system can provide at most two of:
- C — Consistency: every read sees the most recent committed write (linearizability).
- A — Availability: every request gets a non-error response.
- P — Partition tolerance: system keeps working despite dropped/delayed messages.
Critical: P is not optional in any real distributed system. Networks fail. So the actual choice is CP vs AP during a partition.
Partition occurs
│
┌─────────────┴──────────────┐
▼ ▼
CP system AP system
refuse some requests accept all requests
(return errors) (may return stale data)
guarantees consistency guarantees availability
Example Classifications
| System | Class | Behavior under partition |
|---|---|---|
| Spanner, CockroachDB | CP | Refuses writes that can't reach majority |
| ZooKeeper, etcd | CP | Loses minority side |
| DynamoDB (eventual) | AP | Both sides accept writes, reconcile later |
| Cassandra (default) | AP | Tunable per query via consistency level |
| Postgres (single primary) | CP | Standby read-only if primary unreachable |
| MongoDB | CP (default) | Minority partition rejects writes |
| Riak | AP | Sloppy quorum, hinted handoff |
PACELC (Abadi, 2012)
CAP only describes partition behavior. PACELC adds the steady-state question:
If Partition: choose A or C. Else (no partition): choose L (Latency) or C (Consistency).
A system is described by both halves: PA/EL (DynamoDB), PC/EC (Spanner), PA/EC (MongoDB sometimes), PC/EL (some configs).
The "EL or EC" axis matters because synchronous replication to remote replicas adds latency. Choosing EC means waiting for ack from N regions before responding to the client.
Tunable Consistency
Modern systems rarely pick one corner. They expose per-operation knobs:
-- Postgres: synchronous_commit per transaction
SET LOCAL synchronous_commit = on; -- wait for replica fsync
-- Cassandra
SELECT ... USING CONSISTENCY QUORUM; -- per query
-- DynamoDB
GetItem(ConsistentRead=true) -- per operation
You buy consistency only where you need it. Eventual reads for product browse; strong reads for checkout.
Quorum Math
For N replicas:
W + R > N → strong consistency on reads
W + R ≤ N → eventually consistent
Common configs:
N=3, W=2, R=2: tolerates 1 node loss, strong reads via quorum.N=3, W=3, R=1: writes block on full ISR; reads are fast.N=3, W=1, R=1: AP — fast, eventually consistent.
What CAP Is NOT
- Not about whether one node is down. Single-node failures are a different problem.
- Not a static label for an entire system. Same DB can run CP or AP depending on config.
- Not a green light to pick "any two." You always need P.
Interview Use
When asked "what's the consistency model of your design?":
- State the failure mode you're optimizing for.
- Choose CP or AP per data class, not for the whole system. Orders → CP. Likes → AP.
- Mention quorum settings, replication topology, and what happens during partition (errors vs stale reads).
Common Mistakes
| Mistake | Reality |
|---|---|
| "Cassandra is AP, full stop" | Tunable per query: CONSISTENCY ONE (AP) vs ALL (CP-leaning) |
| "We can have CA — we're inside one AZ" | Cross-rack network partitions still happen |
| "Spanner beats CAP with TrueTime" | It's still CP — just smaller partition windows |
| Using CAP to defend eventual consistency for a bank balance | Pick the right axis per data domain |
[!NOTE] CAP is the partition-time trade-off. PACELC is the steady-state trade-off. Real systems live mostly in steady state — PACELC matters more day-to-day, but CAP determines what happens when the network actually breaks.
Interview Follow-ups
- "How would you build a CP system on top of an AP store?" — Wrap with a consensus layer (Raft) above the keyspace; trade availability for linearizability.
- "Why doesn't Spanner break CAP?" — It doesn't. It's CP. TrueTime narrows the wait window to ~7 ms, making the consistency cost cheap.
- "What's linearizability vs serializability?" — Linearizability is about single-object real-time ordering. Serializability is about transactions being equivalent to some serial order. SQL DBs offer the latter; distributed KVs argue about the former.
Q: ACID vs BASE — what guarantees, and when to pick which?
Answer:
ACID is the transactional guarantee of relational databases: writes are all-or-nothing, isolated, and durable. BASE is the loose guarantee of NoSQL and distributed systems: writes eventually settle. Picking between them per-data-class is one of the most consequential design decisions in any system.
ACID
- Atomicity: a transaction succeeds entirely or fails entirely. No partial commits.
- Consistency: a transaction leaves the database in a valid state (constraints, FKs, triggers).
- Isolation: concurrent transactions don't see each other's intermediate state.
- Durability: once committed, the data survives crashes (fsync to disk or replicated WAL).
Example: bank transfer.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Either both rows change, or neither. No middle state where money disappears.
Isolation Levels
The "I" in ACID is not binary — SQL standard defines four levels:
| Level | Prevents | Allows |
|---|---|---|
| Read Uncommitted | nothing | dirty reads (rare in practice) |
| Read Committed (PG default) | dirty reads | non-repeatable reads, phantoms |
| Repeatable Read (MySQL InnoDB default) | non-repeatable reads | phantoms (PG: also prevents phantoms via snapshot) |
| Serializable | everything | — |
Anomalies:
- Dirty read: read uncommitted data from another tx.
- Non-repeatable read: same query, two values in one tx.
- Phantom read: same range query, different row count.
- Lost update: two tx read, both write, second overwrites first.
- Write skew: two tx read-then-write disjoint rows under a constraint that should hold globally.
SERIALIZABLE prevents all but is the slowest. Most apps run READ_COMMITTED and add SELECT ... FOR UPDATE or version columns to guard hot rows.
BASE
- Basically Available: system responds (maybe with stale data).
- Soft state: state may change over time without input (replicas converge).
- Eventual consistency: given no new writes, all replicas converge.
Trade: relaxed consistency in exchange for:
- Higher availability under partitions.
- Lower latency (no synchronous cross-region writes).
- Horizontal scale beyond what 2PC allows.
Example: a social-network "like count." Showing 1,234 when it's truly 1,235 is fine.
How to Decide
Per data class, ask:
| Question | ACID | BASE |
|---|---|---|
| Are writes financial / legal / regulated? | ✓ | ✗ |
| Can a user tolerate stale reads for seconds? | ✗ | ✓ |
| Is the dataset > 1 TB and growing? | scaling pain | ✓ |
| Do you need multi-region writes < 100 ms? | hard | natural fit |
| Is referential integrity critical? | ✓ | ✗ |
| Does a stale value cause data loss or only UX glitch? | ACID if loss | BASE if UX |
In one system you almost always have both:
- ACID for orders, payments, inventory, user identity.
- BASE for feeds, recommendations, analytics counters, search indexes.
Mixing Them Safely
Common pattern: write to ACID system of record, asynchronously propagate to BASE views.
Postgres (orders, ACID)
│
▼ CDC (Debezium / outbox)
▼
Kafka
│
▼
├──► ElasticSearch (search index)
├──► Redis (cache)
└──► ClickHouse (analytics)
Source of truth is ACID. Derived views are BASE. Reconcile drift via periodic full-sync jobs or bookkeeping.
Quasi-ACID at Scale
- Spanner / CockroachDB: distributed ACID. External consistency via TrueTime / hybrid logical clocks. Slower than single-node PG, faster than 2PC.
- DynamoDB Transactions: ACID across up to 100 items. Adds latency.
- MongoDB multi-document transactions: ACID within a shard; cross-shard adds 2PC.
These narrow the gap but don't eliminate it. Cost (latency, throughput) is real.
Common Mistakes
| Mistake | Reality |
|---|---|
| "We use Postgres, so we're ACID" | Only inside transactions. Async fanouts to caches/search are eventual |
| "Eventual consistency = unreliable" | Means "consistent in the limit"; with bounded staleness it's measurable and engineerable |
| "Use NoSQL because it scales" | Most workloads fit on one well-tuned Postgres until very large |
| Running everything Serializable | Conflict aborts → retry storms; rarely the right default |
| Cross-microservice 2PC | Operational nightmare; use Saga + Outbox |
[!NOTE] ACID is a feature you pay for. BASE is a property you build around. The biggest engineering wins come from segmenting your data correctly: ACID where wrongness costs money, BASE everywhere else.
Interview Follow-ups
- "How would you maintain a global like-count without write contention?" — Sharded counter; per-shard atomic increment; periodic aggregation. BASE; final number eventually consistent.
- "How do you keep an ACID DB and a search index in sync?" — Outbox table in the same transaction; CDC stream → indexer. Avoid dual writes.
- "What does
SELECT FOR UPDATE SKIP LOCKEDsolve?" — Lets multiple workers pull from a job table without blocking each other or pulling the same row.
Q: Explain consistency models — strong, eventual, causal, read-your-writes, monotonic.
Answer:
"Consistency" is overloaded — it means one thing in CAP, another in ACID, and a third in distributed-systems literature. The literature definition is the one that matters at design time: what guarantees does a read make about the writes it observes?
The Spectrum
Strongest Weakest
─────────────────────────────────────────────────────────────────────────►
Strict Serializable Linearizable Sequential Causal Eventual
Roughly: the further left, the more coordination required and the slower the system.
Strong Consistency (Linearizability)
A read returns the most recent committed write in real time. As if there were one copy of the data and operations happened one at a time.
T1: write(x, 1)
T2: write(x, 2) (after T1 commits)
T3: read(x) must return 2
Achieved via consensus (Raft/Paxos), single-leader writes with synchronous replication, or external clocks (Spanner).
Cost: latency = round-trip to quorum. Cannot be globally faster than the speed of light × distance.
Sequential Consistency
Operations appear in some total order consistent with each process's program order, but not necessarily the real-time order.
Weaker than linearizable. Strong enough for many algorithms (caches, mutexes).
Causal Consistency
Operations that are causally related are seen in the same order by all replicas. Concurrent operations can be observed in any order.
T1: post(p1)
T2: comment(p1, c1) depends on p1
→ every reader sees p1 before c1
T3: post(p2) concurrent with above
→ readers may see p2 before or after p1/c1
Implemented with vector clocks. Practical for social feeds, collaborative editing.
Read-Your-Writes
A user always sees their own writes. Other users may not see them yet.
You post a photo.
You immediately refresh — you see the photo.
Your follower refreshes — may not see it yet.
Common pattern: route a user's reads to the same replica (or leader) as their writes for a short window. Or buffer the write client-side until visible.
Monotonic Reads
If a process reads value v1, subsequent reads will see v1 or newer. Never goes backward.
Without monotonic reads, a load-balanced read might bounce between replicas with different lag and the user sees state regress. Stick a user to a replica (sticky sessions) to fix.
Monotonic Writes
A process's writes are applied in the order it issued them. Often paired with read-your-writes.
Eventual Consistency
Given no new writes, all replicas converge to the same value. No bound on when.
Strongest claim: "eventually" — useless for SLAs unless you add bounded staleness.
Cassandra Read One: nearest replica wins, may be stale by seconds
DynamoDB eventual: up to ~1s lag typical
S3: strong consistency since 2020
Bounded Staleness
A practical strengthening of eventual: read no more than T seconds (or N operations) behind the latest committed write.
Cosmos DB exposes this as a knob: "5 seconds" or "100 writes" max lag. Useful for dashboards that can be slightly behind but not arbitrarily so.
Choosing Per Data Class
| Data | Right model |
|---|---|
| Bank balance | Linearizable |
| Order status | Read-your-writes minimum |
| Inventory decrement | Linearizable on the SKU |
| Chat message order in a room | Causal |
| User profile read after edit | Read-your-writes |
| Social like count | Eventual |
| Search result | Eventual w/ bounded lag |
| Analytics dashboard | Bounded staleness |
Where Each Lives
| System | Default model |
|---|---|
| Postgres (single leader) | Linearizable on the leader; standby is async eventual |
| MySQL semi-sync | Linearizable on commit; standby slightly behind |
| Spanner | Strict serializable (linearizable + serializable txns) |
| DynamoDB | Eventual default, strong on opt-in |
| Cassandra | Tunable per query (ONE, QUORUM, ALL) |
| Redis | Linearizable on the primary; replicas async |
| Kafka per partition | Linearizable per partition |
Read-Your-Writes Implementations
1. Sticky session. Send each user to the leader (or same replica) for a window after a write.
2. Version pinning. Client stores the write's version; reads include min_version, server waits or routes to a replica that has it.
3. Client-side cache. App holds the just-written value locally until the server's view catches up.
Causal Consistency Implementations
1. Vector clocks. Each replica tags writes with a vector of per-node counters. Reader walks dependencies.
2. Lamport clocks + dependency tracking. Simpler but less precise.
3. CRDT (conflict-free replicated data types). Some CRDTs are causal by construction.
Common Mistakes
| Mistake | Fix |
|---|---|
| Treating "eventually consistent" as "eventually we'll fix the bug" | Set a measurable bound; treat it as an SLI |
| Read-your-writes via "wait 500 ms" | Brittle; use sticky routing or version pinning |
| Causal consistency for monetary writes | Underpowered; use linearizable |
| Strong consistency for everything | Latency floor = cross-region RTT |
[!NOTE] In interviews, when asked "what consistency does this need?", the wrong answer is "strong" by default. The right answer is "linearizable for X, causal for Y, eventual for Z, with bounded staleness of ~T."
Interview Follow-ups
- "What's the difference between linearizable and serializable?" — Linearizable: real-time order on a single object. Serializable: transactions equivalent to some serial schedule. Strict serializable = both.
- "How does Spanner achieve external consistency?" — TrueTime (GPS + atomic clocks bound clock skew); commits wait out the uncertainty window.
- "Why is eventual consistency cheap?" — No coordination on the write path; replicas reconcile in the background.
Q: Latency vs throughput vs tail latency — why does p99 matter more than average?
Answer:
Two systems can have the same average latency and feel completely different to users. The difference is the distribution. Modern system design is dominated by the long tail (p99, p99.9), not by the mean.
Definitions
- Latency: time for one request, end to end.
- Throughput: requests served per unit time.
- Bandwidth: bytes per unit time.
They aren't independent: by Little's Law, concurrent_requests = throughput × latency. If you fix concurrency, raising one drops the other.
Why Averages Lie
Request latencies (ms):
20, 25, 22, 30, 27, 25, 28, 1200, 22, 26
^
Average: 142 ms slow tail
Median: 26 ms
p99: 1200 ms
Average gets dragged by a single outlier. Median + percentiles tell the real story.
Percentiles Cheat Sheet
| Percentile | Meaning |
|---|---|
| p50 (median) | Half of users see this or better |
| p90 | 1 in 10 users sees worse |
| p99 | 1 in 100 sees worse |
| p99.9 | 1 in 1000 sees worse |
| p99.99 | 1 in 10,000 — the "tail of the tail" |
Fan-Out Multiplies Tail Latency
A page that issues N parallel calls and waits for all returns when the slowest one returns. If each call has 1% chance of being slow:
N = 1: 1% of requests slow
N = 10: 1 - 0.99^10 ≈ 10% slow
N = 100: 1 - 0.99^100 ≈ 63% slow!
p99 of the service becomes p50 of the page. This is why backend latency p99 matters more than average — fan-out amplifies it.
Sources of Tail Latency
- GC pauses (long for big heaps, short with ZGC).
- JIT warmup on cold instances.
- Cache misses to slower storage.
- Lock contention under load.
- Network jitter / packet loss → retransmit.
- Disk fsync / log rotation.
- Cold connections (TCP slow start, TLS handshake).
- Coordinated omission: load generator stops counting when it can't push, hiding the real tail.
Latency Numbers to Remember
DC round-trip: ~500 µs
Cross-AZ: ~1–2 ms
Cross-region (US ↔ EU): ~80–100 ms
Cross-continent: ~150 ms
Memory read: ~100 ns
SSD random read: ~100 µs
HDD seek: ~10 ms
A simple "fetch from DB" query in another region is automatically ~100 ms before you do any work.
Throughput Tuning
Increase throughput via:
- Parallelism (multiple threads/cores).
- Pipelining (overlap requests; multiple in flight per connection).
- Batching (Kafka producer linger, DB multi-row insert).
- Async I/O (don't block threads on remote calls).
- Avoid contention (locks, hot rows, GC).
Each often hurts latency for individual requests in exchange for system-wide throughput.
Latency Tuning
Reduce latency via:
- Cache (RAM-fast vs disk/network).
- Move compute to data (e.g., DB-side aggregation).
- Reduce serial dependencies (parallelize independent calls).
- Reduce hops (CDN, edge compute).
- Connection reuse (HTTP keep-alive, gRPC streams).
- Pre-warm caches and connection pools.
Throughput vs Latency Trade-off
Throughput
│ ╱╲ ← optimal point
│ ╱ ╲
│ ╱ ╲ ← degradation
│ ╱ ╲
│ ╱
│ ╱
│ ╱
└──────────────────────────► Concurrency / load
Past the optimal point, adding load reduces throughput because queues fill, contention rises, GC churns. The same shape governs every queueing system.
Load Shedding > Queuing
When a service is overloaded, queueing more requests makes it worse — each new request sits behind a longer queue, increasing latency, until upstream times out and retries. Better: shed requests at the door.
Healthy: accept all requests
Degraded: accept critical, reject low-priority
Overloaded: reject all but health checks, surface clear 503
Hedged Requests
Tail latency mitigation: send a duplicate request to a second replica after a short delay (p95). Take whichever returns first. Cancels the slow one if possible.
Pioneered by Google for search. Cheap insurance against a single slow node.
t=0: send to A
t=p95: if no response, send to B
t=any: return first response, cancel the other
Coordinated Omission
A common benchmark bug: load generator can't keep up with target QPS, so it backs off — the slow responses it's measuring don't include the time spent waiting to send. Real-world load doesn't get to back off.
Use wrk2, tlb-bench, or modern tools that compensate. Otherwise your published p99 is fiction.
Latency Budgets
In an SLA of 200 ms p99 end to end:
Browser → CDN: 30 ms (network)
CDN → API gateway: 5 ms
Gateway → service: 1 ms
Service compute: 50 ms
Service → cache: 2 ms
Cache miss → DB: 20 ms
Serialization: 5 ms
Network back: 30 ms
────
143 ms (within budget)
Each layer must fit within its slice. Cache miss on every request blows the budget.
Common Mistakes
| Mistake | Reality |
|---|---|
| Reporting average latency | Hides tail; useless for SLA |
| Optimizing p50 when fan-out exists | Page latency is dominated by tail |
| Adding capacity to fix latency | Helps until queue depths drop; not a cure for slow code |
| Synchronous calls in series | Sums latencies; parallelize independent ones |
| Ignoring connection setup | TLS + DNS can add 100+ ms; pool aggressively |
[!NOTE] The most operationally important number in any service is its p99 latency under realistic load. Track it. Alert on it. Tune it. Average latency is a vanity metric.
Interview Follow-ups
- "How do you measure latency under load?" — Distributed tracing (sample); aggregate via histogram metrics (Prometheus +
histogram_quantile); load test withwrk2ork6. - "What's a hedged request, and when not to use it?" — Don't use for non-idempotent writes; don't use when load is already high (doubles work).
- "Why is p99 a worse SLO than
latency ≤ X for 99% of requests / minute?" — Both describe the same thing if framed right; the second form is what alerting actually does — windowed.
Q: How do you do back-of-envelope estimation in a system design interview?
Answer:
Most system design interviews expect numeric reasoning: storage in TB, requests per second, bandwidth in Gbps. The numbers don't have to be perfect — they have to be defensible and order-of-magnitude correct. Walk through assumptions out loud.
Numbers Worth Memorizing
Time (orders of magnitude)
L1 cache reference 1 ns
Branch mispredict 5 ns
L2 cache reference 7 ns
Mutex lock/unlock 25 ns
Main memory reference 100 ns
Compress 1KB with Zippy 3,000 ns
Send 2KB over 1 Gbps network 20,000 ns = 20 µs
SSD random read 150,000 ns = 150 µs
Read 1MB sequentially from memory 250,000 ns = 250 µs
Round-trip within same datacenter 500,000 ns = 500 µs
Read 1MB sequentially from SSD 1,000,000 ns = 1 ms
Disk seek (HDD) 10,000,000 ns = 10 ms
Read 1MB sequentially from HDD 20,000,000 ns = 20 ms
Send packet CA → Netherlands → CA 150,000,000 ns = 150 ms
Source: Jeff Dean's classic table, updated for SSDs.
Capacity Cheat Sheet
Server RAM: commodity 64–256 GB, large 1–2 TB
Server cores: 8–96
Server NIC: 10–25 Gbps typical, 100 Gbps possible
SSD throughput: 3–7 GB/s sequential read, 100k+ IOPS random
HDD throughput: 150 MB/s sequential, 100 IOPS random
Postgres on SSD: ~5,000–50,000 simple QPS per node depending on row size
Redis single node: 100k–1M ops/sec
Kafka broker: 100–500 MB/s sustained
S3: ~3,500 PUTs/sec/prefix, ~5,500 GETs/sec/prefix
Quantities Worth Memorizing
Seconds in a day: 86,400 ≈ 10⁵
Seconds in a year: 31,536,000 ≈ 3 × 10⁷ (~ π × 10⁷)
1 KB: 10³ bytes (or 2¹⁰; close enough)
1 MB: 10⁶
1 GB: 10⁹
1 TB: 10¹²
1 PB: 10¹⁵
1B users: 1,000,000,000 = 10⁹
1B/day: ≈ 11,574 / sec
1B/year: ≈ 32 / sec
The Workflow
When the interviewer says "design X for Y users":
1. Functional scope. Pick 2–4 core features. Don't try to design everything.
2. Scale assumptions. DAU (daily active users) → QPS → storage → bandwidth.
3. QPS. Reads vs writes. Peak ≈ 2–3 × average.
4. Storage. Bytes per record × records per day × retention.
5. Bandwidth. QPS × payload size.
Worked Example: Twitter Read Path
"Design Twitter for 200M DAU. 100 tweets/day read per user, 1 tweet/day written."
QPS:
Reads: 200M × 100 / 86,400 ≈ 230k QPS average
Writes: 200M × 1 / 86,400 ≈ 2.3k QPS average
Peak: 2–3× average → reads ~700k QPS, writes ~7k QPS
Storage growth:
Per tweet: 200 B (text) + 100 B (metadata) ≈ 300 B
Per day: 200M × 1 × 300 B = 60 GB
Per year: 60 GB × 365 ≈ 22 TB
5-year archive: ~110 TB (text only)
+ media (photo/video) — typically 10–100× larger
Bandwidth:
Read egress: 700k × 300 B ≈ 200 MB/s ≈ 1.6 Gbps
(plus media — much larger; usually served by CDN)
Conclusion: a few hundred servers for the read tier, fronted by CDN for media. One Postgres can't take 700k QPS — needs caching (Redis) and replication.
Worked Example: URL Shortener
"200M shortenings/day, 20B reads/day."
Write QPS: 200M / 86,400 ≈ 2,300/s
Read QPS: 20B / 86,400 ≈ 230,000/s
Read/write ratio ≈ 100:1
Storage:
Per record: ~500 B (long URL + short code + metadata + indexes)
Per day: 200M × 500 B = 100 GB
Per year: 100 GB × 365 ≈ 36 TB
5-year keep: ~180 TB
Decision: read tier dominated by cache. Origin DB can be smaller. Use Redis or in-memory LRU with TTL. Object storage for archive (rarely-accessed old links).
Common Conversions
1 Gbps = 125 MB/s
10 Gbps = 1.25 GB/s
100 Gbps = 12.5 GB/s
Latency budgets
Web page first paint: < 1 s
REST API p99: < 200 ms (good), < 1 s (acceptable)
Cache hit: < 10 ms
DB query (indexed): < 5 ms in DC
Cross-region call: 50–150 ms
Heuristics
- Daily → per-second: divide by ~10⁵.
- Peak factor: 2–3× for global apps; 5–10× for spikes (Black Friday, viral content).
- Read/write ratio: most consumer apps are 10:1 to 1000:1 read-heavy.
- Hot data: 80/20 rule — 20% of keys account for 80% of accesses. Cache the 20%.
- Index overhead: ~30% on top of row size for typical Postgres tables.
- Replication factor: ×3 for storage cost in most replicated DBs.
Validating With Constraints
Sanity-check by mapping numbers to known systems:
- "700k QPS" → larger than any single Postgres node; need sharding or caching.
- "100 TB" → fits in a single dedicated cluster, no need for object storage tier.
- "10 PB" → object storage is mandatory; pricing matters more than performance.
Common Mistakes
| Mistake | Fix |
|---|---|
| Citing 1B users with no peak factor | Multiply by 2–3 for real-world spikes |
| Computing total bytes without retention | Multiply by retention years |
| Ignoring metadata + indexes + replication | Real storage = data × ~3–5 |
| Working in mixed units (Mbps vs MB/s) | Pick one; bytes/sec is usually cleaner |
| Forgetting media/blob payloads | Photos/videos dominate bandwidth |
[!NOTE] Interviewers care more about how you reason than the exact answer. State assumptions, do arithmetic out loud, and revise when given a constraint. "I assumed 200M DAU, 100 reads each — does that match?" is a great prompt.
Interview Follow-ups
- "How would you handle 10× the load?" — Identify the bottleneck from the numbers (writes, reads, storage, bandwidth) and propose specific scaling (sharding, caching, CDN).
- "How much does this cost?" — TB × $/TB for storage, QPS × $/req for managed services, Gbps × $/GB for egress.
- "What's the latency budget end-to-end?" — Decompose into client → CDN → LB → app → cache → DB; sum the worst-case to compare against SLA.
Q: What is scalability, and what are the axes you can scale along?
Answer:
A scalable system handles more load by adding resources, ideally proportionally (2× resources → 2× capacity). The three classical axes — vertical, horizontal, and functional — describe different strategies. Real systems scale along all three at once.
The Scale Cube
Coined by Martin Abbott, this is the canonical mental model:
Z-axis: data partition / shard
▲
│
│
│
Y-axis (functional split, services)
◄────────┼────────►
│
│
▼
X-axis: clone / horizontal
- X: run more copies of the same thing behind a load balancer.
- Y: split by function — separate services for orders, payments, search.
- Z: split by data — shard customers by region, user_id mod N.
X-Axis: Horizontal Scaling
Run N identical app servers behind a load balancer. Prerequisites:
- Stateless application tier (no in-memory session that survives across requests).
- Externalize state to a shared store (Redis, DB).
- Idempotent request handling (because retries may hit different instances).
┌──── App-1
LB ─────┼──── App-2 → DB / cache / queue
└──── App-3
Cheap if you've designed for it. Almost free with K8s deployments + HPA.
Limits:
- Database eventually becomes the bottleneck.
- Shared mutable state (cache, queue) still has its own limits.
- Network coordination grows non-linearly past some point.
Y-Axis: Functional Decomposition
Split the monolith into services by domain:
Monolith Microservices
┌──────────────┐ ┌─────────┐ ┌─────────┐
│ Orders │ │ Orders │ │ Payments│
│ Payments │ → └─────────┘ └─────────┘
│ Inventory │ ┌─────────┐ ┌─────────┐
│ Notifications│ │ Inv. │ │ Notif. │
└──────────────┘ └─────────┘ └─────────┘
Benefits:
- Independent deploy cadence.
- Different DBs/runtimes per service.
- Failure isolation (payments outage doesn't kill catalog).
Costs:
- Network latency between services.
- Distributed transactions become Saga + Outbox.
- Operational complexity (deploys, monitoring, tracing).
Rule of thumb: split when teams are larger than ~8 engineers per service, or when domains have different scale/latency profiles.
Z-Axis: Sharding
Same code, different data. Each instance owns a subset (a shard) of the data.
Shard 1: user_id % 4 == 0
Shard 2: user_id % 4 == 1
Shard 3: user_id % 4 == 2
Shard 4: user_id % 4 == 3
Needed when a single DB node can't hold the working set or serve QPS. Common shard keys: user_id, tenant_id, geographic region.
See Sharding & Partitioning for details.
Vertical Scaling
"Bigger box." More CPU, more RAM, faster disk. Limits:
- Cost grows super-linearly past commodity hardware.
- Single failure domain.
- Eventually you hit physical ceiling (largest available SKU).
Right tool for: databases that can't shard, in-memory caches, single-leader systems.
Stateless vs Stateful
| Stateless | Stateful |
|---|---|
| Easy to scale horizontally | Sticky sessions / consistent hashing required |
| Any node can serve any request | Specific nodes own specific data |
| Examples: API servers, render workers | DB nodes, cache shards, Kafka brokers |
Rule: keep the application tier stateless; push state to a dedicated tier that's designed for it.
Read Scaling vs Write Scaling
Different problems:
Read scaling (easier):
- Read replicas.
- Caching (Redis, CDN).
- Materialized views.
- Eventual consistency where acceptable.
Write scaling (harder):
- Sharding.
- Write-behind buffering.
- Batching.
- Split into multiple writable services (e.g., separate write paths for orders vs activity log).
A read-heavy app (10:1, 100:1) is much easier to scale than a write-heavy app.
Amdahl's Law
speedup = 1 / ((1 - P) + P/N)
Where P is the parallelizable fraction. Sobering: even 95% parallel work has a hard ceiling at 20× speedup. The 5% serial bottleneck dominates eventually.
In practice: any single point (a primary DB, a global lock, a coordinator) eventually caps your scale, no matter how many app servers you add.
Universal Scalability Law (Gunther)
Refines Amdahl with coherence cost: as N grows, coordination between nodes pulls throughput down. Plotted, throughput peaks and then declines past an optimum.
Means: more boxes is not always more throughput. Past a point, you're paying more for coordination than you gain in capacity.
Designing for Scale Up-Front
A pragmatic recipe:
- Make the app stateless. Any future scaling is impossible without this.
- Externalize sessions. Redis or signed JWTs.
- Idempotent endpoints. Retry-safe by default (
Idempotency-Key). - Choose a shardable data model. Even if you start with one DB, design rows around a future shard key.
- Async where possible. Queue heavy work; don't block request threads.
- Cache strategically. Identify the 20% hot data; cache that.
- Measure before scaling. Profile p99, find the bottleneck, scale that layer.
Common Mistakes
| Mistake | Reality |
|---|---|
| Scaling app tier when DB is the bottleneck | Move the bottleneck; don't pile on the wrong layer |
| Sharding too early | Sharding adds complexity for life; defer until you have to |
| Microservices on day one for a 5-person team | Bigger ops burden than monolith; do Y-axis only when teams demand it |
| Caching everything for "performance" | Cache adds invalidation bugs; cache hot data only |
| Assuming horizontal scale = infinite | Universal Scalability Law says no |
[!NOTE] Scale problems are rarely about adding boxes — they're about removing coordination. The fastest single-shard system you can design beats the largest distributed system you can build.
Interview Follow-ups
- "What's the biggest single bottleneck in your design?" — Always have an answer; that's where you'd scale next.
- "Can the DB handle 10× the QPS?" — If no: read replicas + cache for reads; sharding for writes.
- "How would you scale to global users?" — Multi-region deploy, latency-routed DNS / Anycast, regional caches, eventual consistency for non-critical data.
Q: What are SLI, SLO, SLA, and error budgets — and how do they connect?
Answer:
SRE terminology that often confuses engineers. The three are nested:
- SLI: a measurement.
- SLO: an internal target for that measurement.
- SLA: a contract with consequences if you miss the SLO.
- Error budget: how much you can afford to miss the SLO over a window.
SLI — Service Level Indicator
A quantitative measure of one aspect of service quality. Common SLIs:
- Availability: ratio of successful responses to total.
successful_requests / total_requests - Latency: fraction of requests faster than threshold.
count(requests where duration < 200ms) / total_requests - Throughput: requests per second.
- Correctness: ratio of responses that match a golden expectation (rare, valuable).
- Durability: probability of data loss over time (mostly for storage).
- Freshness: how stale the data behind a request can be.
SLO — Service Level Objective
A target for an SLI over a window:
99.9% of requests succeed, measured over 30 days
99% of p99 latencies ≤ 200 ms, measured over 7 days
The "9s" budget mapped to downtime:
| SLO | Allowed downtime per 30 days |
|---|---|
| 99% | 7h 12m |
| 99.5% | 3h 36m |
| 99.9% | 43m |
| 99.95% | 21m |
| 99.99% | 4m 19s |
| 99.999% ("five nines") | 26 seconds |
Anything past 99.99% requires multi-region active-active and disciplined release engineering.
SLA — Service Level Agreement
A contract between you and a customer, often with refund clauses:
"We guarantee 99.9% monthly uptime. Below that, you get 10% credit.
Below 99% you get 25% credit."
The SLO is internal (your engineering target). The SLA is external (legal). The SLO should be tighter than the SLA — leave headroom for unknowns and customer-friendly fudge.
Error Budget
If SLO = 99.9% availability over 30 days, the error budget is the 0.1% — about 43 minutes of allowed downtime per month.
Used for:
1. Release velocity decisions. Budget remaining → ship aggressively. Budget exhausted → halt risky changes; focus on reliability.
2. Engineering investment. Frequent budget burn → pay down reliability debt. Budget unused → invest in features instead.
error budget
100% ──────────────────────────────── SLO ceiling
│
│ ╱╲
│ ╱ ╲ ← burn
99.9%├────────────╱────────╲──────────── SLO target
│ ╱ ╲
│
│
99.5%│
└────────────────────────────────► time
Burn Rate Alerts
Better than threshold alerts: alert on how fast you're burning the budget.
Burn rate = (errors_in_window / requests_in_window) / (1 - SLO)
Burn rate = 1 → you'd consume the budget exactly on schedule
Burn rate = 14 → you'd burn 30 days' budget in ~2 days
Common dual-burn-rate alert (Google SRE Workbook):
- Fast: 14× burn rate over 1 hour AND 5 minutes → page.
- Slow: 6× burn rate over 6 hours AND 30 minutes → ticket.
Catches outages quickly without paging on every flap.
Designing SLIs
Good SLI properties:
- User-perceived: measure what users feel, not what your dashboard fancies.
- Computable: from existing telemetry, no manual instrumentation needed.
- Aggregable: by service, by API, by tenant.
- Cheap: doesn't itself cost the budget to compute.
The two go-to SLIs for any HTTP service:
availability = sum(rate(http_requests{status!~"5.."}[5m]))
/ sum(rate(http_requests[5m]))
latency = sum(rate(http_requests_bucket{le="0.2"}[5m]))
/ sum(rate(http_requests[5m]))
Choosing SLO Targets
| Service type | Reasonable SLO |
|---|---|
| Internal tools | 99% |
| Customer-facing UI | 99.9% |
| Payment / financial APIs | 99.95% |
| Critical infra (DNS, auth) | 99.99% |
Higher targets force expensive architecture (multi-region, sync replication). Don't promise five-nines without buying the engineering.
Decoupling Internal SLO from External SLA
SLA (external): 99.9%
SLO (internal): 99.95% ← tighter, gives headroom
Budget alert at: 99.97% ← act before SLO threatened
If you fire alerts at the SLA threshold you've already missed it. Bake in margin.
Where Each Number Lives
SLO ┐
├── alert rules (burn-rate based)
│
├── dashboard headline ("we're at 99.91% this week")
│
├── feature freeze trigger (budget < 0)
│
└── post-mortem ROI ("how much SLO did this incident cost?")
Common Mistakes
| Mistake | Fix |
|---|---|
| Identical SLA and SLO | Tighten SLO to leave headroom |
| SLOs for internal metrics nobody cares about (CPU usage) | Pick user-perceived measures |
| 100% SLO | Impossible; even AWS doesn't claim it |
| Alerting on every below-threshold blip | Use burn rate over windows |
| Treating SLO as a goal to overshoot | If you're consistently at 99.999% on a 99.9% SLO, you're over-investing — spend the budget on features |
[!NOTE] Error budgets reframe reliability from "always more" to "exactly enough." A team that's never burning its budget should be shipping faster; a team always over budget should slow down and harden.
Interview Follow-ups
- "How do you decide SLO targets for a brand-new service?" — Start with a lenient SLO (99.5%) for a few months, gather data, tighten once stable. Don't promise what you haven't measured.
- "What if a dependency violates its SLO?" — Either renegotiate yours, mitigate via fallback (cache last-known-good), or split usage to a more reliable dependency.
- "What's the difference between MTTR, MTTD, and MTTF?" — Mean Time To Recovery (how long an outage lasts), Detection (how long to notice), Failure (between failures). All three feed into SLO performance.
Q: How do you approach a system design interview — the structured framework.
Answer:
System design interviews are conversational, not technical trivia. The interviewer is testing whether you can drive a 40-minute design conversation, make justified trade-offs, and surface risks. The framework below is what most senior interviewers expect — adapt it, don't recite it.
The 6-Step Framework
1. Clarify requirements (5 min)
2. Estimate scale (3 min)
3. API + data model (5 min)
4. High-level design (10 min)
5. Deep-dive on 2–3 areas (15 min)
6. Trade-offs & extensions (5 min)
Adjust the time per signal from the interviewer — if they push deeper on storage, spend more time on storage.
1. Clarify Requirements
Don't start drawing boxes. Ask:
Functional:
- Core features (pick 2–4; defer the rest).
- Read patterns vs write patterns.
- Multi-tenant? Multi-region?
Non-functional:
- DAU / scale.
- Latency target.
- Consistency requirements (per data class).
- Availability target (SLO).
- Mobile vs web? Geographic distribution?
Verbalize: "I'm assuming we don't need to design the mobile client, just the backend. Sound right?"
This step is graded. Skipping it is a red flag.
2. Estimate Scale
Numbers ground the design. See Back-of-Envelope Estimation.
Compute:
- QPS (read and write separately).
- Storage growth (per day, per year).
- Bandwidth (peak egress).
Match against known limits:
-
50k QPS → cache + replicas.
-
10 TB → sharding.
-
1 Gbps egress → CDN.
3. API + Data Model
API first — what's the contract?
POST /v1/orders { items: [...], userId: ... } → 201 { orderId }
GET /v1/orders/{id} → 200 { order }
GET /v1/orders?userId=&cursor= → 200 { orders, nextCursor }
PATCH /v1/orders/{id} { status: "cancelled" } → 200
State idempotency: "I'd require Idempotency-Key for POST."
Then the data model — pick a schema and a storage class.
Orders Table (Postgres, sharded by userId)
id UUID PK
user_id BIGINT (shard key)
status ENUM
amount NUMERIC
created_at TIMESTAMPTZ
INDEX (user_id, created_at DESC)
For NoSQL choices: state partition key + sort key + access pattern.
4. High-Level Design
Draw boxes. Standard layers:
Client → CDN → Load Balancer → API Gateway → Service(s) → Cache → DB
↓
Queue → Async Workers
Annotate what each layer does. Common ones:
- CDN: static assets, media.
- LB: L7 (Envoy/HAProxy/Nginx) for HTTP routing.
- API Gateway: AuthN, rate limit, request shaping.
- Stateless services: business logic.
- Cache: Redis for hot reads.
- Primary DB: source of truth.
- Search index: Elastic / OpenSearch.
- Queue/Stream: Kafka / SQS for async.
Tell a request's story end to end. "User clicks 'Order Now' → API gateway → orders service → write to Postgres → publish event to Kafka → notify service → SMS."
5. Deep Dive on 2–3 Areas
Pick the two or three areas where the interview signal is highest. Common deep-dive prompts:
- Sharding: which key, how to rebalance, hot shards.
- Caching: invalidation strategy, eviction, stampede protection.
- Consistency: which operations need linearizability vs eventual.
- Failure handling: what happens if X dies — service, DB primary, region.
- Hot keys: how the system handles a celebrity user / viral post.
- Scaling: where the next bottleneck is and how you'd address it.
Walk through one in detail; let the interviewer steer the others.
6. Trade-offs & Extensions
Self-critique:
- "I'd start without sharding; add it once we cross 5k write QPS."
- "Cache is a write-through pattern; trades latency on writes for stronger reads."
- "We'd lose data in a multi-region disaster; for orders, switch to synchronous cross-region writes once revenue justifies the latency hit."
Mention what you'd add given more time: search, recommendations, monitoring, on-call playbooks.
Anti-Patterns
| Anti-pattern | What to do instead |
|---|---|
| Jumping to boxes before clarifying | Spend 5 minutes on requirements |
| Reciting buzzwords (Kafka! Kubernetes! CRDTs!) | Justify every component you add |
| Overengineering for hypothetical scale | "We start simple; here's the migration path" |
| Ignoring failure modes | Always have an answer for "what if X dies?" |
| Drawing 30 boxes with no labels | Fewer, more thoughtful components |
| Picking technologies you don't understand | Stick to what you can defend |
Signals Interviewers Look For
Senior signals:
- Asks clarifying questions before designing.
- Estimates before optimizing.
- Picks trade-offs deliberately, not by default.
- Names the bottleneck before being asked.
- Talks about operability (deploys, alerts, on-call).
- Pushes back on under-specified asks.
Junior signals:
- Knows specific tech stacks.
- Can draw a typical architecture.
Red flags:
- "We need it consistent, so let's use [strong-consistency DB]" for everything.
- Designing the perfect 100M-user system on minute one.
- Never mentioning failure, observability, or cost.
Useful Phrases
- "For now, let's assume X. We can revisit."
- "I see two options: A trades latency for consistency, B does the opposite. I'd pick A because..."
- "The next bottleneck would be the DB write throughput. To go further: shard by user_id."
- "If a region goes down, here's what happens..."
- "I'd capture this metric and alert on it."
Common Mistakes
| Mistake | Fix |
|---|---|
| One-line answers ("just use Cassandra") | Justify with the constraint that drove it |
| Treating it as a pop quiz | It's a conversation; ask, respond, iterate |
| Avoiding numbers | Drive every decision with an estimate |
| Forgetting non-functional requirements | Latency, availability, cost matter as much as features |
| Designing for the average request | Tail latency, hot keys, abuse — the interesting cases |
[!NOTE] Best preparation: design 10–20 real systems out loud, in 45 minutes each, against a friend or alone. The framework becomes automatic; you spend the interview thinking about trade-offs, not the structure.
Interview Follow-ups
- "What if I gave you another hour?" — Always have a list: observability, capacity planning, deployment story, security threats, cost optimization.
- "What would you NOT do here?" — Show judgment: "I wouldn't use a graph DB for this; the query pattern is key-value."
- "How would you migrate from the existing system?" — Strangler fig pattern; dual writes; feature flag the cutover.
Q: Load balancers — L4 vs L7, algorithms, health checks, gotchas.
Answer:
A load balancer (LB) distributes incoming requests across multiple backend instances. The decision tree starts at OSI layer: L4 (transport) sees IP + port; L7 (application) sees HTTP headers, paths, cookies.
L4 vs L7
| Aspect | L4 (TCP/UDP) | L7 (HTTP/HTTPS) |
|---|---|---|
| Inspects | IP, port, protocol | URL, headers, cookies, payload |
| Latency overhead | µs | ms (small) |
| TLS termination | Pass-through or terminate | Always terminates |
| Routing rules | Backend pool by VIP/port | Path, host, header-based |
| Cost | Cheap, line-rate | More CPU |
| Examples | AWS NLB, HAProxy (mode tcp), IPVS, MetalLB | AWS ALB, Envoy, Nginx, Traefik, HAProxy (mode http) |
L4 for raw throughput, non-HTTP protocols, deep packet workloads. L7 for everything HTTP — that's where the smart routing pays off.
Common Algorithms
| Algorithm | When |
|---|---|
| Round Robin | Default; homogeneous backends |
| Weighted Round Robin | Heterogeneous (some larger boxes) |
| Least Connections | Sticky workloads (long-lived TCP) |
| Least Response Time | Auto-balance based on observed latency |
| Random | Surprisingly effective; no state |
| Random Two Choices ("Power of Two") | Pick 2 random, send to the less loaded — near-optimal, low overhead |
| IP Hash / Consistent Hash | Sticky sessions, cache locality |
| Resource-based | Drain by CPU/memory headroom (advanced) |
For most web services: Power of Two Choices is the modern default. Beats round-robin and least-connections at scale with O(1) memory.
Health Checks
LB must know which backends are alive.
Active: probe each backend on an interval.
- HTTP
GET /healthevery 2–5s. - Timeout: ≤ 1s typical.
- Mark unhealthy after N consecutive failures (3–5).
- Mark healthy after M consecutive successes (2–3) to avoid flapping.
Passive: observe live traffic.
- Mark unhealthy if outbound error rate spikes.
- Cheaper but slower to react.
Best practice: both — active for fast detection, passive for nuance.
The probe endpoint should reflect what readiness means for traffic. See Health Checks Deep Dive.
Sticky Sessions
Pin one client to one backend across requests. Use when:
- The backend has in-memory session state that isn't replicated.
- Cache warmth matters (consistent hashing).
Mechanisms:
- Source IP affinity (L4): brittle, breaks behind NAT.
- Cookie-based (L7): LB sets/reads a
lb-sessioncookie. - Consistent hash on key (e.g., user_id in header).
Cost: uneven load when one user spikes. Prefer stateless services + shared cache when possible.
TLS Termination
Two strategies:
A) Terminate at LB (most common)
client ──TLS──► LB ──HTTP──► backend
- Cert lives at LB
- Backend sees plaintext
- Easy to inspect, route, gzip
B) Passthrough / SNI routing
client ──TLS──► LB ──TLS──► backend
- LB only routes by SNI
- End-to-end encryption
- No L7 features
Re-encrypt option: terminate, decrypt, then open a new TLS connection to backend. Costs CPU but lets the LB inspect while keeping in-DC traffic encrypted.
Global vs Regional LBs
DNS / Anycast (global)
│
▼
GeoDNS / AWS Global Accelerator
│
▼ routes to nearest region
Regional LB (ALB, NLB, Envoy)
│
▼
Backend instances
Global routing handled by DNS (latency-based, geo-based) or Anycast IP (BGP-based). Regional LB then distributes within the region.
LB as a Failure Domain
The LB is itself a service. Mitigate:
- Multiple LB instances behind a VIP / DNS round-robin.
- Cloud-managed LB: AWS ALB/NLB are themselves multi-AZ by default.
- Self-managed: keepalived + VIP failover, BGP ECMP for active-active.
A single Nginx box is not a load balancer for production.
Service Mesh as Per-Pod LB
In K8s + Istio/Linkerd, each pod has a sidecar that handles client-side load balancing. The "global" LB becomes the cluster ingress; everything inside is mesh-routed.
Benefits:
- Connection pooling per pod.
- Per-call retries, timeouts, mTLS without app code.
- Power-of-two-choices natively.
Cost:
- Sidecar resource overhead.
- More moving parts to debug.
Common Gotchas
| Gotcha | Fix |
|---|---|
| Slow start: new backend gets full traffic instantly | Configure slow-start ramp on LB |
| Health-check passes but the app is "warming up" | Use startup probe (K8s) or distinct /ready endpoint |
| Sticky session sends all whales to one node | Add hot-shard splitting; don't rely on stickiness for fairness |
| Single LB instance | Multi-AZ HA; managed LB or active-passive VIP |
| Health check too aggressive | Causes flap; use higher thresholds + windows |
| Long-lived connections imbalance | Use Least Connections + connection age limits |
Connection Draining
When pulling a backend out (deploy, scale-in), don't kill in-flight connections.
1. Mark backend "draining" — LB stops sending new connections
2. Wait for existing to finish (or hit timeout, e.g. 60s)
3. Kill backend
K8s: combine with preStop hook + terminationGracePeriodSeconds.
LB Architecture Reference
Internet
│
▼
┌──────────────┐
│ Anycast / DNS │ (global)
└──────┬───────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
us-east-1 us-west-2 eu-west-1
│ │ │
▼ ▼ ▼
ALB/NLB ALB/NLB ALB/NLB
│ │ │
▼ ▼ ▼
K8s ingress K8s ingress K8s ingress
│ │ │
▼ ▼ ▼
pods pods pods
Common Mistakes
| Mistake | Reality |
|---|---|
| Using L7 features for non-HTTP traffic | L4 with hashing is what you want |
| Forgetting WebSocket support | Need WS-aware LB (ALB, Envoy); some legacy LBs break upgrades |
| HTTP/2 from client but HTTP/1.1 to backend | Possible (most LBs translate) but watch for streaming-RPC mismatches |
| Per-route routing in app code | Move it to L7 LB; cleaner, more reliable |
| Direct LB → DB | LBs are for stateless backends; DBs use their own replicas |
[!NOTE] A surprisingly large portion of "scaling problems" are LB problems: hot backends, bad sticky sessions, slow health checks. Treat the LB as a first-class component to design, not an afterthought.
Interview Follow-ups
- "How do you load-balance gRPC?" — gRPC uses HTTP/2 long-lived connections; round-robin breaks. Use Envoy with per-request L7 LB, or client-side LB.
- "How do you handle a 'thundering herd' on backend restart?" — Slow-start, jittered staggering, warmup endpoints.
- "How do you do canary deploys via LB?" — Weighted target groups (1% → 10% → 50% → 100%) or header-based routing for a small group of users.
Q: How do CDNs work, and when do you use one?
Answer:
A CDN (Content Delivery Network) is a global network of cache servers (Points of Presence — PoPs) that serve content close to the user, reducing latency and origin load. It's the cheapest single performance optimization a web app can make.
What CDNs Cache
| Content | Cache value |
|---|---|
| Static assets (JS, CSS, images, fonts) | Very high; rarely change |
| Video segments (HLS/DASH chunks) | Very high; large objects |
| API responses (signed/idempotent) | Variable; can be huge win |
| User-personalized HTML | Low — needs careful cache keys |
| WebSocket / SSE | None — pass-through only |
How a Request Flows
1. User in Mumbai requests https://cdn.example.com/img.png
2. DNS → CDN PoP near Mumbai (anycast or geo)
3. PoP looks up img.png in its cache
4. HIT → return from PoP (~10 ms latency)
MISS → fetch from origin (~150 ms first time, then cached for next user)
PoPs sometimes form a tree: edge PoP → regional shield PoP → origin. The shield absorbs origin load by deduplicating misses across many edges.
Cache Key
Defines what counts as "the same object" at the CDN:
default: scheme + host + path + query
GET /img.png?v=1 ≠ GET /img.png?v=2
Configure:
- Strip query strings if irrelevant.
- Include
Accept-Encodingso gzipped vs br vs identity are separate. - Include
Vary: ...headers (CDN respects standards). - Avoid including auth cookies — turns every user into a unique key.
Cache Headers
Origin tells the CDN how long to cache.
Cache-Control: public, max-age=31536000, immutable
↑ public to CDN, browser keeps it 1 year, won't even revalidate
Versioned assets pattern:
/static/app.<hash>.js # never changes; cache 1 year
/static/app.js # mutable; very short cache or no-cache
Revalidation:
Cache-Control: max-age=0, must-revalidate
ETag: "abc123"
On next request, browser/CDN sends If-None-Match: "abc123"
Origin returns 304 Not Modified — saves bandwidth, costs a roundtrip.
Stale-While-Revalidate
Cache-Control: max-age=60, stale-while-revalidate=300
CDN serves cached object for 60s. From 60s–360s, serves stale instantly and asynchronously refetches. Eliminates user-visible refresh latency.
Cache Invalidation (Purge)
Three modes:
- Purge by URL: nuke specific paths.
- Purge by tag (Cloudflare, Fastly): tag assets at insert time, purge by tag — invalidate "all blog posts" with one call.
- Wait for TTL: cheapest; works if TTL is acceptable.
Purge is propagation, not instant — usually < 30s but not zero.
Origin Shield
A "shield" PoP sits between edge PoPs and origin. All misses funnel through the shield.
100 edge PoPs miss "img.png" → 100 requests at origin?
With shield: → 1 request at origin (others served from shield)
Critical for protecting origin during traffic spikes.
CDN for Dynamic Content
Two strategies:
1. Cache short.
Cache-Control: public, max-age=10
Even 10 seconds of caching absorbs huge bursts on hot pages (Reddit front page, breaking news). API responses for read-mostly endpoints.
2. Edge compute.
CDN runs your code at PoPs:
- Cloudflare Workers, Vercel Edge Functions, Lambda@Edge, Fastly Compute@Edge.
Use cases: A/B testing, redirects, light personalization, rate limiting, JWT verification. Sub-50ms latency anywhere.
Geographic Routing
DNS-based: CDN's DNS resolver returns IP of nearest PoP based on resolver's location (which is mostly the user's ISP).
Anycast: same IP advertised from many PoPs via BGP; the network routes to the topologically nearest one. Faster failover but harder to debug.
Security Features
Modern CDNs are also security layers:
- DDoS absorption: PoPs absorb L3/L4 floods.
- WAF (web app firewall): rule-based filtering at edge.
- Bot management: CAPTCHA, rate limit by fingerprint.
- TLS termination + cert management: free certs, automatic renewal.
- Origin shield: hide origin IPs.
When NOT to Use a CDN
- WebSockets / SSE (most CDNs are pass-through; some now support).
- Real-time bidirectional protocols.
- Highly personalized HTML that varies per user (cache will miss).
- Internal services not exposed to the internet.
For uncacheable traffic, the CDN still adds value as a TLS terminator + DDoS shield.
Cache Hit Ratio
hit_ratio = hits / (hits + misses)
Targets:
- Static assets: > 95%.
- Dynamic with short cache: 70–90%.
- API caching: varies; measure per-endpoint.
Diagnose low hit ratio:
- Cookies / auth headers in cache key.
- Query string randomization.
- Different
Acceptheaders per client. - TTL too short.
Costs
Pricing is typically per-GB egress + per-request. Cheaper than your origin's egress. For media-heavy apps, the CDN itself is the dominant cost line — measure and optimize.
Common Mistakes
| Mistake | Fix |
|---|---|
Cache-Control: no-cache everywhere "for safety" | Bypasses the CDN; defeats the point |
Versioning files via query string (?v=1) but no cache header | CDN treats them as one URL with stale content |
| Caching authenticated responses | Privacy bug — user A sees user B's data |
One huge purge_all to fix any stale issue | Origin gets pummeled; use tag-based purge |
Forgetting Vary: Accept-Encoding | Compressed/uncompressed get confused |
[!NOTE] The first hour of optimizing a web app's performance is almost always CDN setup: long-lived cached versioned assets, short cached API responses, fingerprinted filenames, edge TLS. Compounding gains for trivial work.
Interview Follow-ups
- "How do you cache user-specific data at the edge?" — Use
Vary: Cookieonly for the specific cookie that identifies cacheable variants (e.g., locale, not session). Or use Edge Workers to assemble personalized + cached fragments. - "How do you handle a CDN-cached bad response?" — Tag-based purge or version bump. Treat broken cache as a real outage class.
- "How do you serve video?" — Encode to HLS/DASH segments, signed URLs, long TTL on segments, manifest with shorter TTL. CDN serves the bulk; origin only fingerprints manifests.
Q: REST vs gRPC vs GraphQL — picking an API style.
Answer:
The three dominate modern API design. They optimize for different things: REST for simplicity + caching, gRPC for performance + strict contracts, GraphQL for flexible client queries. Most companies use more than one.
REST
HTTP verbs map to operations on resources, identified by URLs. JSON payload by convention.
POST /v1/orders # create
GET /v1/orders/123 # read one
GET /v1/orders?status=open # list with filters
PATCH /v1/orders/123 # partial update
DELETE /v1/orders/123 # delete
Strengths:
- Universal tooling (curl, browsers, CDN).
- Cacheable via standard HTTP (
Cache-Control,ETag). - Easy to inspect/debug.
- Versionable by URL or header.
Weaknesses:
- No native streaming (long-polling, chunked, or SSE workarounds).
- Type-loose; relies on OpenAPI/JSON Schema for contracts.
- Multiple round-trips for related data (over-fetching / under-fetching).
gRPC
Defines services in .proto files, generates typed clients/servers in any language. Uses HTTP/2 with binary Protobuf payloads.
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
rpc WatchOrders(WatchRequest) returns (stream OrderEvent); // server stream
rpc UploadOrders(stream Order) returns (Summary); // client stream
rpc Chat(stream Msg) returns (stream Msg); // bidi
}
Strengths:
- Strong typing, generated code.
- Binary format → smaller, faster than JSON.
- Native streaming (server, client, bidirectional).
- HTTP/2 multiplexing — many calls one connection.
- First-class deadlines, cancellation, metadata.
Weaknesses:
- Browser support requires gRPC-Web proxy (Envoy or in-process).
- Less universal tooling (specialized debuggers needed).
- Schema changes require
.protodistribution to consumers.
Best for: service-to-service internal APIs, mobile apps with native clients, high-throughput RPC.
GraphQL
A query language. Clients send a single query asking for exactly the fields they need across multiple resources.
query {
order(id: 123) {
id
total
customer {
name
email
}
items {
product { name price }
quantity
}
}
}
Strengths:
- Single request fetches a deep object graph.
- Client picks fields — no over-fetching.
- Self-documenting via introspection.
- Strong typing via SDL.
Weaknesses:
- Caching is harder (no URL-keyed cache; needs persisted queries + custom cache layer).
- N+1 query risk on the server (mitigated by DataLoader-pattern batching).
- Authorization is per-field (more surface area).
- "Resolver explosion" — many code paths to maintain.
Best for: aggregating data from many services for a single UI; mobile apps wanting minimal payloads; partner APIs where flexibility matters.
Comparison Table
| Aspect | REST | gRPC | GraphQL |
|---|---|---|---|
| Transport | HTTP/1.1 or 2 | HTTP/2 (required) | HTTP/1.1 or 2 |
| Format | JSON (typical) | Protobuf binary | JSON over HTTP |
| Streaming | SSE / chunked workaround | Native server/client/bidi | Subscriptions (over WebSocket usually) |
| Contract | OpenAPI (optional) | .proto (required) | SDL (required) |
| Code-gen | OpenAPI generators | First-class | Apollo/Relay codegen |
| Caching | Browser/CDN native | Hard | Hard (persisted queries) |
| Debugging | curl, browser dev tools | grpcurl, BloomRPC | GraphiQL, IDE plugins |
| Versioning | URL/header | Backward-compatible .proto rules | Field deprecation; never break |
Versioning
REST: /v1/orders, /v2/orders — old and new run side by side.
gRPC: Protobuf evolution rules. Add fields with new tag numbers; never reuse or change types. Old clients ignore unknown fields.
GraphQL: never version; deprecate fields with @deprecated, add new ones. The schema evolves like an org chart.
Error Models
REST: HTTP status codes + JSON body.
- 4xx client error, 5xx server error.
- RFC 7807 "Problem Details" for structured errors.
gRPC: status codes (OK, INVALID_ARGUMENT, NOT_FOUND, ABORTED, ...).
- Rich error model via google.rpc.Status with details.
GraphQL: always 200 OK with errors array in body. Has to define partial-success.
Streaming Comparison
| Need | REST | gRPC | GraphQL |
|---|---|---|---|
| One-shot request/response | Native | Native | Native |
| Server pushes events | SSE or polling | server stream | subscription |
| Client streams uploads | Chunked transfer | client stream | rare (multipart) |
| Bidirectional chat | WebSocket | bidi stream | subscription |
When to Pick Each
Internal service-to-service → gRPC
Public REST API for many consumers → REST + OpenAPI
Mobile app with constrained network → GraphQL or gRPC (compact payloads)
Browser fetch, hit-and-run → REST
Real-time bidirectional → gRPC or WebSocket
Aggregation across many backends → GraphQL (BFF pattern)
Hybrid Architectures Are Normal
Browser ──REST/GraphQL──► Edge / BFF ──gRPC──► Services ──gRPC──► Services
│
▼
DB / cache
- REST or GraphQL on the edge for browser ergonomics.
- gRPC internally for performance and contracts.
Pagination
REST: ?cursor=...&limit=... (cursor-based) or ?page=...&size=... (offset-based).
gRPC: PageToken pattern.
GraphQL: Relay Cursor Connection spec — connections, edges, page info.
Cursor-based scales; offset-based breaks past a few thousand rows.
Idempotency
POST is non-idempotent by default. All styles handle this the same way:
- Client sends
Idempotency-Keyheader / metadata. - Server stores result keyed by it; replays on retry.
See Idempotent REST APIs.
Common Mistakes
| Mistake | Fix |
|---|---|
| Using GET for actions with side effects | Verb hygiene; CDN may "pre-fetch" |
| REST + no OpenAPI spec | Drift between docs and reality; generate from spec |
| gRPC over the open internet without TLS | Always use TLS; gRPC was designed assuming it |
| GraphQL queries 5 levels deep on every page | Set depth/complexity limits; reject expensive queries |
| Switching everything to GraphQL "for flexibility" | Old REST endpoints + caching are still right for hot read paths |
[!NOTE] Pick by who consumes your API and what they need. Internal microservices = gRPC. Diverse external consumers = REST. UI aggregating lots of data = GraphQL. There is no general winner.
Interview Follow-ups
- "How do you do auth in gRPC?" — Metadata headers (often a JWT). Interceptors verify per-call. mTLS for service-to-service.
- "What's HATEOAS and do you use it?" — Hypermedia in REST responses (links to next actions). Theoretically elegant; rarely worth the complexity in practice.
- "How does GraphQL handle file uploads?" — Multipart spec; usually offload to a separate REST endpoint that returns a URL.
Q: WebSockets vs Server-Sent Events vs Long Polling — picking real-time transport.
Answer:
Three ways to push data from server to client. Each picks a point on the bidirectionality × overhead × infrastructure-cost triangle.
Long Polling
Client makes HTTP request; server holds it open until data arrives (or timeout), then responds. Client immediately reopens.
client ──GET /events──► server (holds for 30s)
client ◄──response───── server (data arrives or timeout)
client ──GET /events──► server (next poll)
Pros:
- Plain HTTP. Works through proxies, firewalls, CDN.
- No new protocol.
Cons:
- Connection setup overhead per cycle.
- Server holds many idle connections.
- Latency = connection-reopen time.
Use for: legacy environments where WS / SSE aren't supported.
Server-Sent Events (SSE)
Single long-lived HTTP/1.1 or HTTP/2 connection. Server sends a stream of text events.
GET /stream HTTP/1.1
Accept: text/event-stream
Response:
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"type": "msg", "body": "hi"}\n\n
data: {"type": "msg", "body": "hello"}\n\n
...
Pros:
- One direction (server → client) — perfect for feeds, notifications.
- Auto-reconnect built into browser API.
- Plain HTTP — proxies, CDN edge support.
- Built-in
idfor resume.
Cons:
- One-way.
- Text only (binary needs base64).
- Some legacy proxies buffer SSE responses.
Use for: live notifications, server logs, dashboard updates, AI chat streaming.
WebSockets
Persistent bidirectional connection over a single TCP socket. Initiated via HTTP Upgrade.
GET / HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
← HTTP/1.1 101 Switching Protocols
After upgrade, the socket carries WS frames in either direction.
Pros:
- True bidirectional.
- Low overhead per message (few bytes).
- Binary or text.
Cons:
- Stateful — server must track each connection.
- LB / CDN support varies (sticky required).
- Reconnect logic non-trivial.
Use for: chat, collaborative editing, gaming, trading platforms.
Comparison
| Feature | Long Poll | SSE | WebSocket |
|---|---|---|---|
| Direction | C↔S | S→C | C↔S |
| Overhead/message | High | Low | Lowest |
| Setup cost | High (per cycle) | One-time | One-time |
| Browser API | fetch, manual | EventSource | WebSocket |
| Auto-reconnect | Manual | Built-in | Manual |
| Binary | No | Base64 only | Yes |
| Proxy/CDN friendly | High | Medium-high | Low (sticky) |
| Server connection count | High (idle) | Medium | Medium |
Connection Cost Per Server
Each connection consumes:
- OS file descriptor (1).
- TCP socket buffers (~64 KB default).
- Application memory (KB to MB depending on state).
Typical server: 100k concurrent connections (8 GB RAM allocated)
Tuned (epoll/io_uring, lean app): 1M+ connections
Scale-out: shard connections across N gateways; LB by client ID or sticky hashing.
Choosing in 30 Seconds
Need bidirectional? ─── yes ──► WebSocket
│
no
│
Server pushes events only? ─── yes ──► SSE
│
no
│
Don't care about latency, just need updates? ─── Long Poll
Real-Time at Scale Patterns
Whichever transport, the backend looks similar:
event source ──► message bus (Kafka/Redis pub-sub) ──► connection gateway ──► clients
gateway:
- holds N connections
- subscribes to topics relevant to its connected users
- filters and pushes events to each client
Sticky LB → user always lands on the same gateway → that gateway holds the subscription.
Heartbeats / Keepalives
All three need application-level keepalives:
- WS: send ping/pong every 30s.
- SSE: server sends comment
:keepalive\nevery 15s. - LP: timeout the long-poll at 30–60s; client reconnects.
Without heartbeats, idle connections die silently behind NATs / proxies.
Auth
- WS: usually JWT in URL query param or sub-protocol header (cookies work too).
- SSE: same-origin cookies or
Authorizationheader (no headers in browserEventSource; use cookies or query token). - LP: standard cookie / header auth.
Token rotation: send signed short-lived token via WS / SSE message; refresh before expiry.
Backpressure
If client can't keep up:
- WS: app reads from socket; OS buffers fill; producer blocks or drops.
- SSE: similar; some servers drop oldest.
- LP: client controls pace (next poll happens after processing).
Server policy: queue per-connection (bounded), drop oldest on overflow, or disconnect slow consumers.
CDN / Edge
- WS: limited; some edges (Cloudflare, Fly.io) support; CloudFront does not natively.
- SSE: HTTP-native; most CDNs work if buffering disabled.
- LP: native HTTP; full CDN support.
Common Mistakes
| Mistake | Fix |
|---|---|
| Using WS where SSE would do | One-way? Skip the complexity |
| No reconnect logic on client | Long outages = unrecoverable session |
| Holding state per connection in app memory | Crash loses sessions; persist if state matters |
| Single WS gateway as SPOF | Multi-instance with sticky LB |
| Pushing through CDN without WS support | Doesn't work; bypass CDN for WS path |
[!NOTE] Default to SSE for "server → client updates" use cases — it's HTTP, simple, infra-friendly. Reach for WS only when you need bidirectional. Long-poll is legacy.
Interview Follow-ups
- "How would you scale WebSockets to 10M concurrent?" — Shard gateways; per-user sticky LB; pub/sub bus (Redis or Kafka) for per-user routing.
- "How is WebRTC different?" — P2P data/media; needs signaling (often WS); used for low-latency media (voice/video).
- "WebTransport?" — Modern alternative to WS over HTTP/3 (QUIC); multiplexed streams, unreliable datagrams. Not widely adopted yet.
Q: API Gateway and BFF (Backend for Frontend) — what role do they play?
Answer:
In a microservices world, clients can't talk to 100 backend services directly. The API gateway is the front door: one stable endpoint that routes, authenticates, rate-limits, and aggregates. The BFF is a specialization: one gateway per client type (web, iOS, Android).
API Gateway
┌──────────────┐
client ───────────────►│ API Gateway │
└──────┬───────┘
│
┌─────────────┬───────┼─────────┬───────────┐
▼ ▼ ▼ ▼ ▼
Orders Inventory Search Payments Users
Service Service Service Service Service
Responsibilities:
- Routing: URL/path/host → service.
- AuthN / AuthZ: verify JWT; map to user context.
- Rate limiting: per-IP, per-API-key, per-tenant.
- Request shaping: header injection, request transformation.
- Response shaping: aggregate, filter.
- Caching: HTTP-cache headers for read-mostly endpoints.
- Observability: per-route metrics, traces.
- Circuit breaking / retries (optional).
Popular implementations: AWS API Gateway, Kong, Envoy, Tyk, Apigee, custom-built.
BFF Pattern
Different clients need different shapes of data. Rather than one gateway serving all, one BFF per client:
┌──── Web BFF ─► Services
│
client (web) ───────┘
┌──── Mobile BFF ─► Services
│
client (iOS) ───────┘
┌──── Partner BFF ─► Services
│
client (partner)────┘
Each BFF:
- Owned by the client team (closer to UI requirements).
- Optimized for that client (smaller payloads on mobile; richer on web; restricted on partner).
- Aggregates backend services into one client-friendly response.
GraphQL is often the right tool for the BFF layer — clients pick what they need.
Why Not "Just Have Services Call Each Other"
Pre-gateway client-direct era problems:
- Clients hardcoded N service URLs.
- Each service handled auth differently.
- Cross-cutting concerns (rate limit, retries) duplicated.
- Browser CORS hell.
Gateway centralizes that. Worth the extra hop.
Anti-Patterns
| Anti-pattern | What goes wrong |
|---|---|
| Business logic in gateway | Gateway becomes monolith over time |
| Stateful gateway | Doesn't scale; defeats purpose |
| Single gateway for browser + mobile + partners | Different needs collide; use BFF per client |
| Gateway that retries everything | Hidden duplication; idempotency required |
| Synchronous fanout across many services per request | Latency multiplied; use aggregation patterns |
Aggregation
GET /v1/pages/order/123
├── parallel call: orders.get(123)
├── parallel call: payments.byOrder(123)
├── parallel call: shipping.byOrder(123)
├── parallel call: users.get(order.user_id)
└── compose response
Gateway/BFF orchestrates; one client request → N parallel service calls → one merged response.
Auth at the Gateway
Standard pattern:
client → gateway
gateway: verify JWT signature + expiry
gateway: extract claims → set X-User-Id header
gateway: forward to service
service: trust X-User-Id from gateway (internal network)
Service shouldn't re-verify (slow); but gateway must be authoritative.
mTLS between gateway and services prevents spoofing internal headers.
Rate Limiting
Multi-level:
- Per-IP at edge.
- Per-API-key / per-token at gateway.
- Per-user / per-tenant at gateway or BFF.
Versioning
Gateway-level versioning lets you migrate services behind it:
/v1/orders → orders-service-v1
/v2/orders → orders-service-v2
Or header-based:
Accept-Version: 2
Gateway as Performance Layer
- Compression (gzip/br).
- HTTP/2 multiplexing to upstream.
- TLS termination centralized.
- Edge caching for read-mostly responses.
- Connection pooling to backends.
Failure Modes
| Failure | Handling |
|---|---|
| One service down | Circuit-break; return partial response if possible |
| Gateway pod dies | LB removes; remaining pods absorb |
| Auth service slow | Cache JWT validations briefly; fail fast |
| Single point of failure | Multi-AZ deployment; minimum 3 instances |
Common Mistakes
| Mistake | Fix |
|---|---|
| Gateway becomes monolith of "common code" | Keep it thin; cross-cutting only |
| Service mesh + gateway with overlapping responsibilities | Define clear roles |
| BFF that grows beyond its team | Refactor into smaller services |
| Direct DB access from gateway | Never; gateway is stateless |
| No versioning strategy | Painful migrations |
[!NOTE] The gateway is your platform's contract with the outside world. Keep its responsibilities narrow (routing, auth, rate-limit, basic shape); push everything domain-specific into services or BFFs.
Interview Follow-ups
- "Where do you put logging / tracing?" — Gateway emits per-request entries; trace context propagated via headers (W3C
traceparent) to all downstream calls. - "How is API Gateway different from a service mesh?" — Gateway = north-south traffic (client → cluster). Mesh = east-west (service ↔ service). Different layers; often coexist.
- "How do you avoid the gateway becoming a SPOF?" — Multi-region, multi-AZ; managed (cloud) when possible; client retries with backoff.
Q: SQL vs NoSQL — when to pick which?
Answer:
"SQL vs NoSQL" is a misleading split — there are at least five NoSQL families with very different properties. The right question is "given my access patterns and constraints, which data model and consistency story fits?"
The Families
| Family | Examples | Best for |
|---|---|---|
| Relational (SQL) | Postgres, MySQL, Oracle, SQL Server | ACID transactions, ad-hoc queries, normalized data |
| Document | MongoDB, DynamoDB, Couchbase | Schema flexibility, nested objects, per-document access |
| Wide-column | Cassandra, ScyllaDB, HBase, Bigtable | Massive write throughput, time-series, log-like |
| Key-value | Redis, Memcached, DynamoDB, RocksDB | Fast lookups by key, caching, session store |
| Graph | Neo4j, ArangoDB, JanusGraph, Neptune | Relationship traversal, recommendation, fraud |
| Search | Elasticsearch, OpenSearch, Meilisearch | Full-text + ranking, filtered search |
| Time-series | InfluxDB, Timescale, Prometheus | Metrics, IoT telemetry |
The Right Mental Model
Don't pick one for the whole system. Polyglot is the norm:
Source of truth: Postgres (orders, users — ACID)
Hot reads: Redis (sessions, leaderboards)
Search: Elasticsearch (full-text)
Analytics: ClickHouse (rollups)
Object/blob: S3 (uploads)
Event bus: Kafka (audit, fanout)
Time-series: Timescale (metrics)
Pick the source of truth carefully; derived stores can be rebuilt.
When SQL Is Right
- Strong consistency required (financial, regulated, identity).
- Complex queries (joins, aggregates) you can't predict in advance.
- Mature tooling (migrations, backups, monitoring) matters.
- Data fits in one logical DB (under a few TB hot working set).
- You want referential integrity enforced.
Postgres is the boring, correct answer for ~80% of new services.
When NoSQL Is Right
Each family for different reasons:
Document store (Mongo, Dynamo):
- Schema evolves rapidly.
- Object naturally hierarchical (a "product" with nested specs/reviews).
- Single-record reads dominate.
- Multi-tenancy with isolated schemas per tenant.
Wide-column (Cassandra):
- Write throughput >100k QPS that one RDBMS can't deliver.
- Time-series or log-like data partitioned by entity + time.
- Multi-region active-active with tunable consistency.
Key-value (Redis):
- Lookups by exact key.
- TTL-based eviction (sessions, rate-limits).
- Low-latency requirements (sub-millisecond).
Graph DB:
- Traversals more than 2–3 hops are common.
- "Friends of friends," fraud rings, recommendation graphs.
- (Often: Postgres + recursive CTE handles 2-hop just fine.)
Search:
- Full-text, faceted search.
- Ranking by relevance, not just filtering.
Time-series:
- Append-only metrics.
- Range queries on timestamp.
- Compression of similar adjacent values.
Schema Flexibility
A common reason teams reach for NoSQL: "we don't know the schema yet."
Reality:
- Postgres supports
JSONBcolumns with indexes — gives you schemaless with optional structure. - "Schemaless" doesn't mean "no schema" — it means the schema is in app code, harder to audit.
- Real schema evolution still needs care.
Pattern: Postgres with JSONB for the flexible bits, typed columns for the well-defined bits. Avoid full document-DB unless you really need it.
Access Pattern Matters More Than Tech
NoSQL forces you to design for access patterns up front (DynamoDB famously). SQL lets you write any query and add an index later.
Example: a "user's recent orders" page.
SQL (Postgres):
SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 20;
index: (user_id, created_at DESC)
DynamoDB:
PK = user_id, SK = created_at
Query(PK = u123, SK desc, limit 20)
Same access pattern, both work. SQL gives you GROUP BY status for free; DynamoDB needs a second index.
Joins
A common claim: "NoSQL doesn't do joins." Mostly true. The reply is denormalize:
- Embed related data in the document.
- Maintain a materialized view.
- Pre-compute the join offline.
If your model needs joins on every read, SQL is the lower-friction choice.
Transactions
| Class | Multi-row transactions |
|---|---|
| SQL | Always (ACID) |
| DynamoDB | Up to 100 items, single region |
| MongoDB | Multi-document since 4.0 (replica-set scope) |
| Cassandra | Lightweight transactions (Paxos, slow) |
| Redis | MULTI/EXEC for batch, optimistic |
If your business logic crosses many entities atomically, prefer SQL.
Scaling Story
| Family | Scale-up path |
|---|---|
| SQL | Vertical scale → read replicas → application-side sharding (Vitess, Citus) |
| Distributed SQL (Spanner/Cockroach) | Horizontal natively |
| DynamoDB / Cassandra | Horizontal natively (partition key is the limit) |
| Redis Cluster | Hash slots across nodes |
"Scales horizontally out of the box" was the original NoSQL appeal. Modern SQL has caught up — distributed SQL is no longer rare.
Cost & Operability
- Managed SQL (RDS, Aurora, Cloud SQL): expensive at scale, very mature ops.
- Managed NoSQL (DynamoDB): pay per request + storage; cheap at low scale, expensive at extreme scale.
- Self-hosted: cheaper compute but you own the on-call.
DynamoDB's "auto-scaling" is real but bills can surprise. Cassandra is brutal to run yourself.
Common Mistakes
| Mistake | Better |
|---|---|
| MongoDB for relational data | Postgres + JSONB if flexibility matters |
| Cassandra for low-write workload | Pure overengineering |
| Redis as primary store | OK for some use cases, but no built-in durability beyond AOF |
| Choosing "modern" tech over team experience | Boring tech wins long-term |
| Single DB for OLTP + analytics | Hot/cold path competes; split via CDC |
Decision Heuristics
- Default: Postgres for source of truth. Add specialized stores as patterns emerge.
- Write QPS > 50k: think Cassandra/DynamoDB or Postgres with Citus.
- Search: don't do
LIKE '%x%'in Postgres at scale — use Elasticsearch. - Sub-millisecond reads: Redis in front of the DB.
- Multi-region active-active: Cassandra, Cosmos, or Spanner/Cockroach.
- Data > 100 TB hot: distributed SQL or columnar warehouse.
[!NOTE] The most common production stack is Postgres + Redis + S3 + a queue. That's enough for most consumer-scale apps. Reach for more exotic stores only when access patterns demand it.
Interview Follow-ups
- "You said Postgres. How would you scale it past one machine?" — Read replicas; partition table by time/customer; eventually shard with Citus or Vitess; or migrate to distributed SQL.
- "Why is DynamoDB schemaless?" — Schema is enforced at app level; tables only require PK (and optional SK). Trades validation for flexibility.
- "What's HTAP?" — Hybrid Transactional/Analytical Processing — single DB serving OLTP + analytics. SingleStore, TiDB, Spanner. Niche; usually splits via CDC are simpler.
Q: Database indexing — B-tree, hash, GIN, covering, composite. Trade-offs.
Answer:
Indexes turn O(N) table scans into O(log N) lookups. Each one costs disk space + write amplification. Understanding which index helps which query is the difference between a 5 ms response and a 5 second one.
B-Tree (default)
[50, 80]
/ | \
[20,30,40] [60,70] [85,90,99]
Sorted, balanced tree. Logarithmic depth. Used by:
- Postgres (default)
- MySQL InnoDB primary + secondary
- Most RDBMS
Supports:
- Equality:
= - Range:
<,>,BETWEEN - Prefix wildcard:
LIKE 'abc%'(not'%abc') ORDER BYmatching index direction
Doesn't help:
- Functions:
WHERE lower(email) = ?(use functional index) - Suffix wildcards:
LIKE '%abc'(use trigram or reverse index) <>(not equal) — index scanned but rarely useful
Hash Index
hash(key) → bucket → entry
Pros: O(1) lookup. Cons:
- No range queries.
- No ordering.
Postgres has hash indexes but rarely worth it over B-tree. Used mostly internally (e.g., in-memory hash joins).
Composite Index
Index on multiple columns, ordered:
CREATE INDEX ON orders (user_id, created_at DESC);
Helps queries that filter by the leftmost prefix:
WHERE user_id = ? -- ✅ uses index
WHERE user_id = ? AND created_at > ? -- ✅
WHERE user_id = ? ORDER BY created_at DESC -- ✅ no sort needed
WHERE created_at > ? -- ❌ doesn't help
Order matters. Lead with the most selective column you filter on.
Covering Index (Index-Only Scan)
If the index includes all columns a query needs, the DB never reads the table:
CREATE INDEX ON orders (user_id, created_at) INCLUDE (status, total);
SELECT user_id, created_at, status, total
FROM orders
WHERE user_id = ?;
-- index-only scan; skip table
Postgres INCLUDE and MySQL InnoDB cluster keys give this.
Partial Index
Index a subset of rows:
CREATE INDEX ON orders (created_at) WHERE status = 'pending';
Smaller, faster for queries that match the WHERE. Useful for hot subsets (pending jobs, active users).
Functional / Expression Index
CREATE INDEX ON users (lower(email));
SELECT * FROM users WHERE lower(email) = ?; -- uses index
Needed when the query applies a function to a column. Without it, the function runs per row.
GIN (Generalized Inverted Index)
For multi-valued columns: arrays, JSONB, text-search.
CREATE INDEX ON orders USING gin (tags);
SELECT * FROM orders WHERE tags @> ARRAY['urgent'];
CREATE INDEX ON docs USING gin (to_tsvector('english', body));
SELECT * FROM docs WHERE to_tsvector('english', body) @@ to_tsquery('postgres');
Each tag/word becomes an inverted-index entry pointing to rows containing it.
GIST (Generalized Search Tree)
Tree-based, supports complex types:
- Geometry (PostGIS).
- Range types.
- Full-text search (older).
CREATE INDEX ON places USING gist (location);
SELECT * FROM places WHERE ST_DWithin(location, ?, 1000);
BRIN (Block Range INdex)
Tiny index storing per-block summary stats (min/max). Useful for big tables where rows are naturally ordered (timestamps):
CREATE INDEX ON events USING brin (created_at);
Small index, helps range scans on time-ordered tables. Bad for random-order data.
Cost of an Index
Every index adds:
- Disk space (~10–30% of table).
- Write amplification: inserts and updates must update all relevant indexes.
- Memory: cached pages in the buffer pool.
Indexes are not free. Don't index columns that aren't filtered/sorted.
When NOT to Index
- Low-cardinality columns (
gender,statuswith 3 values). Postgres planner often prefers seq scan. - Frequently updated columns (especially in hot tables).
- Tables < ~10k rows (seq scan is faster anyway).
Reading Query Plans
Postgres:
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;
Look for:
Index Scan(good).Index Only Scan(best).Seq Scanon a big table (smell).Bitmap Heap Scan(often fine for multi-row results).Sortstep (consider an index that returns pre-sorted).
Index Maintenance
- REINDEX: rebuild bloated indexes (Postgres has
REINDEX CONCURRENTLYsince 12). - Statistics:
ANALYZEupdates table statistics; planner uses them. - Auto-vacuum: tune for write-heavy tables.
Sharded Indexes
In sharded systems:
- Local index: each shard's own index. Fast for queries that include shard key.
- Global secondary index: separate store keyed by non-shard column; cross-shard queries → fan out vs centralized index.
DynamoDB GSI is the latter; Cassandra secondary index is the former.
Common Mistakes
| Mistake | Reality |
|---|---|
| One index per column | Many redundant; combine into composite |
| Indexing every column "to be safe" | Write amplification; storage waste |
| Wrong column order in composite | Index unused; lead with most-filtered |
| Missing index on FK columns | Joins do seq scan |
| Index on UUID v4 primary key (random) | Random inserts → page splits, bloat. Use sortable UUIDs (ULID, UUIDv7) |
[!NOTE] The most useful skill is reading
EXPLAIN ANALYZE. Indexes are not magic; the planner picks one only if it thinks it'll help. Verify, don't assume.
Interview Follow-ups
- "How would you index
WHERE status = 'active' AND created_at > ??" — Partial index on(created_at)WHERE status = 'active'; or composite(status, created_at). - "How does MySQL InnoDB primary key clustering differ from Postgres?" — InnoDB stores rows physically in PK order — primary key lookup is the table. Postgres stores rows in heap; index is separate.
- "When would you choose hash over B-tree?" — Equality-only queries with very high cardinality and no range needs; in practice, rarely.
Q: Sharding & partitioning — strategies, key choice, rebalancing, gotchas.
Answer:
Partitioning is splitting a dataset into subsets. Sharding is partitioning across separate machines. You shard when one machine can't hold the working set, serve the QPS, or survive the disk-write load.
The Three Strategies
| Strategy | How | Pros | Cons |
|---|---|---|---|
| Range partitioning | Rows grouped by key range | Range queries efficient | Skew → hot shards |
| Hash partitioning | hash(key) % N | Even distribution | No range queries |
| Directory / lookup | Explicit map (key → shard) | Flexible | Lookup table is itself bottleneck |
| Consistent hashing | Hash on a ring | Smooth rebalance | More moving parts |
Range Partitioning
shard A: keys [aaa-bbb)
shard B: keys [bbb-ccc)
shard C: keys [ccc-zzz)
Used by HBase, BigTable, MongoDB (range mode), CockroachDB. Splits/merges shards automatically as data grows.
Strength: scan WHERE key BETWEEN x AND y reads one (or few) shards.
Weakness: monotonically increasing key (e.g., timestamp) → all writes to last shard.
Hash Partitioning
shard = hash(user_id) % N
Spreads writes uniformly. Used by Cassandra, DynamoDB, MySQL (by app), Redis Cluster.
Strength: even load (if keys are well-distributed). Weakness:
- No
WHERE key BETWEEN ...range queries — scatter-gather across all shards. - Adding shards changes
% N→ almost all keys move. (See: consistent hashing.)
Consistent Hashing
Place shards and keys on a ring 0..2^64. Each key maps to the next shard clockwise. Adding/removing one shard moves only 1/N of the keys.
0
╲
●─── shard A (token 100)
╱
keys 50..100 → A
keys 100..220 → B
●─── shard B (token 220)
╱
...
Refinement: each shard owns multiple virtual nodes (tokens) on the ring → smoother rebalance. Cassandra uses 256 vnodes/server by default.
See Consistent Hashing for the full mechanics.
Choosing a Shard Key
The most consequential decision. Bad shard keys cause:
- Hot shards (one shard takes 90% of load).
- Scatter-gather queries (every read hits every shard).
- Cross-shard transactions (slow, complicated).
Good shard key properties:
- High cardinality (millions+ of distinct values).
- Uniform access pattern (no celebrity hot keys).
- Co-locality: rows accessed together share a shard.
- Stable over time (not monotonically increasing).
Common keys:
| Domain | Good shard key |
|---|---|
| SaaS multi-tenant | tenant_id |
| Social network | user_id |
| E-commerce | customer_id (read-heavy) or order_id (write-heavy) |
| IoT | device_id (avoid timestamp alone) |
| Geo data | geohash(lat, lng) |
Bad keys:
created_at(monotonic → hot shard).country(skewed; US dominates).status(low cardinality).- Mutable fields (key change = data movement).
Composite Shard Keys
Combine fields for cardinality + locality:
shard_key = hash(tenant_id) + range(created_at)
Hash spreads load across shards; range within a shard keeps a tenant's recent data together. Used by MongoDB's compound shard keys and DynamoDB's PK + SK design.
Hot Shard Problems
Symptoms: one shard at CPU/disk limit while others idle.
Causes:
- Skewed key distribution (one tenant huge).
- Sequential key (timestamp, auto-increment ID).
- Hot row within a shard (celebrity user).
Mitigations:
- Salt the key: prepend a random small bucket →
bucket_n_userid. Decompose on read with scatter-gather. - Re-key by composite: hash a more uniform field.
- Split the hot shard (range partitioning supports this automatically).
- Tiered access: separate read replicas just for the hot tenant.
Rebalancing
Adding capacity needs to move some data without taking the system offline.
| Approach | Mechanism |
|---|---|
| Stop the world, copy, restart | Smallest engineering effort, biggest outage |
| Resharding (range) | Split a shard's range in half; move half |
| Consistent hashing + vnodes | Add new node → it claims 1/N of vnodes from existing nodes |
| Logical → physical mapping | App holds a directory of logical shards → physical hosts; rebalance by remapping |
Production systems: vnode + background streaming. Cassandra, ScyllaDB, DynamoDB do this transparently.
Cross-Shard Queries
Queries that span multiple shards are slow:
SELECT * FROM orders WHERE customer_email = ? ; -- shard key is user_id
Options:
- Secondary index on email → maintained per shard, queried via scatter-gather.
- Global secondary index → separate index store keyed by email (writes are now cross-shard).
- Application-level fanout → query all shards in parallel, aggregate.
For aggregate queries (counts, sums), use a separate analytics store (ClickHouse, Druid).
Cross-Shard Transactions
Hard. Options ordered by complexity:
- Avoid them. Co-locate via shard key design.
- Saga — distributed multi-step workflow with compensation. See Saga Pattern.
- 2PC — synchronous, blocking, complex. Last resort.
- Use a distributed SQL DB (Spanner, CockroachDB, YugabyteDB) that does it for you.
Schema Per Shard
Each shard typically has the same schema. Migrations must apply to all shards — usually orchestrated by a tool (Vitess, Citus) or careful application code.
Multi-tenant per-customer-DB pattern (one DB per tenant) trades shard-management complexity for migration complexity — each new schema change is N migrations.
Examples by System
| System | Strategy |
|---|---|
| Cassandra | Consistent hash (Murmur3) + vnodes |
| DynamoDB | Hash on partition key; range on sort key within partition |
| MongoDB | Range or hash, configurable per collection |
| Postgres + Citus | Hash by distribution_column |
| Vitess (MySQL) | Hash on vindex, lookup-vindex for secondary |
| Kafka | Hash on producer key, partition count |
| Redis Cluster | CRC16 → 16384 hash slots |
Common Mistakes
| Mistake | Result |
|---|---|
Shard key = created_at | All new writes hit one shard |
| Shard early without need | Adds complexity for years |
| Skip secondary indexes | Cross-shard scatter-gather kills latency |
| Hot tenant on a multi-tenant shard | Cripples neighbors; need isolation/tiered DBs |
| No plan for resharding | Future you suffers; build directory layer early |
Cross-shard JOIN | Use denormalization or a separate analytics store |
[!NOTE] Sharding is a one-way door. Pick the key with care; getting it wrong costs months of migration. Better to design for shardability and not shard than to shard with the wrong key.
Interview Follow-ups
- "How would you migrate from one shard key to another?" — Dual write to old + new (mapped by both keys); backfill old data; switch reads; remove old after burn-in.
- "How do range queries work on hash-partitioned data?" — They don't, efficiently. Use a separate index (search engine, materialized view) keyed appropriately.
- "What's the failure mode of consistent hashing under node loss?" — Node's range is taken over by its neighbor → load doubles. With vnodes, load spreads across many neighbors.
Q: Replication topologies — single-leader, multi-leader, leaderless. Trade-offs.
Answer:
Replication maintains copies of data on multiple machines for availability, durability, and read scaling. The three topologies — single-leader, multi-leader, leaderless — each pick a different point in the consistency / availability / latency space.
Single-Leader (Primary-Replica)
writes
client ───────► leader ───────► follower
│ ───────► follower
│
async or sync replication
- All writes go to one leader.
- Followers receive a stream of changes (binlog, WAL).
- Reads can go to followers (eventual) or leader (strong).
Used by: Postgres, MySQL, MongoDB (replica set), most managed RDBMS.
Sync vs async replication:
- Async: leader acks write before followers apply. Fast. Risks data loss if leader dies before replication.
- Sync to one: at least one follower must ack. Safer, slower.
- Sync to majority (Raft-style): durable but cross-AZ latency tax.
Failover:
- Detect leader failure.
- Promote most up-to-date follower.
- Reroute clients.
Window where writes are lost = unreplicated lag at moment of crash.
Multi-Leader (Active-Active)
writes
client ───► leader A ◄───► leader B ◄─── client (other region)
▲ ▲
└───── replicate ─┘
Multiple leaders, often one per geographic region. Each accepts writes locally; replicates async to others.
Used by: BDR (Postgres), CouchDB, multi-region MySQL with custom plumbing, Cassandra (per-DC), Cosmos DB multi-write.
Trade: local-region latency, but write conflicts between leaders. Same key updated in A and B concurrently → which wins?
Conflict resolution:
- Last-write-wins (LWW): timestamp-based; can drop data.
- App-defined merge: e.g., union for sets, max for counters.
- CRDTs (G-Counter, OR-Set): merge correctly by construction.
- Manual: store both, raise to user (rare).
Leaderless
┌─► node A
client ──────────►├─► node B
└─► node C
Writes go to multiple nodes in parallel. Reads also go to multiple nodes and the client (or coordinator) picks the latest version (often via timestamps).
Used by: Cassandra (default), DynamoDB, Riak, ScyllaDB.
Quorum math (N replicas, W write acks needed, R read acks needed):
W + R > N → strong consistency for that op
W + R ≤ N → eventual
Common: N=3, W=2, R=2 (read/write quorum).
Read repair / anti-entropy:
- Read-time repair: when read returns mismatched versions, write the latest back to lagging replicas.
- Merkle-tree-based background sync.
- Hinted handoff: temporarily store writes for an unreachable replica.
Sync vs Async Tradeoffs
| Mode | Latency | Durability | Availability |
|---|---|---|---|
| Single async | Lowest | Lose recent writes on failover | High (no quorum needed) |
| Single sync to one | Low | Survive 1 failure | Medium |
| Single sync to majority (Raft) | Medium | Survive minority failure | High |
| Multi-leader | Local-fast | Conflict-prone | High |
| Leaderless quorum | Configurable per op | Configurable | Highest |
Replication Lag
For async or leaderless reads, the follower can be behind. Implications:
You write to leader: balance = 100
Immediately read from follower: balance = 90 (lag of 1 second)
Patterns to handle:
- Read-your-writes: route reads to the leader for the user's recent writes (sticky for a window).
- Monotonic reads: pin the user to one follower so reads don't bounce between replicas at different lags.
- Bounded staleness: SLA on max lag; alert if exceeded.
Failover and Split Brain
When a leader is unreachable, who promotes?
- Manual: ops team flips the switch. Slow but safe.
- Automated with consensus: Raft/Paxos for promotion. Used by etcd, Consul, modern Postgres (Patroni).
- STONITH (Shoot The Other Node In The Head): kill the old leader to prevent two-leader scenarios.
Split brain: network partition makes each side think it's the leader. Both accept writes → divergence. Mitigations:
- Require quorum to be leader.
- Fence the minority side via STONITH.
- Use a coordination service (etcd) for leases.
Geographic Replication
For multi-region, choose:
| Pattern | Trade |
|---|---|
| Single leader, async to other regions | Cheap, lose data on region loss |
| Single leader, sync to other region | Cross-region write latency (~80–150 ms) |
| Multi-leader per region | Local writes fast, conflict resolution required |
| Distributed consensus (Spanner) | Linearizable across regions; expensive |
Replicated Logs
The underlying mechanism for most replication is a replicated log (WAL, binlog, Raft log, Kafka). Followers consume the log and apply changes in order.
Why logs:
- Order is unambiguous.
- Replay is idempotent (LSN-based).
- Lag is observable (offset distance).
- Recovery = "catch up the log."
Backups vs Replicas
Replicas are not backups. Both protect against hardware failure. Only backups protect against logical errors (bad query deleted everything; replica deleted it too).
Standard: replicas for availability, snapshots + point-in-time recovery for logical disasters.
Common Mistakes
| Mistake | Reality |
|---|---|
| Treating replicas as backup | A DROP TABLE propagates everywhere |
| Reading from replica when read-your-writes needed | Stale data, confused users |
| No alerting on replication lag | Lag balloons unnoticed; failover loses data |
| Manual failover with no documented runbook | Outage longer than necessary |
| Multi-leader with last-write-wins on financial data | Silent data loss |
[!NOTE] Pick a replication topology by the question "what should happen during a partition?" Single-leader CP loses availability. Leaderless AP loses recency. Multi-leader trades both for latency. There's no free combination.
Interview Follow-ups
- "How would you do zero-downtime failover?" — Streaming replication + auto-promotion + connection draining + retry on the client side.
- "What is semi-sync replication?" — Leader waits for at least one follower's ACK before responding to client. Bounded data-loss window.
- "When is multi-leader the right call?" — Multi-region active-active where local-write latency matters AND your data tolerates merge / LWW (calendars, document collaboration, social).
Q: Object storage (S3) — when, what guarantees, gotchas.
Answer:
S3-like object storage is the cheapest, most durable way to keep blobs at scale. It's not a filesystem — it's an immutable key-value store with HTTP API.
What It Is
Bucket: my-bucket
Key: photos/2025/04/img1.jpg → bytes
Key: photos/2025/04/img2.jpg → bytes
Key: backups/db_20250401.sql.gz → bytes
Flat namespace. Slashes in keys are just characters; folder prefixes are a convention.
Properties
- Durability: 11 9s (S3 standard). Multiple copies across AZs.
- Availability: 99.99% (S3 standard).
- Strong read-after-write consistency (since Dec 2020 for S3).
- Cost: cheap storage (~$23/TB/month standard), per-request fees, egress is expensive.
- Throughput: 3,500 PUT/copy/delete and 5,500 GET per second per partition prefix.
Storage Classes
| Class | Use | Cost |
|---|---|---|
| Standard | Hot, frequent access | $$$ |
| Intelligent-Tiering | Auto-move based on access | $$ |
| Standard-IA | Infrequent access | $ |
| Glacier Instant | Archive, immediate retrieve | $ |
| Glacier Flexible | Archive, minutes to hours retrieve | ¢ |
| Glacier Deep Archive | Archive, hours to retrieve | ¢¢ |
Move data via lifecycle policies (e.g., "after 30 days → IA, after 1 year → Glacier").
What Object Storage Is GOOD For
- Large files (photos, videos, backups).
- Static website assets.
- Data lake (Parquet, ORC) for analytics.
- Logs / archives.
- ML training data sets.
- Software releases / installer files.
What It's BAD For
- Frequent updates to the same object (no in-place modify; full rewrite).
- Tiny files (per-request overhead dwarfs payload).
- Low-latency reads (~10–100 ms per GET vs microseconds for Redis).
- Filesystem semantics (no rename — just copy + delete).
Multipart Upload
Large files split into parts (5 MB – 5 GB each):
1. Initiate multipart upload → get UploadId.
2. Upload parts in parallel.
3. Complete (with list of parts + ETags).
Resume: re-upload only failed parts. Cap: 10,000 parts → max object 5 TB.
Presigned URLs
Time-limited URL granting access without sharing credentials:
url = s3.generate_presigned_url('put_object',
Params={'Bucket': 'b', 'Key': 'k'}, ExpiresIn=300)
# client uploads directly to S3 with this URL
Use for direct browser uploads (skip your backend); secure media delivery.
Versioning
Enable on bucket → every PUT creates a version. Delete = adds a "delete marker"; old versions remain until explicitly removed.
Protects against accidental overwrites/deletes. Cost: storage of all versions.
Object Lock / Compliance
Write-Once-Read-Many. Object can't be deleted for a retention period (governance or compliance mode).
Used for audit logs, regulatory archives, ransomware protection.
Cross-Region Replication
Bucket-level: every object replicated to another region (sync or async).
Used for DR or for serving traffic close to users.
Access Patterns
Read patterns:
- Direct from client via presigned URL (most common).
- Through CDN (CloudFront) for caching + edge.
- From backend (analytics jobs).
Write patterns:
- Direct from client via presigned URL.
- From backend (uploads, ETL).
For browser apps: presigned URL + CDN. Backend rarely intermediates the bytes.
Cost Optimization
- Lifecycle policies → cold tier old data.
- Intelligent-Tiering for unpredictable access.
- Egress is the most expensive line — use CloudFront / Cloudflare R2 for free egress.
- Avoid
LISTover millions of keys; use S3 Inventory reports.
Eventual vs Strong Consistency
Pre-Dec-2020 S3 was eventually consistent for reads after writes. As of now: strong read-after-write consistency for all operations.
You can read your write immediately. Same for delete.
Common Pitfalls
| Pitfall | Fix |
|---|---|
| Many tiny files | Pack into bigger objects (e.g., tar/parquet) |
| Hot prefix at scale | Spread by hashing key prefix (a1b2c3/... vs 2025/04/...) |
| Storing PII unencrypted | Enable SSE-S3 or SSE-KMS by default |
| Public bucket leak | Block Public Access + bucket policies + IAM |
| Long polling for new files | Use S3 event notifications (SNS / SQS / Lambda) |
LIST to find files | S3 is KV; maintain own index if you need search |
S3 Event Notifications
PUT object in bucket → SNS / SQS / Lambda trigger
Common patterns:
- Trigger image-resize on upload.
- Index new files into Elasticsearch.
- Notify users of new content.
Multi-Region Architecture
client uploads → nearest region's bucket
│
▼ async replicate
other region buckets
Read from nearest region; cross-region reads possible but slower.
Common Mistakes
| Mistake | Reality |
|---|---|
| Treating S3 as filesystem | No rename, no folder ops; just keys |
| Synchronous PUT for every event | Batch into bigger objects |
| Public bucket "for convenience" | Data breaches |
| Not setting lifecycle policy | Storage bill grows forever |
| Logging every request | Cost; use CloudTrail or aggregate |
[!NOTE] S3 is the cheapest hard drive on earth. Architect around its strengths (immutable, durable, KV) and don't fight its weaknesses (latency, mutations).
Interview Follow-ups
- "How would you build a CDN-fronted bucket for video?" — CloudFront in front; signed URLs; bucket private; signed Cookie for whole-folder access.
- "How to manage millions of small files?" — Pack into parquet/tar; store index in DB; reduces S3 request cost dramatically.
- "S3 vs DynamoDB?" — S3 = large blobs, infrequent access pattern, eventual-now-strong consistency. DynamoDB = small structured records, milliseconds, transactions.
Q: Time-series databases — what makes them different?
Answer:
A time-series DB (TSDB) is optimized for append-only, timestamp-indexed data with high write volume and range-scan reads. Trying to do this in a normal RDBMS is a path to pain.
What Makes Data "Time-Series"
- Each record has a timestamp.
- New data is mostly appended at "now."
- Updates are rare; deletes are bulk (retention).
- Queries scan ranges by time: "last 5 min," "yesterday."
- Aggregations dominate: rate, sum, p99 over windows.
Use cases: monitoring metrics, IoT sensor data, financial ticks, user activity events.
Popular TSDBs
| TSDB | Lineage |
|---|---|
| InfluxDB | Native TSDB; popular for IoT |
| Prometheus | Pull-based monitoring |
| TimescaleDB | Postgres extension; SQL+TSDB |
| Druid / Pinot | Real-time analytics columnar |
| ClickHouse | Columnar OLAP, doubles as TSDB |
| OpenTSDB / KairosDB | HBase-backed |
| VictoriaMetrics | Prometheus-compatible, denser |
Optimizations
Append-only writes: no random writes; sequential disk patterns; high throughput.
Compression: Gorilla algo (timestamps as delta-of-delta, values as XOR diff) → ~1.4 bytes/sample.
Time-partitioned storage: each chunk covers a time window; old chunks dropped wholesale.
Indexes by (series, timestamp): range queries find the chunk + scan.
Downsampling: roll up raw → 1-min → 5-min → 1-hour at increasing retention.
Schema: Series
series_id = hash(metric_name + label_set)
data = [(ts, value), (ts, value), ...]
A series is a unique combination of labels. Each new label combination creates a new series.
Cardinality = total unique series. Bad labels (user_id, request_id) explode cardinality → OOM.
Querying
Prometheus PromQL example:
sum(rate(http_requests_total{status=~"5.."}[5m])) by (service)
InfluxDB Flux:
from(bucket: "metrics")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "cpu" and r.host == "h1")
|> aggregateWindow(every: 1m, fn: mean)
TimescaleDB SQL:
SELECT time_bucket('1 minute', time) AS bucket, avg(value)
FROM cpu_usage
WHERE host = 'h1' AND time > now() - INTERVAL '1 hour'
GROUP BY bucket
ORDER BY bucket;
Retention
Time-based retention is built in:
keep raw for 7 days
keep 1-min downsampled for 30 days
keep 1-hour downsampled for 1 year
Dropping a time window = dropping a chunk file. Trivial.
vs Generic RDBMS
If you try Postgres for metrics at scale:
- 100k inserts/sec hits write throughput.
- Indexes bloat from constant inserts.
- Vacuum overhead.
- Time-range queries do sequential scan on huge tables.
TimescaleDB sidesteps this by chunking (auto-partitioning by time) on top of Postgres + columnar compression. Best of both: SQL + TSDB performance.
vs OLAP (ClickHouse, BigQuery)
OLAP DBs are great for time-series too. ClickHouse, Druid, Pinot:
- Columnar storage.
- Excellent aggregations.
- Higher query latency than dedicated TSDBs but more flexible (joins, distinct counts).
For metrics: TSDB. For events with rich querying: OLAP. The line blurs.
Cardinality Management
Sources of explosion:
- Per-user labels.
- Per-request IDs.
- Per-pod UUIDs in K8s.
- Free-form text labels.
Rule of thumb:
- Labels: < 10 values per label, < 10 labels.
- Cardinality budget per metric: ~10,000 series.
- High-cardinality goes to logs/events, not metrics.
Scale
Million-series TSDBs commonplace.
Tens-of-millions with sharded/clustered solutions.
Hundreds-of-millions: managed (Mimir, VictoriaMetrics cluster, M3DB).
Single node holds ~1M series, 100k samples/sec.
Multi-Tenant / Federated
For organization-wide metrics:
- Per-team / per-cluster TSDB.
- Federation: central queries fan out to clusters.
- Or: managed multi-tenant (Cortex, Mimir, Datadog).
Common Mistakes
| Mistake | Fix |
|---|---|
| Using RDBMS for high-volume time-series | TSDB or OLAP |
| User ID as a label | Cardinality death |
| Querying months at raw resolution | Use downsampled |
| Single global retention | Per-metric retention sized to value |
| Logging individual events as metrics | Use logs/traces; metrics are aggregates |
[!NOTE] Time-series databases trade flexibility for compression + write performance. They're not "a SQL database with a timestamp column" — they're a specialized engine and need to be treated as such.
Interview Follow-ups
- "When would you NOT use a TSDB?" — Ad-hoc analytics with complex joins; rich relational queries — use a warehouse instead.
- "How would you store both metrics and traces?" — Metrics in TSDB; traces in dedicated trace store (Tempo, Jaeger); logs in Loki/Elastic. Three pillars, three stores.
- "What's downsampling, and what does it cost?" — Aggregate raw points into coarser buckets (1m → 5m → 1h) over time. Cost: small CPU to compute; you lose precision but save 12–60× storage.
Q: Cache strategies — cache-aside, write-through, write-back, write-around, read-through.
Answer:
A cache speeds up reads (sometimes writes) by sitting in front of a slower store. The five canonical strategies differ in who writes to the cache vs DB, and when. Pick by failure mode you can tolerate.
The Five Strategies
| Strategy | Read path | Write path | Failure if cache dies |
|---|---|---|---|
| Cache-aside (lazy) | App: check cache → miss → fetch DB → fill cache | App writes DB; invalidates cache | DB takes full load |
| Read-through | App reads cache; cache fetches from DB on miss | App writes DB directly | App still reads DB if cache off |
| Write-through | Same as read-through | App writes cache; cache writes DB sync | Slower writes if cache off |
| Write-back (write-behind) | Same as read-through | App writes cache; cache flushes DB async | Data loss risk if cache dies before flush |
| Write-around | Reads as above | App writes DB only; cache filled lazily | Cold reads of new data |
Cache-Aside (Most Common)
def get(key):
val = cache.get(key)
if val is None:
val = db.get(key)
cache.set(key, val, ttl=600)
return val
def update(key, val):
db.put(key, val)
cache.delete(key) # invalidate; don't update
Properties:
- Simple, app-controlled.
- Cache failures degrade gracefully (DB serves).
- Stale window between DB write and cache invalidate.
- Cache misses go to DB — stampede risk under burst load.
Why delete instead of set on writes: if two writes race, deletes are commutative; set can install a stale value.
Read-Through
Cache exposes the same API as the DB; the cache itself does the DB lookup on miss.
get(key):
cache layer:
val = local.get(key)
if val is None:
val = db.get(key)
local.set(key, val, ttl)
return val
Centralizes cache logic, simplifies the app. Most managed caches (AWS DAX) work this way.
Write-Through
Writes go to cache, cache writes DB synchronously.
write(key, val):
cache.set(key, val)
db.put(key, val) # sync
return success
Pros: cache always in sync. Cons: writes are slower (cache + DB latency). Cache must be in front of every write path — no out-of-band DB writes allowed (or you'll have drift).
Write-Back / Write-Behind
Writes to cache only; cache flushes to DB asynchronously (batched).
write(key, val):
cache.set(key, val)
schedule async DB write
return success ← fast
Pros: ultra-fast writes, batching for DB efficiency. Cons: data loss if cache dies before flush. Suitable for metrics, counters, analytics. Not for financial data.
Write-Around
Writes bypass cache, only DB is touched. Cache is filled on subsequent reads.
write(key, val):
db.put(key, val)
# do NOT write cache
read(key):
val = cache.get(key)
if val is None:
val = db.get(key)
cache.set(key, val)
Useful for write-heavy data that's rarely re-read (logs, telemetry).
Choosing
| Workload | Pick |
|---|---|
| Read-heavy, eventual freshness OK | Cache-aside |
| Read-heavy, want strict freshness | Write-through + invalidation |
| Write-heavy, durability critical | DB-only or cache-aside |
| Write-heavy, durability OK to lose | Write-back |
| Writes never re-read soon | Write-around |
| App should be ignorant of cache | Read-through |
TTL — the Quiet Hero
Every cache entry should have a TTL. Reasons:
- Limits stale-data damage from bugs in invalidation.
- Bounds cache size organically.
- Keeps cache "honest" — if a key is wrong, it self-heals in TTL.
Typical TTLs:
- User profile: 5–60 min.
- Static reference data: hours/days.
- Anything counters or timeseries: seconds.
Negative Caching
Cache "no, this doesn't exist" responses too. Otherwise a flood of requests for nonexistent keys (e.g., bots probing) hits the DB.
val = db.get(key)
if val is None:
cache.set(key, NULL_SENTINEL, ttl=60) # short TTL
Refresh-Ahead
Refresh popular keys before they expire to avoid the miss stampede:
on read:
val, ttl_remaining = cache.get_with_ttl(key)
if ttl_remaining < 10% of original:
async_refresh(key)
return val
The user serving the request never waits on the refresh.
Multi-Tier Caches
Browser cache (HTTP cache headers)
▼
CDN (edge, geo)
▼
App-server local cache (in-process LRU)
▼
Distributed cache (Redis cluster)
▼
DB
Each layer absorbs progressively more traffic. p99 latency is set by the cheapest cache that's still warm enough.
Cache Hit Ratio
Most important cache metric.
hit_rate = hits / (hits + misses)
Healthy: > 80%. Excellent: > 95%. Below 50% — the cache is barely earning its keep; revisit keys, TTLs, capacity.
Common Mistakes
| Mistake | Fix |
|---|---|
| Updating cache on write instead of deleting | Race conditions → stale values |
| No TTL "to maximize hit ratio" | Bugs become permanent; size grows unbounded |
| Caching everything | Adds invalidation surface; cache 20% hot keys |
| Caching huge objects | Network + CPU; consider field-level caching |
| One Redis for hot + cold data | Hot data evicts cold causing fetches |
| Identical TTL on all keys | Synchronous expiry → stampede; jitter TTLs |
[!NOTE] The first rule of caching: invalidation is the hardest problem. The second rule: TTL is your friend — design assuming invalidation will sometimes fail.
Interview Follow-ups
- "How would you achieve strong consistency between cache and DB?" — Hard. Options: write-through with cache as source of truth; or accept eventual + short TTLs; or use CDC to invalidate cache from DB changes.
- "What about cache stampede on cold start?" — See Cache Stampede: request coalescing, locks, probabilistic early refresh.
- "How do you decide what to cache?" — Hot-set analysis: log key accesses, find the Pareto 20%. Cache that; ignore the rest.
Q: Cache invalidation & eviction — the hard part of caching.
Answer:
"There are only two hard things in computer science: cache invalidation and naming things." — Phil Karlton. The invalidation problem is real because cache and source-of-truth can diverge, and detecting / repairing the drift is the bulk of the engineering.
Invalidation Approaches
1. TTL (time-based)
Every key expires after N seconds. Simplest, most reliable. The cache self-heals; stale data has a bounded lifetime.
cache.set(key, value, ttl=600)
Trade: stale data lives up to TTL.
Pick TTL by:
- How sensitive the data is to staleness.
- How much load the origin can take if all cache entries expire at once.
2. Write-through invalidation
On every write to source, also update / delete cache:
write(key, value):
db.put(key, value)
cache.delete(key) ← delete, don't set
Why delete vs set: race conditions. Two concurrent writers can set stale value last. Deleting is commutative.
Trade: every write path must remember to invalidate. Easy to miss in heterogeneous code.
3. Event-driven (CDC) invalidation
The DB streams its changes; a separate process invalidates cache entries based on the stream:
Postgres WAL → Debezium → Kafka → cache invalidator
Decouples invalidation logic from write paths. Works even if a write bypassed your app.
4. Versioning / tagging
Each cached entry includes a version or set of tags. Invalidation = bump version / purge tag.
cache.set("user:42:v3", value) # version in key
cache.set_with_tags(key, value, tags=["user:42"])
# Invalidate:
cache.invalidate_tag("user:42")
Fastly / Cloudflare support tag-based purge for CDN.
Eviction Policies (When Cache Full)
| Policy | Description | Good for |
|---|---|---|
| LRU | Evict least-recently-used | General workload |
| LFU | Evict least-frequently-used | Skewed access (Pareto) |
| TTL-aware | Prefer expiring soonest | TTL-heavy workloads |
| Random | Evict random | Surprisingly effective; cheap |
| TinyLFU | Hybrid; Caffeine default | Mixed workloads |
| No-eviction | Reject writes when full | Counters, queues |
Redis defaults: noeviction. Change via maxmemory-policy.
LRU Implementation
Doubly linked list + hash map:
- On access: move to head.
- On insert: add at head; if over capacity, remove tail.
- O(1) per operation.
LRU is the universal default. LFU often does better when access patterns have a long tail of "loved" keys, but is harder to implement (LFU classic doesn't decay; "windowed TinyLFU" fixes it).
Hot Key Eviction Problem
A single hot key + many less-popular keys + LRU = the hot key keeps the popular content in cache → less-popular evicted constantly → unstable behavior.
Fix: LFU or LRU + size-tier (keep large objects separate).
Negative Caching
Cache "no" answers too:
val = db.get(key)
if val is None:
cache.set(key, NULL_SENTINEL, ttl=60)
Without it, repeated lookups for non-existent keys hit the DB. Bots and crawlers exploit this.
Short TTL — newly inserted keys should appear after a brief window.
Refresh-Ahead
Refresh popular keys before expiry:
on read:
val, ttl_remaining = cache.get_with_ttl(key)
if ttl_remaining < 0.1 * original_ttl:
async_refresh(key) # background
return val
User never waits on refresh. Reduces stampede risk.
Stampede Protection
Hot key expires; many clients race to refill:
- Lock + fetch (first one wins; others wait).
- Probabilistic early expiration (each client treats TTL with jitter).
- Stale-while-revalidate (serve stale during async refresh).
See Cache Stampede.
Multi-Tier Invalidation
Browser cache (Cache-Control)
▼
CDN edge cache
▼
App-server local cache (Caffeine, lru-cache)
▼
Distributed cache (Redis)
▼
DB
Invalidating across all tiers is hard. Strategies:
- Tag-based purge at CDN (Fastly, Cloudflare).
- Short TTL at lower tiers.
- Bust local caches via pub/sub on invalidation event.
CDN-Specific Invalidation
purge by URL: invalidate single URL
purge by tag: invalidate group
soft purge: serve stale until origin returns fresh
Purges are not instant — usually < 30s propagation.
Avoid: Cache Aside With Concurrent Updates
client A: write DB
client B: write DB
client A: invalidate cache
client B: invalidate cache
client A: SET cache = old A read ← race
This is why we delete rather than set on writes. The next miss reads from DB and fills correctly.
Common Mistakes
| Mistake | Fix |
|---|---|
SET cache on write | Race conditions; use DELETE |
| TTL = forever "for hit ratio" | Stale forever when bugs strike |
| Invalidating only on success | DB write succeeds, cache stays stale on app crash |
| No metrics on stale serving | Can't detect drift |
| Cache hit ratio target without observability | Optimizing the wrong number |
Observability
- Hit ratio:
hits / (hits + misses). - Stale rate: how often was the cached value out of date when re-fetched?
- Eviction rate: indicates capacity vs working set.
- Invalidation events / sec: too many = bug in upstream.
[!NOTE] Treat invalidation as a separate, observable concern from the cache itself. The fact that "we have a cache" doesn't tell you whether it's correct — instrument what matters.
Interview Follow-ups
- "How do you keep cache consistent with DB?" — You don't keep them perfectly consistent. Pick: TTL (bounded staleness), write-through (synchronous cost), or CDC (eventual). All have trade-offs.
- "What's the right TTL?" — Long enough that the cache hits; short enough that stale damage is bounded. Measure both.
- "How do you cache personalized data?" — Per-user key prefix; smaller hit ratios but precise. Or cache fragments and assemble at request time.
Q: Cache stampede / thundering herd — what causes it and how to fix it.
Answer:
A cache stampede happens when a hot key expires (or a cache restarts) and many concurrent requests stampede the origin, often crushing the database. Easy to overlook; can take down production.
The Failure
t=0: 100k QPS hits cache; 99% hits.
t=10: hot key expires.
t=10s: 100k requests all miss; all hit DB simultaneously.
t=10s: DB collapses under the load it normally never sees.
The hotter the key, the worse the stampede.
Causes
- Hot key expires: TTL hits; everyone races to refill.
- Cache restart: Redis crash + reboot loses all keys.
- Cache deploy / scaling event: new nodes start cold.
- Mass invalidation: a tag purge nukes thousands of keys at once.
Mitigations
1. Locking / Request Coalescing
Only one request fetches; others wait:
def get(key):
val = cache.get(key)
if val is not None:
return val
if cache.setnx("lock:" + key, "1", ttl=10): # try acquire
try:
val = db.get(key)
cache.set(key, val, ttl=600)
return val
finally:
cache.delete("lock:" + key)
else:
# Someone else is refreshing — wait briefly
time.sleep(0.1)
return get(key) # retry
The first miss takes the lock; the rest poll. DB sees one request.
Variants:
- Java: Caffeine's
LoadingCachedoes this in-process. - Go:
singleflight. - Distributed: Redis SETNX or Redlock.
2. Stale-While-Revalidate
Serve stale during async refresh:
val, age = cache.get_with_age(key)
if age > TTL: # stale
async_refresh(key) # background
return val # serve stale anyway
The user sees no latency spike. Origin sees one refresh, not many.
Browser / CDN support this via Cache-Control: stale-while-revalidate=....
3. Probabilistic Early Expiration
Each client treats TTL as random within a window:
effective_ttl = ttl × (1 + random()×0.1)
Or smarter: XFetch algorithm — refresh probability rises as TTL nears:
on read:
if random() < (now - expiry) / refresh_window:
refresh()
return cached_value
Spreads refresh across clients; only one wins the race naturally.
4. Permanent Cache + Background Refresh
Don't expire; refresh periodically. Cache is always populated; just possibly stale.
on read: return cache.get(key)
every 60s: cache.set(key, db.get(key))
Works for read-mostly data with bounded staleness budget. No stampede ever, because no expiry.
5. Pre-Warming on Deploy
Before promoting a new cache node, pre-populate it from the old:
new node joins cluster
→ drain replica or fetch from sibling
→ mark ready when caught up
Avoids cold-start stampede.
6. Hot Key Replication
For a single key with massive traffic:
- Replicate the key to all cache nodes (read-anywhere).
- Or process-local L1 cache for that key only.
- Reduces request to a single shard.
Stampede Scenarios by Scale
| Scenario | Risk |
|---|---|
| Many low-traffic keys, all TTL aligned | Spike at the boundary — jitter the TTLs |
| One celebrity-user key | Hot stampede — lock + warm |
| Cache restart at 9 AM (deploy time) | Cold stampede — gradual warmup |
| Tag-based purge of 10k keys | Bulk stampede — rate-limit purge |
Detection
Metrics:
- Backend QPS spike correlated with cache miss spike.
- p99 latency briefly degraded.
- Lock contention metrics (if you're using locks).
Alert on:
cache_misses_per_secspike > N× baseline.- DB request count anomaly.
Common Mistakes
| Mistake | Fix |
|---|---|
| All keys with same TTL | Boundary stampede; jitter |
| No lock on cache miss | Every concurrent request hits DB |
Trying to lock with GET+SET (race) | Use atomic SETNX |
| Locking forever (lock never released on crash) | Lock TTL + heartbeat |
[!NOTE] Cache stampedes are silent killers. They don't show up under steady state — only when the cache is "doing its job" most of the time and a hot key happens to expire. Test stampede behavior explicitly (drop a hot key, watch the origin).
Interview Follow-ups
- "What's the cheapest fix?" — TTL jitter + SWR. No new infra, minimal code.
- "How does Caffeine handle this in-process?" —
LoadingCachewith aCacheLoader; only one thread computes per key; others wait on the result. - "How would you handle a cold-start stampede after Redis restart?" — Replay-from-replica + canary connection draining + connection-pool limit toward DB (force backpressure).
Q: Redis vs Memcached — picking a distributed cache.
Answer:
The two dominate distributed caching. Memcached is older, simpler, faster on pure key-value. Redis is richer, with data structures, persistence, pub/sub, scripting. Modern default: Redis unless you need pure speed and nothing else.
Memcached
Pure in-memory key-value store. Multi-threaded.
SET key value 0 60 # value, flags, expiry
GET key
DELETE key
- One data type: opaque blob.
- LRU eviction.
- No persistence.
- Multi-threaded (scales on a single box with many cores).
- Sharding: client-side consistent hashing.
- Operations strictly per-key; no multi-key transactions.
Strengths:
- Simplest semantics.
- Fastest pure get/set.
- Predictable memory model.
Redis
In-memory data-structure store. Single-threaded core + I/O threading.
Data types:
- Strings (incl. counters).
- Lists.
- Sets, Sorted Sets.
- Hashes.
- Streams.
- Bitmaps, HyperLogLog, Geo.
Operations:
- Transactions (MULTI / EXEC).
- Lua scripting (atomic).
- Pub/Sub.
- Streams (consumer groups, like a mini Kafka).
- Persistence (RDB snapshots + AOF append-only file).
- Replication + Cluster mode.
Strengths:
- Much more than a cache (queues, leaderboards, locks, rate limiters).
- Persistence option.
- Richer client libraries.
When To Pick Which
| Need | Pick |
|---|---|
| Cache only, KV, max throughput | Memcached |
| Counters, leaderboards, queues | Redis |
| Rate limiting, distributed locks | Redis |
| Pub/Sub fanout | Redis |
| Streaming with retention | Redis Streams (or Kafka) |
| Persistence required | Redis |
| Single-thread perf is enough, multi-core not critical | Either |
For 90% of new projects: Redis. Memcached's niche is rare these days.
Persistence (Redis)
- RDB: snapshot to disk every N seconds. Fast restore; some loss.
- AOF: append every write to a log. Slower; minimal loss (configurable fsync).
- Hybrid: AOF for durability + RDB for fast restore.
For pure cache: persistence off; treat as ephemeral.
For session store / queue / counter: AOF with everysec fsync — good balance.
Replication
master → slave (or in modern terms, primary → replica)
Async by default. Replica serves reads (optionally); failover if primary dies.
Redis Sentinel monitors and orchestrates failover.
Redis Cluster
16,384 hash slots assigned across nodes. CRC16(key) → slot → node.
3-master, 3-replica cluster:
M1 (slots 0-5460) ← R1
M2 (slots 5461-10922) ← R2
M3 (slots 10923-16383) ← R3
Client gets MOVED redirect if it lands on wrong node. Multi-key operations require keys in same slot (use {} hash tags: user:{42}:profile and user:{42}:settings share a slot).
Memcached Sharding
Pure client-side. The client library hashes the key and picks the node:
node = consistent_hash(key) → server in ring
No coordinated state. Adding a node moves ~1/N of keys.
Throughput Benchmarks
Single node:
- Memcached: ~1.5M ops/sec/core (multi-threaded).
- Redis: ~100k–200k ops/sec single-threaded; more with pipelining + io_uring.
Multi-core scaling:
- Memcached scales linearly across cores in one process.
- Redis traditionally requires multiple instances (or use KeyDB, Dragonfly for multi-core Redis-protocol-compatible).
Pipelining
Both support pipelining: send N requests in one network round trip.
pipeline.set(a, 1).set(b, 2).get(c).execute()
10× throughput improvement under high QPS. Critical for production tuning.
Eviction
- Memcached: LRU per slab class.
- Redis: configurable (
maxmemory-policy):noeviction: reject writes when full.allkeys-lru,allkeys-lfu,allkeys-random.volatile-lru, etc. (only keys with TTL).
For pure cache: allkeys-lru typical. For mixed workload (cache + counters that must not be evicted): set TTL only on cacheable keys and use volatile-lru.
Operational Concerns
- Memory fragmentation: monitor
used_memory_rss / used_memory. Restart if high. - Persistence cost: AOF fsync can stall the event loop.
- Big keys: a 1 GB string is fine to fetch once; killing if accidentally enumerated. Avoid.
- SLOWLOG: identify slow commands.
- Cluster slot reshard during traffic: smooth via streaming; brief blips.
Common Mistakes
| Mistake | Fix |
|---|---|
Using KEYS * in production | O(N) blocking; use SCAN |
| Storing huge JSON blobs as one string | Slow ops; split into Hash fields |
| No connection pooling | Per-request connect overhead |
| Single Redis primary as SPOF | Sentinel + replica; failover plan |
| Using Redis as primary DB without durability | Data loss on restart |
Alternatives
- KeyDB: multi-threaded Redis fork.
- Dragonfly: modern Redis-compatible with much higher throughput.
- Hazelcast / Ignite / Aerospike: enterprise in-memory data grids.
- Cloud-managed: ElastiCache, MemoryDB (durable Redis), Memorystore.
For pure cache at scale: Memcached or Dragonfly. For everything else: Redis.
[!NOTE] The choice often comes down to ecosystem comfort and team familiarity. Both work. Pick the one that matches your durability + data-structure needs.
Interview Follow-ups
- "How do you ensure consistency between Redis cache and DB?" — Cache-aside pattern with delete-on-write + TTL. Never set the cache on write (race).
- "How would you build a distributed lock in Redis?" —
SETNXwith TTL; Redlock for multi-node Redis (controversial — see Martin Kleppmann's critique). For most cases, single-instance SETNX is fine. - "What about MemoryDB?" — AWS's durable Redis (multi-AZ persistence). For workloads that need Redis API + DB-grade durability.
Q: Queue vs Pub/Sub vs Log — which messaging model when?
Answer:
"Messaging" is three distinct patterns with very different semantics:
- Queue: one producer, one consumer per message. Work distribution.
- Pub/Sub: one producer, many consumers per message. Fan-out.
- Log: ordered, replayable record. Pub/sub + history.
Get the model right; the broker choice (Rabbit, SQS, Kafka) follows.
Queue
producer ──► [ msg1, msg2, msg3 ] ──► consumer1 (gets msg1)
──► consumer2 (gets msg2)
──► consumer3 (gets msg3)
Each message delivered to exactly one consumer. Workers compete; throughput scales with workers.
Used for: background jobs, email sending, image processing, async workflows.
Brokers: RabbitMQ, AWS SQS, Google Pub/Sub (pull mode), Beanstalkd, NATS JetStream.
Semantics:
- At-least-once is the typical default; consumer ACKs each message after processing.
- Visibility timeout: message hidden after delivery; redelivered if not ACKed in window.
- DLQ: after N failures, route to dead-letter queue for inspection.
Pub/Sub
producer ──► topic ──► subscriber A (sees all messages)
──► subscriber B (sees all messages)
──► subscriber C (sees all messages)
Each message delivered to every subscriber. Subscribers usually independent — A processing doesn't affect B.
Used for: event notifications, cache invalidation, real-time updates.
Brokers: Redis Pub/Sub (ephemeral), Google Pub/Sub (push), MQTT (IoT), Kafka (topics with consumer groups).
Semantics:
- Most pub/sub is at-least-once.
- If a subscriber is offline, may miss messages (depending on broker).
- Filtering: SNS message filters, AMQP routing keys.
Log
producer ──► append-only log ──► consumer A reads from offset 0
──► consumer B reads from offset 0
──► consumer C reads from offset 1000
[m1][m2][m3][m4][m5][m6][m7]... ← persistent, ordered
A durable, ordered record. Consumers maintain their own offset; can replay from any point. Multiple independent consumer groups read the same log.
Used for: event sourcing, stream processing, audit trails, CDC, data pipelines.
Brokers: Apache Kafka, AWS Kinesis, Apache Pulsar, Redpanda.
Semantics:
- Ordering preserved per partition.
- Retention by time or size, not by ACK.
- Replay: a new consumer can start from offset 0 and reprocess history.
The Three Compared
| Aspect | Queue | Pub/Sub | Log |
|---|---|---|---|
| Delivery | One per consumer | One per subscriber | One per consumer group |
| Retention | Until ACKed | Volatile or short | Days/weeks (configurable) |
| Replay | No | No | Yes (by offset) |
| Order | Loose (queue order, but workers parallel) | Per-subscriber | Per partition |
| Throughput | Medium | Medium | Very high |
| Best for | Work distribution | Fan-out notifications | Event streams, audit |
Picking the Right One
Decision tree:
Need persistent history? ─── yes ──► Log (Kafka)
│
no
│
Same message to many independent systems? ─── yes ──► Pub/Sub
│
no
│
Multiple workers competing? ─── yes ──► Queue
Combinations are common:
- Producer publishes to Kafka (log) → many consumer groups read.
- Each consumer group might internally distribute work via SQS (queue) for fan-out within the service.
Delivery Guarantees
| Guarantee | Meaning | Cost |
|---|---|---|
| At-most-once | Send and forget; may lose | Fast |
| At-least-once | Retry until ACK; may duplicate | Most common default |
| Exactly-once | One effect, even with retries | Requires idempotency + transactions |
Most brokers do at-least-once. Exactly-once is achieved via consumer-side idempotency keys.
See Delivery Semantics.
Ordering
Strict global ordering = single producer + single consumer. Doesn't scale.
Practical: ordering per key.
- Kafka: same key → same partition → ordered.
- SQS FIFO: messages share a
MessageGroupId→ ordered within group. - Pulsar: keyed_shared subscriptions.
Cross-key ordering: not guaranteed; design your business logic to be commutative or sequenced by app.
Push vs Pull
| Push | Pull |
|---|---|
| Broker pushes to consumer | Consumer pulls from broker |
| Broker tracks delivery | Consumer tracks offset |
| Easy for slow consumers? Hard (backpressure issues) | Easy (consumer paces) |
| Examples: SNS, webhooks | Kafka, SQS (long-poll) |
Pull-based usually handles backpressure better — consumer reads at its own pace.
Backpressure
What happens when the consumer can't keep up?
| Broker | Default behavior |
|---|---|
| Kafka | Lag grows; producer not impacted (unless retention deletes unread data) |
| SQS | Queue grows up to limits; visibility timeouts trigger retries |
| RabbitMQ | Producers eventually throttled (flow control); messages discarded if no DLQ |
| Redis Pub/Sub | Messages dropped for slow subscribers (no buffering) |
See Backpressure.
When Not to Use a Broker
- Synchronous request/response with low fanout — just call the service.
- Workflows requiring strict ordering across many entities — durable workflow engine (Temporal, Camunda).
- Sub-millisecond replies — broker hops add latency.
Common Mistakes
| Mistake | Reality |
|---|---|
| Treating Kafka as a queue (one consumer group) | Works but wastes its strengths; SQS is cheaper |
| Treating SQS as a log | Can't replay; design events around at-least-once + idempotency |
| Redis Pub/Sub for critical events | Drops messages for slow subscribers; no durability |
| Using a queue for fan-out (one queue per subscriber) | Now you have N queues to maintain; use pub/sub |
| Ignoring DLQ | Bad messages cause infinite redelivery loops |
[!NOTE] The hardest model to get right is "exactly-once event processing." Stop fighting it: use at-least-once + idempotent consumers + Idempotency-Key. That's how real systems do it.
Interview Follow-ups
- "How do you ensure ordering across services?" — Per-key ordering via partition/group; for cross-key, sequence via a single owner (saga orchestrator) or accept eventual.
- "What's the difference between Kafka topic and SQS queue?" — Topic = log with partitions, consumer groups, retention. Queue = transient buffer, one delivery per message, no replay.
- "How would you do exactly-once delivery?" — You don't. Use at-least-once + dedup by
event_idon the consumer side (DB unique constraint or seen-set).
Q: At-most-once, at-least-once, exactly-once — what they mean and how to achieve them.
Answer:
Delivery semantics describe what happens when messages are sent across a network that may fail. They're not properties of brokers; they're properties of producer + broker + consumer + dedup working together.
The Three Semantics
At-most-once
- Send and forget.
- Message may be lost.
- Never duplicated.
- Fastest.
At-least-once
- Retry until acknowledged.
- May duplicate.
- Never lost.
- Most common default.
Exactly-once
- Single observable effect.
- Hardest; requires producer + consumer + storage coordination.
Achieving Each
At-most-once:
producer.send(msg)
# no retry on failure
# no ack required
OK for: telemetry where freshness > completeness; UDP-like protocols.
At-least-once:
producer.send(msg)
on ack timeout: retry with backoff
Plus:
- Broker durability (replication, fsync).
- Consumer commits offset AFTER processing.
- Idempotent processing.
This is what 99% of production systems use.
Exactly-once:
Two flavors:
- Exactly-once delivery: impossible over an unreliable network (Two Generals Problem).
- Exactly-once effect (idempotent): achievable; producer can duplicate, but consumer ensures the work happens once.
How to Make Effects Exactly-Once
1. Idempotent consumer.
Consumer dedups by event ID before applying:
INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING;
-- if conflict: skip
-- else: do the work
event_id from producer, immutable.
2. Transactional consumer.
Wrap "do work + commit offset" in one transaction:
begin
apply event (idempotent OR uses event ID)
commit consumer offset to same store
commit
Kafka EOS_V2: producer transactions + consumer reads read_committed to skip aborted records.
3. Outbox + inbox pattern.
Producer atomically writes outbox + business data. Consumer atomically writes inbox + applies effect.
See Transactional Outbox.
Kafka's Exactly-Once Story
- Idempotent producer (
enable.idempotence=true): broker dedups by(producer_id, seq). Eliminates dupes within one producer session. - Transactions: producer writes atomically to multiple topics + commits consumer offsets in one tx. Consumer with
isolation.level=read_committedskips aborted records. - End-to-end: app must use Kafka transactions correctly and read with
read_committed.
Cost: ~20–40% throughput hit; small latency increase. Worth it for billing pipelines and similar.
Common Cases
Order placement webhook:
- At-least-once + idempotency key.
- Processor dedups by key.
- "Exactly-once effect."
Email sending:
- At-least-once + dedup by
dedup_key. - Bouncing duplicate sends → just don't send the email again.
Counters / analytics:
- At-least-once + dedup by event_id at the aggregator.
- Or accept small overcount for higher throughput.
Financial ledger:
- At-least-once + idempotency key.
- Append-only ledger; duplicate inserts rejected by unique constraint.
- Reconciliation pass catches anything that slipped through.
Where Each Layer Can Fail
producer ─send→ broker ─store→ consumer ─process→ effect ─commit→
A failure between any two stages requires the next attempt to be idempotent.
Common Mistakes
| Mistake | Reality |
|---|---|
| "Exactly-once delivery" guaranteed by broker | Marketing; in practice = at-least-once + dedup |
| Trusting client clocks for dedup IDs | Generate IDs at producer service-side |
| Dedup table without retention | Grows forever; TTL old entries |
| Commit offset before processing finishes | Loss on crash |
| Process then commit, but processing isn't idempotent | Duplicates on crash → wrong state |
[!NOTE] Stop asking for exactly-once delivery. Ask for exactly-once effects — that's achievable with idempotent operations and a dedup table. The semantics of the wire are at-least-once; the semantics of your business logic must be at-most-once on top.
Interview Follow-ups
- "How does Kafka achieve exactly-once across two topics?" — Transactional producer wraps writes + offset commit; consumer reads
read_committed. Atomic from the consumer's POV. - "What if the dedup store is down?" — Fail-closed (reject the message) or fail-open (allow duplicate). Pick per business cost.
- "Why is exactly-once delivery impossible?" — Two Generals Problem: in an unreliable network, you can never know with certainty that your peer received the message. Retries are necessary; therefore duplicates are unavoidable; therefore consumer dedup is mandatory.
Q: Backpressure — what is it, and how do you implement it?
Answer:
Backpressure is the feedback signal from a slow consumer telling its producer to slow down. Without it, producers overwhelm consumers, queues balloon, latency spikes, and the system collapses. Every streaming and async system needs an explicit backpressure story.
The Failure Mode
producer → unbounded queue → consumer (slow)
Queue grows: 1k, 10k, 100k, 1M messages.
Memory pressure builds. GC churns.
Latency for old messages goes from ms to minutes.
Eventually: OOM, or consumer can never catch up.
The Fix: Apply Backpressure
The producer must respond to the consumer's pace. Three flavors:
1. Blocking.
Producer's send() blocks until consumer has capacity.
bounded queue (capacity=1000)
send() blocks if full
recv() unblocks send when capacity returns
Simple. Trade: blocked producer threads.
2. Lossy. Drop messages when full.
try_send(msg):
if queue.size < capacity:
queue.add(msg)
else:
drop or sample
Used for telemetry, analytics where freshness > completeness.
3. Reactive (pull-based). Consumer signals demand; producer sends only what's requested.
consumer.request(10) → producer sends 10 items
consumer.request(5) → producer sends 5 more
Used by Reactive Streams (Java/JVM), RxJS, Akka Streams.
Where Backpressure Lives Per Tech
| Layer | Backpressure mechanism |
|---|---|
| TCP | Built-in window-based flow control |
| HTTP/1.1 | TCP-level only |
| HTTP/2 | Per-stream window + flow control |
| gRPC | Built on HTTP/2; flow control implicit |
| Kafka | Consumer pulls — natural backpressure |
| RabbitMQ | Per-channel flow control; broker can block publishers |
| SQS | Producer never blocked; messages buffer in broker |
| Reactive Streams | request(n) method |
| Tokio channels | Bounded send().await blocks |
Application-Level Patterns
1. Bounded buffer.
queue.put(msg) # blocks if full
Java's BlockingQueue capacity-bounded. Producer naturally throttles.
2. Semaphore for concurrency control.
sem = Semaphore(100)
for task in tasks:
sem.acquire()
async_process(task).then(sem.release)
Caps in-flight work; doesn't let it spike unboundedly.
3. Token bucket producer.
Producer holds a bucket of tokens, replenished at consumer's confirmed pace. Sends only with a token.
4. Adaptive rate limit.
Producer measures consumer's response latency; if latency rises, reduce send rate. AIMD (Additive Increase, Multiplicative Decrease) — same as TCP congestion control.
When Backpressure Isn't Enough — Shed Load
If you can't slow down, drop messages. Patterns:
- Tail drop: drop new messages when full.
- Head drop: drop oldest (newest data more valuable, e.g., telemetry).
- Priority drop: drop low-priority first.
- Random sampling: drop 1 in N to maintain a representative sample.
Better than collapsing. Document what's dropped.
Async / Reactive Backpressure
Reactive Streams spec defines a Publisher-Subscriber protocol with request(n):
Flux.range(1, 1000)
.onBackpressureBuffer(100) // buffer up to 100
.onBackpressureDrop(msg -> log("dropped: " + msg))
.subscribe(subscriber);
Operators like buffer, drop, latest define overflow strategy.
Buffer Sizing
buffer_size = expected_lag × throughput
If consumer might lag by 1s and you process 10k/sec, buffer of 10k handles 1s spike.
Larger buffers smooth bursts; smaller buffers reveal problems faster. Tune for your SLA.
Backpressure vs Throttling
- Backpressure: feedback from consumer to producer.
- Throttling / rate limiting: producer caps itself (or is capped by gateway).
Both reduce flow; backpressure is reactive (responds to actual capacity); throttling is proactive (predefined limit).
Multi-Layer Backpressure
client ─── HTTP/2 flow control ──► gateway
│
│ bounded channel
▼
service
│
│ async submit with semaphore
▼
worker pool
│
│ bounded queue
▼
downstream
Each layer enforces capacity. The slowest layer signals back through every prior layer.
Common Mistakes
| Mistake | Result |
|---|---|
| Unbounded queue / channel | OOM when consumer slow |
| Async submit with no concurrency cap | Thousands of in-flight requests crush downstream |
| Catch-and-ignore overflow | Silent loss |
| Logging at INFO every dropped message | Log floods worse than the original problem |
| No metric for buffer size / age | Can't see backpressure happening |
Observability
queue_size(gauge).queue_oldest_age(oldest pending item's age).producer_blocked_total(counter).dropped_messages_total(counter, with reason label).
Alerting on these surfaces backpressure before users feel it.
[!NOTE] Backpressure is not optional. Every async boundary in a system needs an explicit policy: bound the buffer, define overflow, surface the metric. "Just make the queue bigger" is how systems die.
Interview Follow-ups
- "How does Kafka apply backpressure?" — Consumer pulls. If consumer can't keep up, lag grows; producer keeps producing into the log. Backpressure to producer happens only if log retention is exceeded (very long lag) — by then it's a real outage.
- "What about WebSocket backpressure?" — Per-connection send buffer; if app can't drain client's slow socket, write to socket blocks; either drop or disconnect slow client.
- "How does AIMD relate?" — TCP congestion control mimics what application backpressure should do — slowly raise rate, halve it on signs of trouble.
Q: Consensus — Raft and Paxos. What do they solve, how do they work?
Answer:
Consensus protocols let a cluster of nodes agree on a value despite failures and message delays. They're the foundation of every distributed system that claims correctness: etcd, Consul, ZooKeeper, Spanner, CockroachDB, Kafka KRaft.
The Problem
Multiple servers must agree on:
- Who is the leader.
- The order of operations in a replicated log.
- Membership of the cluster.
Despite:
- Crashes (fail-stop).
- Network partitions.
- Message reordering, delay.
But not despite:
- Byzantine failures (servers lying) — outside scope; need different protocols.
Properties
A correct consensus protocol guarantees:
- Safety: agree on the same value (no two correct nodes decide differently).
- Liveness: eventually decide a value (assuming a majority is up).
Famous result: FLP impossibility — no deterministic protocol guarantees both safety and liveness in a fully async network with even one crash. In practice, we use timeouts and accept brief liveness violations during failures.
Raft (Understandable)
Raft splits consensus into three sub-problems:
- Leader election.
- Log replication.
- Safety (rules ensure only safe logs win elections).
Election
Each node is Follower, Candidate, or Leader.
Follower: hears heartbeats from leader; if timeout, become Candidate.
Candidate:
++current_term
vote for self
request votes from peers
Receive majority of votes? → become Leader.
Receive heartbeat with higher term? → become Follower.
Election timeout? → start new election.
Election timeout randomized to prevent split votes (e.g., 150–300 ms).
Log Replication
Leader receives client requests, appends to its log, replicates to followers.
client → leader.append(cmd)
leader: append to its log at index i
leader: send AppendEntries to all followers
followers: write to log; reply OK
leader: when majority confirmed → mark index i committed
leader: apply to state machine; respond to client
leader: tell followers of new commit index on next heartbeat
Once committed, the entry is durable — survives leader failure.
Safety
Rules ensure no leader is elected without all committed entries:
- Vote denied to candidate whose log is shorter / older.
- Leader never overwrites committed entries.
- Log matching property: same index + term → same prefix.
Paxos
Older, more general, harder to understand. Multi-Paxos for replicated logs.
Two phases per decision:
- Prepare: proposer picks a number n, asks acceptors to promise not to accept lower n.
- Accept: with a majority of promises, proposer asks acceptors to accept its value.
Leader-based optimizations (Multi-Paxos) skip Phase 1 once a leader is stable — looks similar to Raft in practice.
Paxos paper is notoriously dense. Raft was designed (and named) for understandability.
Raft vs Paxos
| Aspect | Raft | Paxos |
|---|---|---|
| Mental model | Leader + log + elections | Proposers + acceptors + rounds |
| Pedagogy | Clear | Difficult |
| Implementations | etcd, Consul, Kafka KRaft, TiKV, CockroachDB | Chubby, Spanner (some variants), ZooKeeper-Zab (similar) |
| Performance | Comparable to Multi-Paxos | Comparable |
| Variants | Joint consensus, Membership change | Multi-Paxos, Cheap Paxos, Fast Paxos |
Practical guidance: use Raft unless you have a specific reason for Paxos. Most modern systems do.
Where Consensus Is Used
| System | Why |
|---|---|
| etcd, ZooKeeper, Consul | Distributed coordination (locks, leader election, config) |
| Kafka KRaft | Cluster metadata (formerly via ZooKeeper) |
| CockroachDB, Spanner, TiKV | Per-range / per-partition consensus for ACID |
| MongoDB replica set | Modified Raft for primary election |
| Hashicorp products (Nomad, Vault) | Built on Raft |
Quorum
Most operations need a majority of nodes (floor(N/2) + 1).
- 3 nodes → 2 quorum → tolerate 1 failure.
- 5 nodes → 3 quorum → tolerate 2 failures.
- 7 nodes → 4 quorum → tolerate 3 failures.
Larger N tolerates more failures but writes slow (must wait for majority).
Best practice: 3 or 5. Odd numbers avoid split-brain ties.
Failure Modes
| Failure | Result |
|---|---|
| Minority dies | Cluster keeps writing (majority intact) |
| Majority dies | Cluster can't make progress; writes halt until restoration |
| Network partition (split) | Majority side runs; minority is read-only or stops |
| Leader fails | Election picks new leader (~ election timeout latency) |
| Slow leader | Holds back throughput; followers eventually trigger election |
Performance
- Write latency = 1 RTT to quorum (best case).
- Read latency: from leader (cheapest); from follower (eventual).
- Throughput: bounded by leader's I/O.
Scale beyond one Raft group: shard the keyspace, one Raft group per shard (Spanner, CockroachDB do this).
Pitfalls
- Split brain after partition heals: the protocol prevents it, but only if implemented correctly.
- Slow consensus over WAN: round-trip dominates; consider regional clusters.
- Disk fsync stalls: AppendEntries requires fsync; cheap drives kill throughput.
- Membership changes: must use joint consensus or single-node-at-a-time, not raw replacement.
Common Mistakes
| Mistake | Reality |
|---|---|
| Using consensus for everything | Way overkill; only for metadata / leader-coordinated state |
| 2-node "quorum" | Worst of both: needs both up → less availability than 1 |
| Even number of nodes | Same fault tolerance as N-1 odd; waste a node |
| Ignoring election timing | Too short → flaps; too long → outages |
[!NOTE] Consensus is the gold-plated screw of distributed systems. Use it for the small piece of state that must be linearizable across nodes. Build the rest on cheaper primitives (gossip, eventual consistency).
Interview Follow-ups
- "How does Raft handle leader failure?" — Election timeout triggers; candidates emerge; majority elects new leader; minimum disruption to clients (retries on new leader).
- "What is joint consensus?" — Raft's safe membership-change protocol: both old and new configurations active during transition.
- "Difference between Raft and Multi-Paxos?" — In practice, similar performance. Raft has stricter leader behavior; Paxos allows proposals from any node. Raft easier to implement correctly.
Q: How do you elect a leader in a distributed system?
Answer:
Many systems need exactly one node to perform a role (write coordinator, scheduler, cron). Leader election picks that one node and re-picks when it dies. The wrong implementation produces split-brain; the right one uses a consensus-backed lease.
Why You Need a Leader
- Single writer (avoids conflict resolution).
- Coordinator for distributed work (sharding, locking).
- Reduce thundering herd (one node decides, others follow).
Examples: Kafka controller, Raft leader, Kubernetes controller-manager, scheduled-job runner.
Naive Approaches (Don't)
Lowest IP wins. Works until two nodes disagree about who's "up."
Random: two could pick simultaneously.
Acquire a row lock in DB: lock holder is leader. Sort of works; DB outage = no leader.
These have split-brain risk: two leaders run simultaneously, both perform "exclusive" work, data corrupts.
The Right Pattern: Lease + Consensus
Coordinator (etcd / ZooKeeper / Consul)
acquire-lease(key="leader", ttl=30s)
if no one holds it: grant
else: deny
Leader periodically renews the lease (e.g., every 10s, before TTL expiry).
If leader fails to renew → lease expires → another candidate acquires.
The coordinator runs Raft/Paxos internally; the lease grant is linearizable.
etcd Example
lease = etcd.lease(ttl=30)
ok = etcd.put("leader", "node-1", lease=lease.id, prevExist=False)
if ok:
print("I am leader")
while running:
lease.keepalive()
else:
watch("leader") # become leader when current expires
ZooKeeper Pattern
Ephemeral sequential nodes:
each candidate creates /election/node_xxxx (auto-numbered, ephemeral)
the one with the lowest number is the leader
candidates watch the node ahead of them
if that node dies (session closes → ephemeral disappears) → next one becomes leader
ZooKeeper sessions ensure failed nodes are reliably detected.
Kubernetes Lease Object
The standard way in K8s:
apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
name: my-controller
spec:
holderIdentity: pod-1
leaseDurationSeconds: 15
renewTime: "..."
Controllers compete by updating the Lease object. K8s API server (backed by etcd) gives linearizable updates.
Built into client-go and operator SDKs.
Common Mistakes
Split-brain via short network blip.
Leader's link blips, lease expires, follower takes over. Original leader's network recovers — it still thinks it's leader. Now two leaders.
Mitigation: leader checks its lease before each operation; abdicates if lease has expired or been taken. Fencing tokens (see below) make this bulletproof.
Fencing Tokens
Each lease grant comes with a monotonic counter. Downstream systems reject writes from older fence tokens:
acquire-lease → grant with fence=42
→ write to DB with fence=42
later:
acquire-lease → grant with fence=43
→ write to DB with fence=43
old leader (still thinks it's leader) tries to write with fence=42
→ DB rejects (fence < current)
Required for correctness when downstream systems matter (DBs, queues).
Implemented in: etcd revision numbers, ZooKeeper zxid, Kafka epochs.
When Leader Election Fails
- No consensus possible (majority of coordinator nodes down): no leader; system degrades.
- Repeated elections (flapping): usually network instability; increase lease TTL.
- Two leaders simultaneously: bug. Add fencing tokens, verify lease before critical operations.
Performance
- Lease renewal cost: 1 round trip every N seconds (cheap).
- Election after failure: lease TTL + acquire time (~seconds).
- Acceptable for control-plane work; not for hot path.
Use Cases by Frequency
- High-frequency coordination (e.g., per-request leader): wrong tool; restructure.
- Per-job leader (cron, distributed scheduler): yes.
- Per-partition leader (Kafka): each partition has its own.
- Cluster-wide leader (k8s controller, Kafka controller): yes.
Trade-offs
Long TTL: faster failover, lower coordinator load, longer outages.
Short TTL: quicker failure detection, higher coordinator chatter, brief flap windows.
Typical: 15-30 second TTL with 5-second renewal.
Common Mistakes
| Mistake | Fix |
|---|---|
| Self-rolled leader election | Use etcd/ZK; correctness is hard |
| No fencing token at downstream | Two leaders write simultaneously |
| Renewing only on heartbeats with long network gap | Lease expires before renewal arrives; increase margin |
| Multiple unrelated services sharing one leader election | Per-service lease — finer-grained |
[!NOTE] The lesson of every distributed-systems incident report: trust your lease + use fencing tokens. Without them, "we thought we had one leader" is the punchline.
Interview Follow-ups
- "How does Kafka elect the controller?" — In KRaft, Raft itself among the controller quorum. In ZK mode, candidates registered ephemeral znode; first wins; reelection on failure.
- "What's bully algorithm?" — Classic textbook leader election by node ID. Educational; not used in production (no fencing, sensitive to clock skew).
- "Why not just use a DB row lock?" — Works for simple cases; doesn't handle the DB's own failover, can't fence downstream operations.
Q: Distributed locks — how to build them, when to use them, when not to.
Answer:
A distributed lock makes one node "the only one doing X" across a cluster. It's tempting and dangerous in equal measure. Get it wrong and you have data corruption; get it right and you've added latency and a coordinator dependency.
When You Want a Distributed Lock
- Singleton workers (only one runs the migration).
- Per-resource serialization (only one process writes to file X).
- Coordinated leader work.
When You Don't
- Hot path (every request acquires lock → serialization bottleneck).
- Replacing transactions in your DB (use the DB's locking).
- Idempotency (use idempotency keys instead — no coordination needed).
- "Just in case" — every lock is a SPOF you didn't have before.
Implementation Options
1. Redis SETNX (single instance)
SET lock:key unique_value NX EX 30
- NX: only if not exists
- EX 30: TTL 30 seconds
Release:
-- Atomic Lua: delete only if I own it
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
The unique_value (request UUID) prevents Bob from deleting Alice's lock.
Properties:
- Single point of failure (Redis goes down → no lock).
- Lease-based via TTL — handles holder crash.
- Cheap, ~ms latency.
Best for: short-lived locks (< 30 s) where SPOF risk is acceptable.
2. Redlock (multi-instance Redis)
Acquire lock from majority of N independent Redis instances:
for each redis in [r1, r2, r3, r4, r5]:
try acquire with TTL T
if majority acquired AND total time < T:
lock held
else:
release everywhere and retry
Goal: tolerate Redis instance failures.
Famous critique: Martin Kleppmann (2016) argues Redlock lacks correctness guarantees under clock skew / GC pauses. Antirez (Redis author) disagrees. For most use cases, Redlock is fine if you have fencing tokens downstream.
3. etcd / ZooKeeper / Consul
Backed by Raft. Linearizable lease management.
etcd:
lease = etcd.lease(ttl=30)
ok = etcd.put("lock:key", "owner", lease=lease.id, prevExist=False)
if ok:
# I hold the lock
do_work()
lease.revoke()
Properties:
- Linearizable.
- Survives failures up to quorum.
- Higher latency than Redis (~10s of ms).
- Operates as both lease store + watcher.
Best for: correctness-critical locks where a few ms latency is acceptable.
4. Database lock
-- Postgres advisory lock
SELECT pg_try_advisory_lock(hash_key);
-- ... do work ...
SELECT pg_advisory_unlock(hash_key);
-- Or for a row:
BEGIN;
SELECT * FROM resources WHERE id = ? FOR UPDATE;
-- ... do work ...
COMMIT;
Properties:
- DB is already in the trust set.
- Limited by DB capacity.
- No TTL on advisory locks → holder crash holds lock until session ends.
The Lock Holder Dies Problem
Critical: what if the lock holder crashes mid-work?
- Without TTL: lock held forever.
- With TTL: lock expires, someone else takes over. But original holder might still be doing work (slow GC, paused process, network blip). Two holders. Bug.
Solution: fencing tokens.
Fencing Tokens
Lease grant includes a monotonically increasing token. Every downstream operation includes the token. Downstream rejects writes with stale tokens.
Alice acquires lock → fence=10
Alice's GC pauses 30s
Alice's lock expires
Bob acquires lock → fence=11
Bob writes to DB with fence=11
Alice resumes, tries to write with fence=10
DB sees fence < current → reject
Without fencing tokens, distributed locks can't be correct. Period.
Implementing: etcd revision, ZK zxid, Kafka epoch — all support fence-like semantics.
Locking Latency
- Redis single-instance: 1–2 ms.
- Redlock: 5–20 ms (depending on cluster geometry).
- etcd: 10–50 ms.
- DB advisory lock: depends on DB load.
Hot path that needs 1k+ QPS with a lock → wrong design. Restructure to avoid coordination.
Lock Granularity
- Coarse: one lock for all updates to a resource. Simple; reduces concurrency.
- Fine: one lock per row / key. More concurrent; more lock churn.
Match granularity to contention. Lock the smallest scope necessary.
Avoiding Locks Entirely
Better alternatives when applicable:
1. Optimistic concurrency (version columns).
UPDATE row SET ..., version = version + 1
WHERE id = ? AND version = ?
No lock; retry on mismatch.
2. Single-writer per partition (Kafka).
If one entity has one partition, only one consumer processes it. No lock needed.
3. Idempotency.
If retries are safe, you don't need to prevent them.
4. CRDTs.
Mergeable types eliminate conflict resolution.
Common Mistakes
| Mistake | Result |
|---|---|
| No TTL on lock | Holder crashes; lock held forever |
| Releasing lock without ownership check | One process deletes another's lock |
| Single Redis instance lock for critical data | SPOF; use Redlock or etcd |
| No fencing token downstream | Two holders silently corrupt |
| Lock + I/O | Holds lock during slow network call; massive contention |
| Locking around long batch job | Use a state machine, not a lock |
[!NOTE] Distributed locks are a sharp tool. Two-line code with subtle correctness implications. Use them sparingly; prefer designs that don't need them.
Interview Follow-ups
- "How do you implement a lock with retry?" —
acquire()retries on contention with backoff + jitter; give up after max attempts; alert if pattern persists. - "What if a follower in etcd thinks it's leader?" — Raft ensures only the elected leader can perform consistent reads/writes; lease grants are linearizable.
- "Redlock or etcd for production?" — etcd if you need provable correctness; Redlock if you have fencing tokens and Redis is already in the stack.
Q: Two-phase commit (2PC) — and why you should avoid it.
Answer:
2PC is the classical protocol for atomic transactions across multiple participants. It works on paper, fails in production. Modern systems prefer Saga + idempotency over 2PC almost everywhere.
The Protocol
Coordinator + N participants.
Phase 1 (prepare):
coordinator → all participants: PREPARE
participants: do the work, lock resources, write to log
participants → coordinator: VOTE YES or NO
Phase 2 (commit/abort):
if all YES:
coordinator → all: COMMIT
participants: apply changes, release locks
else:
coordinator → all: ABORT
participants: roll back
Each participant is either committed or rolled back; never partial.
Why It Works (In Theory)
Each participant logs its decision before voting. If anyone crashes, on recovery they consult their log and the coordinator to learn the final outcome.
Why It's Bad (In Practice)
1. Blocking on coordinator failure.
If the coordinator dies after Phase 1 + some Phase 2 messages sent but not others, participants are left holding locks waiting for the coordinator to recover. Could be hours.
coordinator says PREPARE to A, B, C
all reply YES
coordinator crashes
A, B, C: locked, waiting for commit/abort
No reliable way to resolve without coordinator. Other transactions wait.
2. Holding locks across a network.
Participant locks rows during PREPARE. Lock duration = network round trip × 2. Cross-DC = hundreds of ms. Throughput plummets.
3. Doesn't scale.
Coordinator is a bottleneck; participants block on each other. Latency = max(slowest participant).
4. Doesn't handle some failures.
If a participant votes YES but crashes before receiving COMMIT, on recovery it must reconcile. Possible, but adds protocol complexity (3PC tried, mostly failed).
Where 2PC Lives Today
- XA transactions in JDBC + JMS: classic 2PC pattern. Performance and operational nightmare; rarely used.
- Internal distributed-DB protocols: Spanner uses 2PC under the hood across shards (with Paxos for durability). Works because Google built it carefully and locks are short.
- CockroachDB, YugabyteDB: similar Spanner-like distributed-SQL approach.
If you find yourself implementing 2PC in app code, stop.
Replacements
1. Saga.
Sequence of local transactions + compensating actions. Eventually consistent; no global lock. See Saga Pattern.
2. Transactional outbox.
Atomic write to DB + outbox in one local transaction. Downstream catches up async. See Transactional Outbox.
3. Distributed SQL.
If you really need ACID across data, use a system designed for it (Spanner, CockroachDB) and let it handle 2PC internally.
4. Single-writer per entity.
If only one node writes to a given entity, no coordination is needed.
Three-Phase Commit (3PC)
Adds a "PRE-COMMIT" phase between PREPARE and COMMIT. Reduces blocking risk; doesn't eliminate it in async networks. Theoretically interesting; not used in practice.
When 2PC Is Acceptable
- Small number of participants (2–3).
- Fast network (LAN, same DC).
- Short critical section.
- Failure tolerance acceptable (you're OK with manual recovery if coordinator dies).
Even then, the design conversation should justify why no alternative works.
Common Mistakes
| Mistake | Reality |
|---|---|
| Cross-microservice 2PC | Operational nightmare; use Saga |
| Long-running transactions with 2PC | Locks held for minutes; system grinds |
| Coordinator as a singleton | SPOF; if you must, replicate via consensus |
| Treating "exactly-once" as the goal | Aim for exactly-once effect via idempotency |
[!NOTE] 2PC fails open in the wrong direction: when in doubt, participants block, not commit. That means any coordinator failure can cause an unbounded outage. Avoid it.
Interview Follow-ups
- "How does Spanner do distributed transactions?" — 2PC within Spanner, but each participant is itself a Paxos group; coordinator failure tolerated by majority. TrueTime narrows the lock window.
- "What's the Saga alternative?" — Sequence of local transactions; each step has a compensation. No global locks; eventual consistency.
- "What did Google's MegaStore / Spanner papers teach us?" — That 2PC + Paxos + clock synchronization + small batches can scale, with massive engineering effort. Most teams don't have that bandwidth.
Q: Distributed clocks — Lamport, vector, hybrid logical.
Answer:
Wall-clock time on different machines disagrees. Distributed systems use logical clocks to reason about event ordering without relying on synchronization. Three flavors: Lamport, vector, hybrid logical.
Why Wall Clocks Fail
Node A's clock: 12:00:00.500
Node B's clock: 12:00:00.450
A: sends message at A's time 12:00:00.500
B: receives at B's time 12:00:00.450 ← appears to receive before A sent it
NTP keeps drift to ~10ms but can't eliminate it. Clocks even go backwards during sync adjustments.
Lamport Clocks
Each process maintains a counter. Bump on event:
on local event: counter += 1
on send msg: counter += 1; attach counter to msg
on receive msg: counter = max(counter, msg.counter) + 1
Properties:
- If event
ahappened-beforeb, thenclock(a) < clock(b). - Reverse not necessarily true —
clock(a) < clock(b)doesn't meana → b. - Total order: ties broken by node ID.
A: counter 0 → 1 (local)
A: counter → 2 (send to B with timestamp 2)
B: receives, sets counter = max(0, 2) + 1 = 3
Useful for total-ordering events into a single log. Used in Cassandra (last-write-wins timestamps), older distributed locks.
Vector Clocks
Each process holds a vector of counters — one per process:
A: [Va, 0, 0]
B: [0, Vb, 0]
C: [0, 0, Vc]
on local event: self[i] += 1
on send msg: self[i] += 1; attach copy
on receive: for each j: self[j] = max(self[j], msg[j]); self[i] += 1
Properties:
a → biffclock(a) ≤ clock(b)element-wise.a || b(concurrent) iff neither is ≤ the other.
Detects concurrent edits — critical for CRDTs, Dynamo-style conflict resolution.
Cost: O(N) size where N = number of nodes. Grows with cluster size.
Hybrid Logical Clocks (HLC)
Combines wall-clock and logical counters:
HLC = (physical_time, logical_counter)
on local event:
if physical_time > hlc.physical:
hlc = (physical_time, 0)
else:
hlc.logical += 1
on send: attach hlc
on receive (msg):
hlc.physical = max(local_physical, msg.physical, hlc.physical)
hlc.logical = max(... computed appropriately) + 1
Properties:
- Close to wall-clock time (humans recognize timestamps).
- Captures happened-before via the logical part.
- Compact (16 bytes typical).
Used in CockroachDB, YugabyteDB, MongoDB causal consistency.
Comparison
| Clock | Order | Size | Use |
|---|---|---|---|
| Lamport | Total (with tiebreak) | 8 bytes | Single-log ordering, LWW |
| Vector | Partial (concurrent detectable) | O(N) | CRDT, Dynamo conflict resolution |
| HLC | Near wall-clock + happened-before | 16 bytes | Distributed SQL (Cockroach), causal stores |
Last-Write-Wins (LWW)
Cassandra and others: every write tagged with timestamp; on conflict, larger timestamp wins.
Problem: relies on wall-clock. Clock skew → "wrong" write wins. Lost data.
Mitigations:
- NTP discipline (still not perfect).
- HLC instead.
- Application-level vector clocks for conflict detection (then user resolution).
TrueTime (Google Spanner)
Spanner uses GPS + atomic clocks across DCs to bound clock uncertainty. TT.now() returns [earliest, latest] interval.
Commit waits out the interval before exposing the write — guarantees external consistency without coordination.
Hardware + careful engineering. Most don't replicate it.
Causal Consistency
A consistency model implementable with vector clocks or HLC:
op A → op B (causal)
all replicas see A before B
concurrent ops may be seen in either order
Strong enough for: collaborative editing, social feed ordering, replicated KV stores.
Idempotency vs Time
For idempotency by event ID, you don't need synchronized clocks — just unique IDs (Snowflake, UUID).
For ordering of events from the same producer, clocks suffice (just local). For cross-producer ordering, you need logical clocks.
Common Mistakes
| Mistake | Result |
|---|---|
| Using wall-clock for cross-process ordering | Skew bugs, dropped writes |
| Vector clocks at scale (thousands of nodes) | Vectors too big; switch to HLC |
| LWW on financial data | Silent data loss when clocks disagree |
| Trusting NTP for sub-second correctness | NTP isn't a guarantee; use logical clocks |
[!NOTE] Wall clocks are advice; logical clocks are truth in distributed systems. If your design relies on "two machines agree on the current time," reconsider.
Interview Follow-ups
- "What's the difference between linearizability and causal consistency?" — Linearizable enforces real-time order on a single object globally. Causal enforces happened-before only; concurrent ops have flexible order.
- "How do you detect concurrent writes in Dynamo?" — Each write carries a vector clock; reads return all versions whose vectors are incomparable; application or LWW resolves.
- "How does Spanner avoid running 2PC for every read?" — TrueTime-bound timestamps + serializable snapshot isolation; reads from any replica at a given timestamp are linearizable.
Q: Unique ID generation — UUID, Snowflake, ULID, KSUID. Pick one.
Answer:
Every distributed system needs IDs. The naive auto_increment doesn't work across nodes; UUID v4 is random but unsorted. Modern choices like Snowflake, ULID, UUIDv7 combine uniqueness, sortability, and decentralization.
Requirements for a Good ID
- Unique: no collisions across distributed generators.
- K-sortable: rough time-ordering helps DB indexes and pagination.
- Compact: 16 bytes is fine; 64-bit is great.
- Stateless generation: no coordination per ID.
- Opaque externally: don't leak business semantics.
Options
| Scheme | Bits | Sortable | Coordination | Notes |
|---|---|---|---|---|
auto_increment | 32–64 | yes | DB row lock | Centralized |
| UUID v1 | 128 | partial (MAC-time) | none | Leaks MAC address |
| UUID v4 | 128 | NO (random) | none | Universal default but unsortable |
| UUID v7 | 128 | yes (time-ordered) | none | New; modern default |
| Snowflake | 64 | yes | machine-id config | Twitter's scheme |
| ULID | 128 | yes | none | URL-safe encoding |
| KSUID | 160 | yes | none | Segment's scheme |
| NanoID | configurable | no | none | URL-safe random; like UUIDv4 |
UUID v4
hex: f47ac10b-58cc-4372-a567-0e02b2c3d479
128 bits, 122 of randomness. Collision probability negligible.
Cons:
- Not time-ordered → bad for DB index locality (random inserts → page splits in B-tree).
- Larger than 64-bit IDs.
- Hard for humans to glance.
UUID v7
018f8d34-... (first 48 bits = ms timestamp; rest random)
Released as draft-2024, in mainstream libs (Postgres uuid_generate_v7() via extensions).
Pros:
- 128 bits like UUID.
- Time-ordered: index locality, easy "newest first" sort.
- Drop-in replacement for UUID v4.
The modern default for new systems. Combines UUID's universality with sortability.
Snowflake
64-bit ID:
| 1 sign | 41 timestamp (ms) | 10 machine_id | 12 sequence |
- Timestamp: ms since epoch (Twitter's: 1288834974657).
- Machine ID: configured per node (10 bits = 1024 machines).
- Sequence: counter per ms per node.
Properties:
- 64-bit, compact.
- K-sortable (by timestamp).
- 4096 IDs/ms/machine = 4M IDs/sec/machine.
- Requires unique machine IDs.
Used by Twitter, Discord, Instagram (similar scheme).
Bookkeeping:
- Machine IDs from etcd / ZK / DB.
- Clock skew handling: refuse to generate if clock goes backwards.
ULID
128-bit, lexicographically sortable:
01H6FXM34VWCEK0NHJRA94QSEW
^^^^^^^^^^ Crockford-base32 timestamp (48 bits)
^^^^^^^^^^^^^^^^ Random (80 bits)
Pros:
- Time-ordered.
- URL-safe.
- No coordination.
- Same size as UUID.
Library: lots; e.g., ulid in many languages.
KSUID (Segment)
20 bytes (160 bits):
27-char base62: 0o5sQohJzs8WW3hjwOyaCfaTeZB
Like ULID but bigger (more random bits → effectively no collision risk at any scale).
Sortability and DB Performance
B-tree primary key with random IDs (UUID v4):
- Each insert → random page in the index.
- Pages split, fragment, bloat.
- Cache locality poor.
- Insert-heavy workloads slow.
Sortable IDs (UUIDv7, ULID, Snowflake, auto_increment):
- Each insert → end of index (last page).
- Sequential writes; cache-friendly.
- 2–10× faster insert throughput in benchmarks.
For new systems: use sortable IDs.
Cross-Region / Multi-DC
- Snowflake: assign machine IDs per region (e.g., first 3 bits = region).
- UUIDv7 / ULID: no coordination at all.
- DB-managed auto_increment: doesn't work across regions.
Don't Expose Internal IDs
Sequential / sortable IDs leak:
- Total count (
/orders/12345→ estimate Twitter's order volume). - Creation order (privacy / business).
For external APIs: use opaque IDs (UUID or signed hashids). Internal: keep the sortable ones.
Choosing in 30 Seconds
External API URL identifier → UUIDv4 or signed hashids
Internal primary key, new → UUIDv7 or ULID
Already on Snowflake → keep
Distributed numbering at extreme scale (10M+ IDs/sec) → Snowflake-class
URL-friendly string → ULID or NanoID
Common Mistakes
| Mistake | Reality |
|---|---|
| UUID v4 PK on a big table | Index bloat, slow inserts |
| Snowflake with same machine ID on multiple nodes | Duplicate IDs |
| Exposing auto_increment in URLs | Leaks order volume |
| Generating IDs in client code | Trust issues; clients can forge |
| Mixing schemes within one table | Painful migrations |
[!NOTE] For 2025 greenfield work, default to UUIDv7 unless you specifically need 64-bit (Snowflake) or URL ergonomics (ULID). UUIDv7 is what UUID v4 should have been.
Interview Follow-ups
- "How does Twitter generate IDs?" — Snowflake; each datacenter has a service that emits IDs with its assigned machine_id range.
- "What if a node's clock jumps backward?" — Refuse to generate (Twitter's choice); panic vs reject; tolerate small skew via NTP.
- "How do you migrate from int64 to UUID without downtime?" — Add UUID column nullable; backfill; flip writes to populate both; switch reads; drop old.
Q: Gossip protocols — what they are, where they're used.
Answer:
Gossip (epidemic) protocols spread state through a cluster by periodic peer-to-peer exchanges. They're decentralized, fault-tolerant, and scale to thousands of nodes — but eventually consistent.
How It Works
every second, each node:
pick K random peers
exchange state digests
update local view with newer entries
Information spreads exponentially: O(log N) rounds to reach all nodes.
round 0: 1 node knows
round 1: K nodes know
round 2: K² nodes
...
round log_K(N): all nodes know
For K=3, N=1000: ~7 rounds.
Properties
- Decentralized: no leader.
- Fault-tolerant: lose any node; gossip continues.
- Scalable: each node talks to constant fanout regardless of cluster size.
- Eventually consistent: not linearizable.
- Probabilistic delivery: with overwhelming likelihood.
Use Cases
| System | What it gossips |
|---|---|
| Cassandra | Cluster membership, token ranges, schema |
| Redis Cluster | Cluster topology, slot ownership |
| Consul | Node health (Serf library) |
| HashiCorp Nomad | Node + job status |
| Akka Cluster | Cluster state |
| Bitcoin / Ethereum | Transaction + block propagation |
| Dynamo / Riak | Membership |
Anti-Entropy
The reconciliation step when two nodes exchange data and find disagreement:
Node A: { keyX: v3 }
Node B: { keyX: v2 }
A → B: "I have keyX = v3"
B: updates to v3
For large state, exchanging hashes (Merkle trees) reduces traffic. Cassandra uses this for SSTable repair.
SWIM Protocol
Specialized membership gossip (used by Consul, HashiCorp Serf):
- Failure detection: probe random peer; if no reply, ask other peers to probe.
- Dissemination: piggyback membership events on probes.
SWIM gives bounded failure-detection time + scalable.
Push vs Pull vs Push-Pull
- Push: I send my state to a peer. Effective when info is new.
- Pull: I ask the peer for theirs. Effective when info is established.
- Push-pull: both directions in one exchange. Most common.
Convergence Time
With K=3 peers per round and 1-second interval:
- 100 nodes: ~5 seconds to fully converge.
- 1000 nodes: ~7 seconds.
- 10000 nodes: ~9 seconds.
The "logarithmic-in-N" property is what makes gossip suitable for large clusters.
Failure Modes
| Failure | Result |
|---|---|
| Network partition | Two halves diverge until partition heals; converge on heal |
| Node restart | Picks up state by gossiping with peers |
| Slow node | Information takes longer to reach it; cluster continues |
| Byzantine node | Gossip protocols don't tolerate this; need different protocol |
Anti-Entropy vs Rumor-Mongering
Two modes:
- Anti-entropy: continuously reconcile entire state. Bandwidth-heavy; always converges.
- Rumor-mongering: spread only new info; stop after seeing it sufficient times. Bandwidth-light; small chance of missing.
Production systems mix: rumor for events, periodic anti-entropy for safety.
Limitations
- Eventually consistent: never use for "must be ordered" decisions.
- Bandwidth: chatty on big clusters; tune intervals.
- No durability: lost on restart unless persisted separately.
- Byzantine tolerance: only via stronger protocols (PBFT etc.).
Common Mistakes
| Mistake | Reality |
|---|---|
| Using gossip for transactions | Doesn't give consistency; use Raft |
| No version on gossiped messages | Can't detect newest; use vector clocks or timestamps |
| Too frequent gossip | Bandwidth spike; tune interval |
| Forgetting fanout / lack of bound | Gossip messages exponentially balloon if not gated |
[!NOTE] Gossip is the right tool for soft state: "who's in the cluster," "what's the cluster's view of itself," "node health." It's the wrong tool for any decision that must be exact.
Interview Follow-ups
- "Why does Cassandra use gossip?" — Membership + topology shared across all nodes; node count can be hundreds; no central coordinator desired.
- "Difference between gossip and pub-sub?" — Pub-sub: broker-centric, push only, often ordered. Gossip: peer-to-peer, eventual, no broker.
- "How fast can gossip detect a node failure?" — Configurable via SWIM probe intervals; typically 5–30 seconds depending on tuning.
Q: Redundancy, failover, multi-AZ/region — designing for failure.
Answer:
Every component will eventually fail. Reliability comes from redundancy (more than one of each thing) + failover (automatically reroute when one fails). Cost rises non-linearly: single-AZ → multi-AZ → multi-region → globally active-active.
Failure Domains
Anything that fails together is one failure domain:
- A single server.
- A rack.
- An availability zone (AZ).
- A region.
- A cloud provider.
Design so that one failure domain dying doesn't take down your service.
Single AZ (Not Enough)
- One AZ → AWS or GCP outage in that AZ takes you down (happens yearly).
- Not redundant against ZA/networking issues.
Multi-AZ
Spread across 2–3 AZs in the same region:
- Latency between AZs: ~1–2 ms.
- Cross-AZ bandwidth typically free or cheap.
- Survives single-AZ outage.
Standard for production:
LB (multi-AZ)
│
├── app servers across AZ1, AZ2, AZ3
▼
DB primary in AZ1, replicas in AZ2, AZ3
Multi-Region
For regional disasters or compliance (data residency):
- Latency cross-region: 50–150 ms.
- Egress between regions: expensive.
- Cross-region sync replication: latency penalty.
Patterns:
1. Active-Passive (DR). Primary region serves all traffic. Standby is hot or warm. Failover triggers (manually or automatically) on regional outage.
RPO (data loss window) = replication lag. RTO (recovery time) = how long to switch.
2. Active-Active. Both regions serve traffic. Writes go to nearest. Replication is async.
Requires conflict resolution (multi-leader semantics). Best for partitioned data: each region owns its own user base.
3. Hot Standby with Read Replicas. Reads in any region; writes only in primary. Higher write latency for distant users.
Failover Mechanics
1. Detect: health checks fail consecutively (alert + auto-detect).
2. Verify: not a transient blip; confirm region is unreachable.
3. Promote standby DB to primary.
4. Update DNS / routing to point at standby.
5. Bring up app tier (or it was already warm).
6. Drain residual connections to old primary (if reachable).
Trade-off: aggressive failover causes flapping; conservative loses time.
DNS-Based vs Anycast Routing
DNS-based:
- TTL determines failover speed (30–60s minimum).
- Cached by resolvers, ignores TTL sometimes.
Anycast:
- Same IP advertised from multiple PoPs via BGP.
- Network routes to topologically nearest.
- Failover is sub-second.
- Used by Cloudflare, AWS Global Accelerator.
Data Replication Choices
| Mode | RPO | RTO | Cost |
|---|---|---|---|
| Sync replication cross-region | Zero | Seconds | Latency on every write |
| Semi-sync (one remote replica) | Tiny window | Seconds | Some latency |
| Async cross-region | Replication lag | Seconds | No write latency |
Sync cross-region is brutal for write latency (100+ ms each). Most use async + accept the small RPO.
Cell-Based Architecture
Split users into cells, each a complete independent stack:
Cell 1: users [0–10M] → own DB, app, cache
Cell 2: users [10M–20M] → own DB, app, cache
...
Outage in Cell 5 doesn't affect Cell 3. Blast radius bounded.
Used by AWS, Stripe, Slack, Salesforce.
Chaos Engineering
You can't claim reliability without testing it. Chaos engineering:
- Inject failures in production (Netflix Chaos Monkey).
- Game-day simulations: take down an AZ during business hours.
- Validate runbooks: practiced or theoretical?
If you've never tested failover, it doesn't work.
Graceful Degradation
When dependencies fail, degrade rather than crash:
- Cache unavailable → fall back to DB (slower, still works).
- ML model service down → fall back to popular items.
- Email send fails → queue for retry.
See Graceful Degradation.
Per-Layer Redundancy
| Layer | Approach |
|---|---|
| LB | Multi-AZ managed LB (ALB, NLB) |
| App tier | Multi-AZ, autoscaling, healthchecked |
| Cache | Redis Cluster across AZs; expect a portion of data lost on AZ loss |
| DB | Primary in one AZ, sync replica in another |
| Queue | Multi-AZ Kafka cluster |
| Storage | S3 (cross-AZ by default), Glacier for backup |
| CDN | Global by definition |
Failover Tests (Game Days)
Periodically practice:
- Kill the primary DB; measure failover time.
- Take down an AZ at the network level.
- Force a region failover; measure data loss.
- Verify runbooks work end-to-end.
Common Mistakes
| Mistake | Reality |
|---|---|
| "We have backups" = "we're highly available" | Backups protect against logical disasters; not the same as redundancy |
| 2 instances in one AZ | Both die when the AZ dies |
| No tested failover | "Will work in theory" |
| Manual failover with no runbook | Outage longer than necessary |
| Replication lag never alerted | Lossy failover surprises |
[!NOTE] Reliability is a function of testing, not architecture diagrams. Until you've taken down each tier in production and seen the system recover, you don't know whether it works.
Interview Follow-ups
- "What's your RPO and RTO target?" — Per service, per tier. Recovery time vs data loss is a budget you spend.
- "How do you avoid cross-region sync writes for an active-active app?" — Partition by user/region; each region writes its own partition; replicate async.
- "How do you do cross-region capacity planning?" — Each region sized to handle its share + N/(N-1) when peer regions are out, to absorb failover traffic.
Q: Retries, backoff, jitter — done right.
Answer:
Retrying failed requests is essential. Done naively, retries amplify outages — the "retry storm." The recipe: exponential backoff + jitter + cap + circuit breaker.
What to Retry
| Failure | Retry? |
|---|---|
| Network timeout | Yes |
| 5xx response | Yes (probably transient) |
| 429 (rate limited) | Yes, respect Retry-After |
| 503 (service unavailable) | Yes, respect Retry-After |
| 4xx (400, 401, 404, 422) | No (client error) |
| Connection refused | Yes |
Retry idempotent operations only. Non-idempotent: include Idempotency-Key.
Naive Retry (Don't)
while not success:
response = call()
if 5xx: continue # immediate retry
If 100 clients all retry immediately, the server gets 100× load instantly. The thing that caused the failure (overload) gets worse.
Exponential Backoff
attempt 1: wait 100 ms
attempt 2: wait 200 ms
attempt 3: wait 400 ms
attempt 4: wait 800 ms
...
attempt N: wait min(2^N × base, max_backoff)
Spreads retries over time. Reduces aggregate load.
Add Jitter
Without jitter, all clients backoff identically and retry at the same instant:
delay = 2^attempt × base
Becomes:
delay = random(0, 2^attempt × base) # full jitter
Or AWS's recommendation:
delay = min(max_backoff, random(base, 2^attempt × base)) # decorrelated jitter
Spreads retries across time; eliminates synchronized retry storms.
The Full Recipe
def call_with_retry(func, max_attempts=5, base=0.1, max_backoff=10):
for attempt in range(max_attempts):
try:
return func()
except RetryableException as e:
if attempt == max_attempts - 1:
raise
delay = min(max_backoff, base * 2**attempt * random())
time.sleep(delay)
Respect Retry-After
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Server tells you when to come back. Honor it.
if response.status == 429:
wait_sec = int(response.headers.get("Retry-After", 1))
sleep(wait_sec)
Cap the Retries
Infinite retries → resource leak (connections, threads) when downstream is broken.
max_attempts = 5 # typical for synchronous user-facing requests
max_attempts = 20+ # for background jobs
After cap, propagate failure upward. Don't pretend it succeeded.
Retry at Which Layer?
Multiple retry layers stack and amplify:
client retry × LB retry × app retry × HTTP client retry = 16+ attempts per user click
Bad. Pick one layer to retry. Other layers should fail fast.
Typical: HTTP client library retries (with reasonable defaults); other layers don't.
Circuit Breaker
After many failures in a row, stop calling for a while:
state: CLOSED (calls allowed) → OPEN (calls fail fast) → HALF_OPEN (probe a few)
See Circuit Breaker.
Pair with retry: retry helps transient failures; circuit breaker handles sustained failure.
Hedged Requests
For latency optimization, not failure handling:
t=0: send request to A
t=p95: still no response → send to B
t=any: take first response, cancel other
Used by Google for tail-latency reduction. Doubles load on slow operations.
Retry Storm
Classic outage pattern:
Service A is slow.
Clients of A retry.
A gets 3× normal load.
A becomes slower / starts failing.
Clients retry more.
A is now totally overwhelmed.
Mitigations:
- Aggressive timeouts (fail fast).
- Limited retries.
- Circuit breaker.
- Backoff + jitter.
- Load shedding by A itself.
Retries vs Backoff in Real Systems
HTTP client (e.g., reqwest, axios):
retries: 3
backoff: exp with jitter
max delay: 5s
Async queue (Kafka consumer):
retries: infinite (or N to DLQ)
backoff: exp with cap 5 min
DB driver:
retries: 2-3
on serialization-failure: retry with backoff
Idempotency Is Mandatory
Retries that aren't idempotent corrupt state.
For HTTP POST: Idempotency-Key header → server dedups.
For DB writes: unique constraint on natural key.
For messages: dedup by event_id.
See Idempotency.
Common Mistakes
| Mistake | Reality |
|---|---|
| Retry immediately (no backoff) | Storm amplifies outage |
| Same delay every attempt | Synchronized storm |
| No max attempts | Resource leak |
| Retry POSTs without idempotency key | Double-charges, duplicate orders |
| Multiple layers retrying | Multiplies load |
| Retry on 4xx | Will never succeed |
[!NOTE] The most operationally important retry pattern is "exponential backoff with full jitter." Memorize it; apply it everywhere. The default in libraries is usually NOT this — set it yourself.
Interview Follow-ups
- "What's the difference between full and decorrelated jitter?" — Full jitter samples uniformly over the backoff window. Decorrelated jitter widens with each attempt; better avoids synchronized retries.
- "How do you handle retries with stream processors?" — Tiered retry topics (Kafka) — failed events flow into delay topics; eventually DLQ.
- "What's a 'retry budget'?" — Cap on the rate of retries the system as a whole tolerates. If retries exceed N% of total traffic, stop retrying. Used by Envoy.
Q: Circuit breaker, bulkhead, timeout — failure-isolation patterns.
Answer:
Three patterns from the fault-tolerance toolbox, each protecting different things:
- Timeout: bound how long you wait.
- Circuit breaker: stop calling a failing dependency.
- Bulkhead: isolate resources so one failure doesn't sink everything.
Timeout
The simplest, most under-used pattern.
client.get(url, timeout=2s)
db.query(sql, timeout=500ms)
Without timeouts:
- Slow downstream stalls your threads.
- Threads exhaust → can't serve any request.
- Cascading failure.
Pick timeouts by:
- Median + 3× standard deviation of normal.
- p99 of healthy + margin.
- Aggressive enough that a stuck downstream doesn't wait forever.
Default: 1–3 seconds for HTTP; 100–500 ms for in-DC RPC.
Circuit Breaker
State machine:
CLOSED (normal):
pass calls through; track success/failure ratio
if failure rate > threshold → OPEN
OPEN:
fail fast — return error without calling
after wait period → HALF_OPEN
HALF_OPEN:
pass a few probe calls
all succeed → CLOSED
any fail → OPEN
Why: when downstream is broken, retrying is wasteful AND amplifies the outage. Stop calling.
Library implementations: Hystrix (deprecated), resilience4j (JVM), polly (.NET), sony/gobreaker (Go), opossum (Node).
Configuration:
failure_threshold: 50% over last N calls.sliding_window: e.g., 100 calls or 10 seconds.open_duration: 30 seconds typical.half_open_probes: 5 calls.
Bulkhead
Borrowed from ship design: walls between sections so flooding one doesn't sink the ship.
In services: isolate resources per dependency.
threadpool[orders]: 100 threads
threadpool[catalog]: 50 threads
threadpool[email]: 20 threads
Catalog goes slow → only catalog threads block; orders thread pool unaffected.
Without bulkheads, all calls share one pool. Catalog slowness starves orders.
Variants:
- Thread pool per dependency (Hystrix style).
- Semaphore per dependency (lighter; no thread overhead).
- Connection pool per dependency (DB, HTTP).
Combined Pattern
client → timeout → circuit breaker → bulkhead → call dependency
- Bulkhead caps concurrency.
- Circuit breaker fails fast when dependency unhealthy.
- Timeout bounds individual call duration.
All three together = isolation depth.
Implementation Example (Resilience4j)
@CircuitBreaker(name = "paymentsApi", fallbackMethod = "fallback")
@TimeLimiter(name = "paymentsApi")
@Bulkhead(name = "paymentsApi")
@Retry(name = "paymentsApi")
public CompletableFuture<PaymentResp> charge(ChargeReq req) {
return client.post(...);
}
PaymentResp fallback(ChargeReq req, Throwable t) {
return PaymentResp.queued(req);
}
Order matters: outer to inner is Retry → Breaker → RateLimiter → Bulkhead → TimeLimiter.
Observability
- CB state: per dependency, time spent in each state.
- Bulkhead saturation: in-use vs max permits.
- Timeout rate: how often timeouts fire.
Alert on:
- CB in OPEN state for > N minutes.
- Bulkhead near saturation.
- Timeout rate > threshold.
Choosing Thresholds
Too tight: false-positive opens during normal load spikes. Too loose: opens too late, damage done.
Start at 50% failure rate over a meaningful window (50–100 calls). Tune from data.
Common Mistakes
| Mistake | Reality |
|---|---|
| No timeout (uses library default) | Default is often "wait forever" |
| Single shared thread pool | Bulkhead missing; one bad dep takes everything |
| Circuit breaker on 4xx errors | 4xx is the caller's bug; doesn't indicate downstream broken |
| Open CB without fallback | Just returns errors; might as well have failed slowly |
| Same CB for hot path + background | Open CB on background work kills user-facing requests |
When Not To Use Each
Timeout: always use. There's no excuse.
Circuit breaker: skip for non-critical path that you'd retry anyway. Don't break on rare network blips.
Bulkhead: overkill for monoliths with a single dependency. Required for services calling many.
Pattern Composition for Production Service
Per dependency:
Bulkhead: 50 concurrent permits
Timeout: 2 seconds
Circuit breaker: 50% failure over 100 calls / 30s window
Retry: 3 attempts with jitter (only on idempotent)
Fallback: cached response or sensible default
Track each layer's metrics.
[!NOTE] These patterns aren't magic — they protect against the specific failure of "downstream slow / failing." For other failures (bug in your service, data corruption), they don't help. Mix with health checks, runbooks, capacity planning.
Interview Follow-ups
- "What's the difference between a circuit breaker and a rate limiter?" — Breaker reacts to failure rate. Limiter caps QPS regardless of success. Different protections, often used together.
- "How does a circuit breaker know when to close?" — HALF_OPEN state allows a few probes; success → CLOSED; failure → back to OPEN.
- "What's load shedding?" — Service deliberately rejects requests when overloaded (often based on queue size or RT). See Graceful Degradation.
Q: Idempotency — what it is, why it's mandatory, how to enforce it.
Answer:
An operation is idempotent if performing it multiple times has the same effect as once. In a world of retries, networks that lie, and at-least-once delivery, idempotency isn't optional — it's the only way to guarantee correctness.
Why It Matters
Networks fail. Clients retry. Brokers redeliver. Without idempotency:
- Double-charges.
- Duplicate orders.
- Counters off by 5×.
- Email sent twice.
- Records inserted N times.
Idempotency turns at-least-once delivery into exactly-once effect.
Naturally Idempotent Operations
SET balance = 100: same value → same state.DELETE WHERE id = 5: gone is gone.PUT /resource/123(replacement).
Naturally NON-Idempotent
INCREMENT count: each call adds.INSERT new row: each call adds a row.SEND email: each call sends.POST /charges(creates): each call creates.
These need explicit dedup.
Patterns to Make Operations Idempotent
1. Idempotency Keys
Client sends a unique key per logical operation:
POST /charges
Idempotency-Key: 8a4b8c3e-...
{ "amount": 100 }
Server stores result keyed by this. Replay returns the cached result.
def charge(req, idempotency_key):
cached = store.get(idempotency_key)
if cached:
return cached.response
result = do_actual_charge(req)
store.put(idempotency_key, result, ttl="24h")
return result
The key is client-generated, scoped to (account, key). Same key → same result.
See Idempotent REST APIs.
2. Unique Constraints in DB
CREATE TABLE orders (
id UUID PRIMARY KEY,
client_order_id UUID UNIQUE, -- client-provided
...
);
INSERT INTO orders ... ON CONFLICT (client_order_id) DO NOTHING;
Duplicate insert → no-op via constraint.
3. Conditional Updates
UPDATE accounts SET balance = balance - 100
WHERE id = ? AND balance >= 100 AND last_tx_id <> ?
Use a last_tx_id or version field; idempotent because second call doesn't match.
Or simpler with optimistic concurrency:
UPDATE row SET ..., version = version + 1
WHERE id = ? AND version = ?
4. Event Sourcing / Inbox
Consumer dedups by event ID:
INSERT INTO inbox (event_id) VALUES (?) ON CONFLICT DO NOTHING;
-- if conflict: ignore
-- else: apply
Pairs with at-least-once delivery to give exactly-once effect.
Idempotency Storage
Where to keep the key → result mapping:
| Store | Trade |
|---|---|
| Same DB as data (transactional) | Strongest correctness; same TX |
| Redis (TTL'd) | Fast; loses on Redis crash |
| Dedicated idempotency service | Reusable across services; another dep |
For payments: same DB as the financial data is the only correct answer.
TTL on Idempotency Records
Records grow forever otherwise. Stripe's choice: 24 hours.
After TTL, a new replay with the same key is treated as a new request. Acceptable because retries usually happen within minutes.
For longer retention (audit), keep a compact log separately.
HTTP Methods and Idempotency
Per spec:
- GET, PUT, DELETE: idempotent.
- POST, PATCH: not.
In practice, POST is the action that creates side effects — and the one that absolutely needs Idempotency-Key.
Async Pipelines
Same principle in event-driven:
producer → at-least-once delivery → consumer
consumer dedups by event_id
Without this, every retry = duplicate effect.
Exactly-Once vs Idempotent
"Exactly-once delivery" = impossible over an unreliable network (Two Generals Problem).
"Exactly-once effect" = achievable: at-least-once delivery + idempotent consumer.
The difference is critical in interviews. Don't say "exactly-once delivery" with a straight face.
Common Mistakes
| Mistake | Reality |
|---|---|
| Trusting client to never retry | Networks ensure they will |
| Idempotency by hashing payload | Two unrelated requests can collide; use explicit key |
| Storing idempotency record in cache only | Cache eviction → duplicate processing |
| Idempotency record outside the business transaction | Race: business write succeeds, idempotency record fails → next retry duplicates |
| No TTL | Storage grows forever |
Validation Pattern
Idempotency record + atomic business write:
BEGIN;
INSERT INTO idempotency_records (key, status='in_progress', client_id=?)
VALUES (?, 'in_progress', ?)
ON CONFLICT (key) DO NOTHING;
-- if conflict: return existing record's response
INSERT INTO orders (...);
UPDATE idempotency_records SET status='completed', response=? WHERE key=?;
COMMIT;
Returns same response on retry; never creates two orders.
[!NOTE] Every external write endpoint should accept
Idempotency-Key. Every async consumer should dedup by event ID. Build it into your framework defaults; otherwise it gets forgotten and bites later.
Interview Follow-ups
- "What if two distinct operations happen to use the same key?" — Scope keys per (tenant, client, request). Or accept "first wins" semantics; document it.
- "How do you handle slow first-call + concurrent retry?" — Insert idempotency record at "in_progress" state with TTL; second call sees in_progress → return 409 or wait.
- "What's the difference between idempotency and 2PC?" — 2PC tries to make multiple steps atomic. Idempotency tries to make one step safe-to-replay. The latter is much cheaper.
Q: Graceful degradation and load shedding — when failure beats unavailability.
Answer:
Under stress, a system can do one of three things:
- Maintain full service (best — only if capacity allows).
- Degrade gracefully (serve a reduced version of the feature).
- Crash / hang (worst — affects everyone).
The pattern: serve a worse experience to many rather than fail a few completely.
Graceful Degradation Examples
| Original feature | Degraded version |
|---|---|
| Personalized recommendations | Popular items |
| Live cart total | Last known total |
| Real-time fraud check | Async fraud check (allow now, review later) |
| Full search | Cached / popular search results |
| Rich profile page | Bare-bones with name + avatar |
| ML-ranked feed | Chronological feed |
User sees something. They don't see an error.
Load Shedding
Reject excess requests at the entry point to protect the system from collapse.
healthy: accept all
degraded: accept critical, reject low-priority
overloaded: accept only health checks; return 503
Mechanism:
- Queue depth check at gateway: if > N, return 503.
- Per-tenant quotas: drop excess from one bad actor.
- Adaptive load shedding: shed proportional to observed latency.
Drop early, drop fast. Letting requests queue makes everyone slow.
Tiered Response
Categorize requests:
- Critical: user-paying actions, security, authentication. Never shed.
- Important: core feature use. Shed last.
- Optional: analytics, marketing, recommendations. Shed first.
Tag at the gateway / load balancer.
Adaptive Concurrency
Borrowed from TCP: AIMD (Additive Increase, Multiplicative Decrease).
in-flight requests = N
observe response time
if RT good: N += 1
if RT bad: N = N × 0.7
Auto-tunes capacity per downstream. Used by Netflix concurrency-limits, Envoy adaptive concurrency.
Failure Modes for Dependencies
When dependency X fails:
| Strategy | Description |
|---|---|
| Fallback to cache | Return last-known-good |
| Fallback to default | Return safe / empty value |
| Async deferral | Queue, retry later |
| Disable feature | Render UI without the dep |
| Hard fail | Only when no fallback makes sense |
Circuit breaker + fallback is the typical implementation.
Cache-as-Fallback
def get_recommendations(user):
try:
return ml_service.recommend(user)
except ServiceUnavailable:
return popular_items_cache.get()
When ML service is broken, return popular items instead. UX slightly worse; service still works.
"Read-Only Mode"
For DB writes: when primary unhealthy, serve reads from replica; reject writes with clear UX.
GET /users/me → works (replica)
POST /orders → 503 "we're temporarily unable to take new orders, please retry"
Better than crashing.
Feature Flags as Kill Switches
Per-feature toggles let you disable a broken feature without redeploying:
if flag("recommendations_enabled"):
show_recs(user)
Flip the flag → recommendations disabled across all instances within seconds.
Used in incidents to isolate damage.
Backpressure as Degradation
When a downstream is slow, applying backpressure to clients (return 429 with Retry-After) is degradation:
client should retry later
service stays alive
See Backpressure.
Communicating Degradation
Don't pretend everything's fine:
401 → "you're not logged in"
503 → "high traffic, retry in 30 seconds"
"Some features temporarily unavailable" banner
Users tolerate degradation if you tell them. They don't tolerate mystery slowness.
Detection / Triggers
- Latency > threshold.
- Error rate > threshold.
- Queue depth > N.
- Downstream circuit breaker open.
- Manual override (oncall flips a flag).
Automated triggers good for fast response; manual triggers for cases that need judgment.
Recovery
When pressure subsides, re-enable degraded features:
- Slow ramp (canary back to full).
- Test each tier as it comes back.
- Don't restore instantly — that often re-triggers the original issue.
Common Mistakes
| Mistake | Reality |
|---|---|
| Hard fail on dependency outage | Unnecessary user-visible breakage |
| Cache forever as "fallback" | Stale data masquerading as live |
| No fallback decision made until incident | Engineers panic-decide; bad outcomes |
| Degraded mode rarely tested | Doesn't work when needed |
| Pretending it's fine | Users lose trust |
[!NOTE] Design degraded modes during peace time, not during incidents. The question to ask of every dependency: "if this is down, what does the user see?" If the answer is "an error," design something better.
Interview Follow-ups
- "How do you decide between failing and degrading?" — If the action is critical (payment), fail clearly. If it's auxiliary (recs), degrade silently.
- "How do you test graceful degradation?" — Game days; chaos engineering injects dependency failures and verifies UX.
- "How do you avoid 'always degraded' state?" — Alert on degradation duration; force a postmortem if a fallback fires for more than N minutes.
Q: CQRS — Command-Query Responsibility Segregation.
Answer:
CQRS splits the write model (commands) from the read model (queries). Each is optimized for its access pattern, often using different schemas, stores, and even consistency levels. It pairs naturally with event-driven systems and is the easiest path to scaling read-heavy workloads.
Without CQRS
One model, one schema, one DB:
Service
│ │
write API read API
│ │
▼ ▼
┌─────────────────┐
│ one table │ <- shape compromises both
└─────────────────┘
Writes need normalized rows; reads need denormalized, pre-joined data. The single schema is a compromise that satisfies neither.
With CQRS
Two models:
Commands Queries
│ │
▼ ▼
Write side Read side
(source of truth) (denormalized views)
│ ▲
│ events / CDC │
└────────────────────┘
- Write side: small, normalized, transactional.
- Read side: denormalized, pre-joined, query-shaped.
- Synced asynchronously via events or CDC.
Concrete Example
E-commerce order tracking.
Write side (Postgres):
orders (id, user_id, status, ...)
items (order_id, sku, qty, price)
Strict ACID, used by POST /orders, PATCH /orders/:id.
Read side (Elasticsearch + Redis):
// One denormalized doc per order, indexed by user
{
"order_id": "o123",
"user_id": "u42",
"status": "shipped",
"items": [{ "sku": "abc", "name": "Widget", "qty": 2, "subtotal": 19.98 }],
"total": 19.98,
"shipped_at": "2025-...",
"tracking_url": "..."
}
Used by GET /users/:id/orders and GET /orders/:id. One query returns everything the UI needs.
Sync: on every write, publish events; a projection updates Elasticsearch.
Where Each Side Lives
| Side | Storage choice | Reason |
|---|---|---|
| Write | Postgres / RDBMS | ACID, constraints, transactions |
| Read | Elasticsearch | Full-text + filters |
| Read | Redis | Sub-millisecond reads |
| Read | ClickHouse / Druid | Analytics aggregates |
| Read | Materialized views in same DB | Smallest leap from non-CQRS |
You can also stay in one DB and use materialized views as the read model — lightweight CQRS without polyglot.
When CQRS Is Right
- Read patterns very different from write (e.g., complex aggregations).
- Reads >> writes (10:1, 100:1).
- Need multiple read shapes (dashboard, mobile, admin) without each pounding the write DB.
- Already event-driven.
When CQRS Is Wrong
- CRUD apps with simple, symmetric read/write.
- Strong read-your-writes required everywhere (CQRS's read side lags).
- Team can't afford the operational complexity.
CQRS adds eventual consistency to a path that was previously instant. Make sure the UX can absorb it.
Read-Your-Writes Challenges
After POST /orders, the user immediately refreshes. The read store hasn't been updated yet → user sees no order.
Mitigations:
- Optimistic UI: client renders the just-written value locally.
- Read-through cache: write side updates a fast cache synchronously, slow read store async.
- Version pinning: client sends
min_version=N; server waits or routes to write DB until projection catches up. - Hybrid: critical reads from write DB, list/search reads from projection.
Eventual Consistency Story
Write happens; projection updates after a delay (50ms – several seconds typically). Measure:
projection_lag = now() - latest_processed_event_timestamp
Alert at thresholds; show "syncing" UI; gate auditable actions on linearizable reads.
CQRS Without Event Sourcing
CQRS is often confused with Event Sourcing. They're separate:
- CQRS: separate read/write models.
- Event Sourcing: log of events is the source of truth (write side stores events, not state).
You can do CQRS with a traditional write side (just publish events for the projection). See Event Sourcing.
Projection Patterns
A projection is the code that builds the read model from events.
on OrderPlaced(id, user_id, items):
upsert read_orders (id, user_id, items, status='placed')
on OrderShipped(id, tracking):
update read_orders set status='shipped', tracking_url=... where id=...
on OrderCancelled(id):
update read_orders set status='cancelled' where id=...
Properties:
- Idempotent (replays don't break).
- Eventually consistent.
- Re-buildable: nuke the read store, replay all events from offset 0 → rebuild from scratch.
That last property is gold. Bug in projection logic? Fix code, rebuild from event log. Want a new read shape? Add a projection, replay history.
Side Reads on Write Side
A common variant: simple/critical reads (GET /orders/:id right after POST) hit the write side; expensive/list reads (GET /users/:id/orders?status=...) hit the projection.
┌── write tier (Postgres)
GET /orders/:id ────┤ read-after-write reads
│ occasional simple reads
POST /orders ───────┘
GET /search ────────► read tier (Elastic)
Best of both: tight consistency where it matters; scale where the queries are.
Common Mistakes
| Mistake | Fix |
|---|---|
| CQRS for a simple CRUD app | Overengineering — single DB is fine |
| Sync writes to both stores | Defeats the point; use events/CDC |
| No projection lag monitoring | Stale data invisible until user complaints |
| Projection that can't rebuild | Don't lose the ability to nuke and replay |
| Mixing read schema migrations with write schema | Keep schemas independently versionable |
[!NOTE] CQRS shines when read and write demands are genuinely different. If they're the same, you're paying complexity for nothing. Use it where it pays back.
Interview Follow-ups
- "How is CQRS different from a read replica?" — Replica has the same schema. CQRS allows different schema, different DB engine, different consistency.
- "How do you keep projections in sync with new code?" — Version them; deploy in parallel; flip traffic when caught up; old projection deleted after burn-in.
- "What's the projection failure recovery?" — Resume from last committed offset; idempotent operations → safe replay.
Q: Event Sourcing — when is "store every event" worth it?
Answer:
Event Sourcing stores the sequence of events that produced state, not the state itself. State is derived by replaying events. Powerful pattern with high ceremony — right for some domains (banking, audit), overkill for most.
The Core Idea
Traditional model:
accounts table:
id | balance
42 | 1000
Event-sourced model:
events log:
id=1 type=AccountOpened account=42 balance=0
id=2 type=Deposited account=42 amount=500
id=3 type=Deposited account=42 amount=600
id=4 type=Withdrawn account=42 amount=100
derived: balance = 0 + 500 + 600 - 100 = 1000
The events are immutable. The balance is a projection derived by replaying them.
Properties
- Full audit trail. Every change is a permanent record with who/when/why.
- Time travel. Replay to any point: "what was the balance on July 4?"
- Multiple read models. Same event log → many projections.
- Reproducible bugs. Bug in derivation? Replay from log with fixed code.
- Natural CDC source. Other systems can subscribe to the event stream.
The Pieces
Commands ──► Aggregate ──► Events ──► Event Store ──► Projections / Read Models
│ │
▼ ▼
publish to bus query views (CQRS)
- Command: intent ("transfer $100").
- Aggregate: domain object validating the command + emitting events.
- Event: fact ("transferred $100"). Past tense, immutable.
- Event Store: append-only log keyed by aggregate ID.
- Projection: process consuming events to build read views.
Aggregate Pattern
class Account:
def __init__(self, events):
self.balance = 0
self.id = None
for e in events:
self.apply(e)
def apply(self, event):
if event.type == "AccountOpened":
self.id = event.account
elif event.type == "Deposited":
self.balance += event.amount
elif event.type == "Withdrawn":
self.balance -= event.amount
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFunds()
return Event("Withdrawn", account=self.id, amount=amount)
Load: read events, apply. Command: emit event. Save: append event.
Snapshots
Replaying every event from year zero gets slow at high counts. Snapshot the aggregate state every N events:
load(account_id):
snapshot = load_latest_snapshot(account_id)
events = load_events_after(snapshot.version)
return apply_all(snapshot, events)
Snapshots are an optimization, not the source of truth. Throw them away anytime; they regenerate.
Schema Evolution
Events are forever. You can't change one — only add new ones. Strategies:
- Add new field with default (Avro/Proto backward-compatible).
- Upcasters: code that rewrites old event versions to new shapes at load time.
- Never delete. Mark "deprecated" instead.
A subtle bug: a 2017 event of type AddressUpdated won't have a country field you added in 2023. Your replay code must handle that.
When Event Sourcing Is Right
- Compliance / audit-heavy domains (banking, healthcare, legal).
- "What changed and when" is a primary query.
- Domain has clear, stable business events (already DDD-modeled).
- You want eventual derivation of multiple read models.
- Replay-based recovery is valuable.
When It's Wrong
- Pure CRUD ("update users.email"); events would be
EmailChangedand nothing else interesting. - Team unfamiliar with the pattern.
- High write throughput on a single aggregate (every event is a serialization point).
- Reporting needs ad-hoc SQL across all entities at once (projections rescue this, but with effort).
Implementation Options
| Stack | Notes |
|---|---|
| Postgres + outbox + Kafka | Simplest; events in a table, indexed by aggregate_id |
| Kafka + KTable / state stores | Logs are first-class; replay is natural |
| EventStoreDB | Purpose-built; tooling for projections, snapshots, replay |
| Axon Framework | JVM-first event sourcing framework |
| Marten (Postgres) | Postgres-as-event-store; .NET-focused |
Eventual Consistency
Same gotcha as CQRS. Projections lag the event store. Read-your-writes patterns:
- Optimistic UI.
- Query the latest snapshot + recent events.
- Pinned read after write for short window.
Concurrency Control
Two commands on the same aggregate race. Use optimistic concurrency:
load events at version 42
build aggregate state
emit new event(s)
append IF current version == 42
on mismatch: reload + retry
Optimistic version checks at append; conflicts trigger retry. No locks held.
Sagas Often Live Above Event Sourcing
Inter-aggregate workflows: events from one aggregate trigger commands on another. The orchestrator (or choreography) lives outside the aggregate boundary. See Saga.
Cost of Event Sourcing
- Storage: O(events) forever. 10× more than current-state storage; with compression, still ~3×.
- Cognitive load: every change starts with "what event is this?"
- Migration: schema versioning is non-trivial.
- Tooling: rebuild from offset 0 is your safety net; needs to actually work.
Patterns It Enables
- Time-travel debugging: replay to the exact moment a bug appeared.
- A/B projection: build two read models from same events for migration.
- Audit trail: free, complete, queryable.
- Temporal queries: "what did this look like at midnight last Friday?"
- External integration: external system gets a clean event stream, no DB CDC kludges.
Common Mistakes
| Mistake | Reality |
|---|---|
| Treating events as "what UI needs" | Events are business facts; UI consumes projections |
| Mutable events | Defeats the entire pattern; events are immutable |
| Skipping snapshots | Replay grows linearly; eventually unusable |
| Single global event stream | Hot partition; partition by aggregate type or ID |
| Forgetting upcasters | Old events fail to deserialize when schema evolves |
[!NOTE] Event Sourcing is the strongest, slowest version of "audit-ability." If the domain doesn't need it strongly, a normal DB + outbox is usually a better trade.
Interview Follow-ups
- "Difference between Event Sourcing and CDC?" — ES emits business events from the domain layer (intent + meaning). CDC emits database-row changes (after the fact). ES has semantics; CDC has bytes.
- "How do you delete data (GDPR)?" — Crypto-shredding: encrypt PII with a per-user key, delete the key on request → data unrecoverable. Or null out specific fields in old events ("tombstone").
- "How do you replay 100M events?" — Snapshot + replay only after; parallelize per aggregate; back-pressure the projection rebuilder.
Q: Saga pattern — choreography vs orchestration. When and how?
Answer:
A Saga manages a multi-step business transaction across services without distributed 2PC. Each step is a local transaction; failures trigger compensating actions to undo earlier steps. Two flavors: choreography (event-driven, no coordinator) and orchestration (central coordinator drives the flow).
The Problem
Place order = {
reserve inventory (service A)
charge card (service B)
ship (service C)
notify (service D)
}
A relational transaction across A, B, C, D would need 2PC (XA) — slow, fragile, doesn't work across HTTP. Saga decomposes into 4 local transactions + 4 compensations.
Choreography (Event-Driven)
Each service publishes events when its step completes; other services subscribe and react.
order-service ──OrderPlaced──► inventory-service
│
▼
inventory-service ──InventoryReserved──► payment-service
│
▼
payment-service ──PaymentCharged──► shipping-service
│
▼
ShipmentScheduled
On failure:
payment-service ──PaymentFailed──► inventory-service
│
▼
compensate: release stock
publish: InventoryReleased
│
▼
order-service ──OrderFailed──► notify-service
Pros:
- No central component.
- Services loosely coupled.
- Easy to add new participants.
Cons:
- Hard to reason about the full flow.
- Hard to debug (where is my order stuck?).
- Cyclic event dependencies easy to introduce.
- Adding compensations means changes scatter across services.
Orchestration (Central Coordinator)
A saga orchestrator explicitly calls each step and decides what to do on failure.
Orchestrator
│ call inventory.reserve()
│ ◄── reserved
│ call payment.charge()
│ ◄── failure
│ call inventory.release() ← compensation
│ ◄── released
│ publish OrderFailed
Implemented with: Temporal (or Cadence), AWS Step Functions, Camunda, Netflix Conductor, or hand-rolled state machines.
Pros:
- Workflow is explicit, version-able, debuggable.
- Centralized error handling.
- Easier to test end-to-end.
- Built-in retries, timeouts, signal handling.
Cons:
- Orchestrator is itself a service to operate.
- Couples services to the orchestrator's view of the world.
- Can become a "god class" if not carefully scoped.
When to Pick Which
| Situation | Pick |
|---|---|
| 2–3 steps, simple linear flow | Choreography |
| > 4 steps, branches, retries | Orchestration |
| Need to visualize / audit the workflow | Orchestration |
| Each service team owns its own piece, low coordination | Choreography |
| Workflows changing weekly | Orchestration (one place to change) |
Many production systems mix: orchestrator for primary flow, events for side effects.
Compensation Semantics
A compensation is not a rollback — it's a business-level undo.
Step Compensation
charge card refund (with original tx id)
reserve inventory release reservation
send email send "actually, cancel that" email
The compensating action must be idempotent — it might run more than once (retries, replay). Track state explicitly (reservation_released = true) and no-op on repeat.
Some actions are not compensable: "send SMS to user" can't be unsent. Design for forward-only flow at those points, or move them after the point of no return.
Idempotency Is Mandatory
Saga steps are at-least-once. Every step must dedup by saga ID + step ID:
service.reserve(orderId="o123", step="reserve_v1")
if step already processed → return cached result
else → do work, record step
Stripe, Temporal, Step Functions: all enforce this with deterministic step IDs.
Saga State
Where the orchestrator (or aggregate of services in choreography) tracks progress.
saga_id VARCHAR PK
current_step ENUM
status ENUM (in_progress, completed, failed, compensating)
context JSONB -- inputs, intermediate results
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ
Recovery: on orchestrator restart, load all in_progress sagas and resume.
Saga + Outbox
To publish events atomically with a DB write, use the Transactional Outbox pattern:
BEGIN;
INSERT INTO orders (...);
INSERT INTO outbox (event_type, payload) VALUES ('OrderPlaced', '{...}');
COMMIT;
Separate process polls outbox (or CDC via Debezium) → publishes to Kafka. Atomic with the order write. No "we committed but the event didn't fire" bug.
See Transactional Outbox.
Failure Modes
| Failure | Handling |
|---|---|
| Network blip during step | Retry with backoff; idempotency dedupes |
| Step permanently fails | Compensate prior steps; mark saga failed |
| Orchestrator crashes mid-flow | On restart, replay from last persisted step |
| Compensation itself fails | Retry; alert; possibly require human intervention |
Saga stuck in in_progress | Timeout per step; force-fail and compensate |
Visualizing
ORDER PLACED
│
▼
[Reserve Inventory]──fail──►[Release Inventory]
│ ok │
▼ ▼
[Charge Card]──fail──►[Refund + Release Inventory]
│ ok │
▼ ▼
[Schedule Shipment]──fail──►[Refund + Release + cancel?]
│ ok │
▼ ▼
COMPLETED FAILED
Hand-drawing this diagram in an interview is half the answer.
Common Mistakes
| Mistake | Fix |
|---|---|
| Treating saga as a transaction | It's eventually consistent — design UX around it |
| Steps not idempotent | Use deterministic step IDs + dedup table |
| Long-held locks across steps | Don't hold DB locks between saga steps — use optimistic versioning |
| One huge orchestrator owning many domains | Split per business workflow |
| No timeouts on steps | Stuck saga; always set a per-step deadline |
| Compensations not tested | Run failure-injection drills |
[!NOTE] Sagas trade ACID for availability + service autonomy. The price: every business invariant becomes an explicit compensation. Design the compensations first; the happy path is the easy part.
Interview Follow-ups
- "Why not 2PC?" — Blocks resources on participants; coordinator failure leaves locked rows; doesn't work over WAN.
- "How do you implement Temporal-style workflows without Temporal?" — Persistent state machine in DB + idempotent step handlers + scheduler. Re-inventing Temporal, badly.
- "What about sagas with parallel branches?" — Both choreography and orchestration support fan-out; orchestrator must wait for all branches before next step (
Promise.allsemantics).
Q: Transactional Outbox — atomic DB write + event publish.
Answer:
You write to your DB and need to publish a message — to Kafka, SNS, another service. The naïve "write then publish" loses events when the process crashes between the two steps. The outbox pattern solves this with one DB transaction.
The Failure Mode
order = insert_order() # ✅ committed
publish("OrderPlaced", order) # ❌ process crashed
# DB has order, no event sent
# OR
publish("OrderPlaced", order) # ✅ sent
order = insert_order() # ❌ failed
# event refers to non-existent order
Either order, dual writes can diverge. No clever try/catch fixes it — the two systems aren't transactional together.
The Solution
Write the event to an outbox table in the same DB in the same transaction. A separate process drains the outbox and publishes:
BEGIN;
INSERT INTO orders (id, ...) VALUES (?, ...);
INSERT INTO outbox (id, aggregate_type, event_type, payload, created_at)
VALUES (?, 'Order', 'OrderPlaced', ?, now());
COMMIT;
Either both rows are present or neither is. Atomic.
A relay process polls outbox, publishes, marks as sent:
SELECT * FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100;
publish to Kafka
UPDATE outbox SET published_at = now() WHERE id IN (...);
Outbox Schema
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
headers JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX ON outbox (published_at, id) WHERE published_at IS NULL;
The partial index makes polling cheap.
Two Relay Strategies
1. Polling
A worker reads unpublished rows, sends them, marks them sent.
while True:
rows = db.fetch("SELECT * FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100")
if not rows:
sleep(0.1)
continue
for r in rows:
kafka.send(r.event_type, r.payload)
db.exec("UPDATE outbox SET published_at = now() WHERE id IN ...")
Simple. Adds latency (polling interval). Concurrent workers can race — use FOR UPDATE SKIP LOCKED to safely parallelize:
SELECT * FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
2. Change Data Capture (CDC)
Tail the database's replication log (WAL/binlog). Tools: Debezium, Maxwell, AWS DMS.
Postgres WAL
│
▼
Debezium connector
│
▼
Kafka topic "outbox.events"
│
▼
Downstream consumers
Sub-second latency, no polling load on the DB. Operationally heavier (Debezium cluster, schema management).
CDC bonus: it can stream changes from any table, not just outbox. Some systems skip the outbox table and CDC the business tables directly — works if events can be derived from row changes alone.
Idempotent Publish
Even with outbox, at-least-once delivery is the rule. The relay might:
- Send to Kafka but crash before marking sent → resends on retry.
- Mark sent but Kafka acked late → next worker picks up.
Consumers must dedup by outbox.id or a business event ID. Standard idempotency pattern.
Ordering
Per aggregate_id ordering preserved if you partition outbox dispatch by it:
publish to topic "orders" with key = aggregate_id
→ Kafka guarantees per-partition order
Across aggregates, no order guarantee. That's usually fine; design events to be commutative or carry timestamps.
Cleanup
Outbox grows forever if you don't prune:
DELETE FROM outbox WHERE published_at < now() - INTERVAL '7 days';
Keep enough history to replay if something downstream crashes. 7–30 days typical.
For very high volume: partition outbox by day (Postgres declarative partitioning) and drop old partitions.
Outbox vs Other Patterns
| Pattern | Description | Trade |
|---|---|---|
| Outbox + polling | Above | Simple; small polling latency |
| Outbox + CDC | Above with Debezium | Sub-second; heavy ops |
| Dual writes (NO) | Write DB, then publish | Loses events on crash |
| Event sourcing | Events ARE the source of truth | Bigger paradigm shift |
| Listen/notify | DB notifies consumer (Postgres LISTEN) | Best-effort; fragile |
| Transactional Kafka | Kafka tx + DB write coordinated (XA-ish) | Operational pain; rare |
Outbox + Inbox
Mirror pattern on consumer side: store inbound events in an inbox table before processing, in the same transaction as the business write:
BEGIN;
INSERT INTO inbox (event_id, event_type) VALUES (?, ?); -- unique constraint dedups
INSERT INTO orders (...); -- business work
COMMIT;
If event is replayed: unique constraint violation on event_id → handler safely no-ops. Bullet-proof at-least-once → exactly-once-effect.
Real-World Example
E-commerce checkout:
BEGIN;
INSERT INTO orders (id, user_id, total, status='pending');
INSERT INTO outbox (event_type='OrderCreated', payload={...});
COMMIT;
Debezium → Kafka → consumers:
- payment-service: charge card
- inventory-service: reserve stock
- email-service: send confirmation
- analytics-service: record event
Each consumer dedups by event_id (from outbox).
Common Mistakes
| Mistake | Fix |
|---|---|
| Forgetting unique key on event_id at consumer | Duplicate processing on retry |
| Outbox in a different DB than business data | Defeats the atomicity; same DB is the point |
| Synchronous relay (write then await publish in same tx) | Slow + couples DB tx to broker availability |
Multiple relay workers without SKIP LOCKED | Race condition; duplicate sends |
| Storing massive payloads in outbox | Bloats DB; store references or signed URLs |
| No retention policy | Table grows unbounded |
[!NOTE] Outbox isn't optional for any system that writes to a DB and publishes events. The "dual write" alternative looks fine in dev and silently loses events in production.
Interview Follow-ups
- "Why not just use a transactional Kafka producer?" — KIP-98 Kafka transactions span Kafka topics, not your DB. You'd need XA-style 2PC; not natively supported by most DBs.
- "What about NoSQL?" — Same idea works if your NoSQL supports per-document/transaction atomicity. DynamoDB Streams (CDC) + outbox attribute works.
- "How would you ship events ordered globally?" — You usually don't need that. Per-aggregate ordering via partition key is enough. If you really need it, you've designed yourself into a single-writer bottleneck.
Q: Change Data Capture (CDC) — what it is, when to use it.
Answer:
CDC streams every row change in a database as an event. Built originally for data warehousing; now the backbone of microservice integration, materialized views, audit, and search indexes.
What It Looks Like
DB write: CDC event:
INSERT INTO orders (id, total) → {
VALUES (123, 50) "op": "create",
"table": "orders",
"after": {"id": 123, "total": 50}
}
UPDATE orders SET total=60 → {
WHERE id=123 "op": "update",
"before": {"id": 123, "total": 50},
"after": {"id": 123, "total": 60}
}
DELETE FROM orders WHERE id=123 → {
"op": "delete",
"before": {"id": 123, "total": 60}
}
How It Works
DBs maintain a transaction log (Postgres WAL, MySQL binlog, MongoDB oplog) for replication. CDC tools tail that log:
DB ──writes──► Transaction Log ──tail──► CDC tool ──publish──► Kafka
Tool: Debezium (most common), Maxwell (MySQL), AWS DMS, GCP Datastream.
Why Tail the Log (Not Poll the Tables)
- Captures every change, even ones rolled back via undo.
- No load on application queries (log is decoupled).
- Captures ordering and transactions.
- Sub-second latency.
Polling SELECT * WHERE updated_at > ? is the alternative; it misses deleted rows and adds DB load.
Use Cases
| Use case | Why |
|---|---|
| Sync DB → search index (Elasticsearch) | Keep search fresh without app changes |
| Sync DB → analytics DB (ClickHouse, Snowflake) | Lower-latency than nightly batch |
| Cache invalidation | Bust Redis when underlying row changes |
| Microservice integration | Service B reacts to data changes in service A's DB |
| Audit trail | Every change is an event |
| Outbox alternative | CDC business tables directly |
| Cross-region replication | Stream changes to remote region |
Outbox vs CDC
Outbox:
- App writes events to an outbox table inside the business transaction.
- CDC (or a poller) streams the outbox.
- App controls event semantics ("OrderShipped" with rich payload).
Direct CDC of business tables:
- No outbox; CDC the
orderstable. - Downstream sees raw rows; less business meaning.
- Schema changes (column adds/drops) directly affect downstream.
Most teams use outbox. Direct CDC is for systems where you control both ends and want zero app changes.
Schema Evolution
Tables change. CDC events must too:
- Add column: new events include the new field; old events don't (downstream tolerant).
- Drop column: old events still have it; downstream tolerant.
- Rename: best avoided; treat as drop+add.
Schema registry (Avro / Protobuf) on the Kafka side helps.
Initial Snapshot + Streaming
When you turn on CDC, you usually want both:
- Snapshot: existing data (initial load).
- Stream: future changes from the WAL.
Debezium does this automatically: takes a consistent snapshot using SELECT ... AS OF SCN (Oracle) or START TRANSACTION REPEATABLE READ (Postgres), then transitions to streaming from the recorded LSN.
Ordering
Per-table or per-key ordering preserved (CDC events flow through Kafka with key = primary key).
Across tables, ordering by transaction is best-effort. Multi-row transactions are visible as multiple events; downstream must reassemble if needed.
Consumer Considerations
CDC events are at-least-once. Consumers must dedup:
- Use Kafka offset + topic-partition for resume.
- Use event's unique LSN / position field for dedup.
For "rebuild downstream from scratch": consume from offset 0; idempotent application of events makes this safe.
Performance Impact on Source DB
CDC reads the WAL — but the WAL is already written for replication. Marginal extra load.
Caveats:
- Postgres: requires
wal_level = logicaland replication slots; abandoned slot fills disk. - MySQL: row-based binlog format required.
- High write volume: CDC can produce huge Kafka traffic.
Schema Tools
Debezium emits events in a specific Avro / JSON Schema format. Schema Registry mandatory at scale.
{
"schema": { "type": "record", ... },
"payload": {
"op": "u",
"before": { ... },
"after": { ... },
"source": { "ts_ms": ..., "lsn": ... },
"ts_ms": ...
}
}
Failure Modes
| Failure | Handling |
|---|---|
| Debezium connector crashes | Resumes from last offset in Kafka |
| Source DB master failover | New master, new WAL — connector reconnects |
| Old WAL purged before Debezium consumed | Lost events; alert on lag |
| Schema change downstream | Consumer must be tolerant (default values, alias) |
Common Mistakes
| Mistake | Fix |
|---|---|
| No retention on WAL during downtime | Replication slot fills disk |
| Dual-writing to DB + Kafka (without outbox/CDC) | Lost events on crash |
| Treating CDC events as commands | They're after-the-fact; commands are intents |
| Ignoring ordering across tables | Stitch tx_id if cross-table consistency needed |
| Slow consumer | Lag balloons; back-pressures the source via replication slot bloat |
[!NOTE] CDC is the canonical way to bridge ACID systems and event-driven downstream consumers. It's a different programming model — events instead of polled snapshots — but the payoff is massive: real-time integration with zero app code.
Interview Follow-ups
- "How would you migrate from a monolith DB to microservice DBs?" — Stand up microservice; CDC the monolith table; populate new DB; cut over; remove the old.
- "How does Debezium handle DDL?" — Captures DDL events too (where supported); downstream may need re-config.
- "How is CDC different from a trigger?" — Triggers are synchronous, in-DB, affect write latency. CDC reads the log post-commit — async, no app impact.
Q: Sidecar pattern and service mesh — what they solve.
Answer:
A sidecar is a helper process running alongside each app container, handling cross-cutting concerns (TLS, retries, telemetry) without app code changes. A service mesh is a fleet of sidecars + a control plane managing them centrally.
The Sidecar Pattern
Co-locate app + helper in the same pod / VM:
┌──────────────────────────────┐
│ Pod │
│ ┌─────────┐ ┌─────────────┐ │
│ │ App │ │ Sidecar │ │
│ │ (any │◀▶│ (proxy / │ │
│ │ lang) │ │ utility) │ │
│ └─────────┘ └─────────────┘ │
└──────────────────────────────┘
Communication: localhost (TCP / UDS) — fast, secure.
Examples:
- Logging agent: tails app stdout, ships to log aggregator.
- Metrics agent: scrapes app, pushes to TSDB.
- TLS terminator: handles HTTPS so app can be plain HTTP.
- Service mesh proxy: Envoy / Linkerd-proxy.
Service Mesh
┌──────────────┐
│ Control Plane │ (Istiod, Linkerd control)
│ - config │
│ - policy │
│ - certs │
└───────┬────────┘
│ pushes config
┌───────────────────┼────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ App + │ │ App + │ │ App + │
│ Sidecar │◄────► │ Sidecar │◄──────► │ Sidecar │
└──────────┘ └──────────┘ └──────────┘
Each pod gets a proxy (Envoy in most). All traffic flows app → sidecar → network → sidecar → app.
Sidecars provide:
- mTLS between services (auto cert rotation).
- Retries, timeouts, circuit breaking.
- Load balancing (client-side).
- Per-request observability (metrics, traces).
- Traffic shifting (canary, blue/green, mirror).
- Authorization policies.
Popular Service Meshes
| Mesh | Notes |
|---|---|
| Istio | Most features; complex; Envoy-based |
| Linkerd | Simpler; Rust-based proxy; lighter |
| Consul Connect | HashiCorp; consul-based discovery |
| Cilium Mesh | eBPF-based; no sidecar overhead |
| AWS App Mesh | Managed; Envoy-based |
Why Sidecar Beats Library
Library approach: each service team integrates a resilience library.
- Per-language; need ports for Go, Python, Java, Node.
- Updates require app redeploys.
- Cross-language consistency hard.
Sidecar approach: one binary, language-agnostic.
- Update sidecar without touching app.
- Same behavior across all languages.
- Cost: extra process per pod.
Cost of Sidecars
Each pod runs an extra container:
- Memory: 50–200 MB per sidecar.
- CPU: 0.05 – 0.5 cores typical.
- Latency: ~1 ms added per hop (in + out).
For 1000-pod cluster: ~100 GB extra RAM, ~50 cores. Not trivial.
Alternatives
Library approach (no sidecar, in-process):
- Spring Cloud / Resilience4j.
- gRPC client interceptors.
- Per-language stack.
eBPF mesh (Cilium):
- No sidecar; kernel handles mTLS, load balancing.
- Lower overhead.
- Limited feature parity with Envoy.
Gateway-only (no per-pod proxy):
- mTLS at the cluster edge; plain inside.
- Cheaper; less defense-in-depth.
Sidecar Use Cases Beyond Mesh
- Logging: Fluent Bit collects app logs from disk → ships to ES/Loki.
- Secrets: Vault Agent renews secrets and mounts them as files.
- DB connection pooling: PgBouncer sidecar.
- Caching: Redis sidecar for in-pod cache.
- Compression / encryption: external for compute-heavy work.
Common Pitfalls
| Pitfall | Fix |
|---|---|
| Sidecar starts after app | App can't connect on startup; use initContainers to wait |
| Sidecar resource limits too tight | OOMKilled sidecar → app loses mesh features |
| Cluster latency spike | Sidecar misconfigured; profile per-hop |
| Mesh config drift | Use GitOps (Argo CD) for control plane config |
| Sidecar terminate before app finishes graceful shutdown | Lifecycle ordering matters; use preStop hooks |
When NOT to Use a Mesh
- Small cluster (< 10 services): library / gateway is fine.
- Cost-sensitive (memory/CPU budget tight).
- All services are one language: native library may be lighter.
- No team to operate it (mesh demands SRE attention).
Strangler-fig with Mesh
Mesh's traffic shifting lets you migrate cleanly:
v1 service: 100% traffic
↓
v1: 95%, v2: 5%
↓
... gradual ramp
↓
v2: 100%; v1 retired
Without app changes — mesh routes based on labels.
[!NOTE] Service meshes solve a real problem (cross-cutting concerns at scale) but bring real complexity. Adopt when the alternative (library per language) is genuinely worse. Don't deploy Istio for a 5-service stack.
Interview Follow-ups
- "How does mTLS work in a mesh?" — Control plane issues short-lived per-pod certs; sidecars present them on each outbound and validate on each inbound.
- "What's the data plane vs control plane?" — Data plane = the sidecars (Envoy) handling traffic. Control plane = the configurator (Istiod) telling sidecars what to do.
- "What's Ambient Mesh (Istio)?" — Sidecar-less variant; mesh functions in node-level proxies. Newer; trades isolation for resource savings.
Q: Strangler fig pattern — migrating from a legacy system without a big rewrite.
Answer:
Named after the tropical fig that grows around a host tree until the host dies. The pattern: a new system grows around the old one, gradually taking over functionality, until the old is shut down.
Beats the alternative ("big rewrite") which famously fails.
When You Need It
- Monolith you can't replace in one go.
- Critical legacy system with tribal knowledge in code.
- Multi-year migration where the old must keep running.
- Re-platforming (on-prem → cloud, mainframe → microservices).
The Pattern
year 0: year 1: year 2:
[Monolith] [Monolith] [Monolith]
│ ──── shrinking ──── │
handles all [Façade] [Façade]
requests ──┬──┐ ──┬──┐
▼ ▼ ▼ ▼
[new] [new] [new] [new]
most traffic
year 3: monolith retired
A façade sits in front of the legacy system. Some requests still go to the legacy; new functionality lives in new services. Over time, more is rerouted.
The Façade
Typically a reverse proxy with routing rules:
GET /api/users/* → new user-service
GET /api/orders/* → legacy (still)
POST /api/checkout → legacy
GET /api/dashboard → new dashboard-service
As features migrate, edit the routing. Clients don't change.
Approach
1. Set up the façade.
Edge proxy (Envoy, nginx, AWS ALB) in front. Initially routes everything to legacy.
2. Pick a slice.
Smallest functional piece you can extract. Often:
- Read-only endpoints first.
- Self-contained domains (auth, search).
- Recently-built modules (cleaner separation).
3. Build new alongside.
New service implements that slice. Tested independently.
4. Shadow / dual-run.
Send traffic to both old and new; compare responses. Flag discrepancies.
5. Cut over.
Switch façade rules; small percentage first (canary).
6. Repeat.
Each slice migrates the same way. Old system shrinks.
7. Decommission.
When legacy serves no traffic, archive it.
Data Migration
The hardest part is data, not code.
Options:
1. New writes go to new system; reads from both.
- New starts empty; backfilled from old.
- Reads union; writes only to new for migrated keys.
2. Dual-write.
- Both systems get the write.
- Risk: divergence on partial failure.
- Add reconciliation jobs.
3. CDC from legacy.
- New system subscribes to legacy's change stream.
- Eventually consistent; near-zero divergence.
- Best pattern for large migrations.
4. Old as system of record, new as cache/view.
- Legacy is canonical; new builds a read-optimized projection.
- Until new is canonical, can revert easily.
When to Cut Over
Per slice, gate criteria:
- New service hits same response shapes as old.
- Shadow comparison error rate < threshold.
- Functional + load tests pass.
- Runbook for rollback documented.
Then ramp: 1% → 5% → 25% → 50% → 100%.
What Goes Wrong
| Problem | Mitigation |
|---|---|
| Data drift between old and new | Reconciliation jobs, schema validation |
| New service depends on legacy internals | Refactor legacy to expose stable API first |
| Migration never finishes (zombie tail) | Force timeline; allocate migration team |
| New service has different bugs than old | Shadow comparison + canary |
| Org politics: who owns the migrated piece | Define ownership upfront |
Strangler vs Big Rewrite
| Aspect | Strangler | Big Rewrite |
|---|---|---|
| Risk | Low (incremental) | High (deploy-day binary cutover) |
| Time | Long (months/years) | Long (often unfinished) |
| Value delivery | Continuous | At the end |
| Org tolerance | Sustained focus | Often loses funding mid-way |
| Success rate | High | Famously low (Joel Spolsky's "Things You Should Never Do") |
Patterns That Pair Well
- Service mesh: enables traffic shifting + canaries with no app changes.
- Feature flags: toggle between old/new code paths per user.
- CDC: pipe data between systems during migration.
- API gateway: hosts the façade routing.
Anti-Patterns
| Anti-pattern | Reality |
|---|---|
| "Rewrite everything in 6 months" | Misses scope by 5×; delivers nothing |
| New system without compatibility layer | Clients break |
| Migrate everything from one shared DB | New microservice tied to old schema; no real isolation |
| No measurement of what's still on legacy | Don't know when done |
[!NOTE] Strangler fig is a discipline, not a tool. The key is incremental delivery of working code — never letting the new system get more than weeks ahead of being deployable.
Interview Follow-ups
- "How do you know when to start strangling vs when to add to the monolith?" — Strangle when new features in the monolith block deployment of other features. Or when scale forces extraction.
- "What if old and new have different consistency models?" — Migration period accepts dual consistency; reconcile end-of-day; switch reads first to test, then writes.
- "How do you keep team morale during a 3-year migration?" — Visible progress per quarter; celebrate each slice retired; rotate engineers through the migration team.
Q: Rate limiting algorithms — token bucket, leaky bucket, fixed/sliding window.
Answer:
Rate limiting protects services from overload, abuse, and runaway costs. The four canonical algorithms differ in burst tolerance and memory cost.
Token Bucket
A bucket holds up to capacity tokens. Tokens added at rate R per second. Each request consumes one token. Empty bucket → reject (or queue).
state per client: tokens (float), last_refill (timestamp)
on request:
now = current_time()
elapsed = now - last_refill
tokens = min(capacity, tokens + elapsed * R)
last_refill = now
if tokens >= 1:
tokens -= 1
return allow
else:
return reject
Properties:
- Allows bursts up to
capacity. - Average rate over time =
R. - Per-client state: two floats. Cheap.
Used by: AWS API Gateway, most production limiters.
Leaky Bucket
Imagine a bucket with a hole leaking at constant rate. Requests pour in; bucket overflowing → reject.
state: water (current depth)
on request:
now = current_time()
leak = (now - last_check) * leak_rate
water = max(0, water - leak)
if water + 1 <= capacity:
water += 1
return allow
else:
return reject
Properties:
- Smooth output: requests processed at constant rate.
- No bursts above leak rate.
- Used when downstream needs constant pace (legacy systems, expensive APIs).
Trade vs token bucket: token bucket allows bursts; leaky bucket forces uniform rate.
Fixed Window Counter
Count requests per fixed window (per minute):
state: counter, window_start
on request:
if now > window_start + 60:
counter = 0
window_start = now
if counter < limit:
counter += 1
return allow
else:
return reject
Properties:
- Simple, cheap (one counter).
- Burst at boundary: limit=100/min; user sends 100 at 12:00:59 + 100 at 12:01:00 → 200 requests in 1 second.
- Discrete window unfairness for clients arriving near boundary.
Sliding Window Log
Store timestamp of each request; count those in the last N seconds.
state: list of timestamps (per client)
on request:
drop timestamps older than now - window
if len(timestamps) < limit:
timestamps.append(now)
return allow
else:
return reject
Properties:
- Accurate.
- Memory: O(N) per client (worst case = limit timestamps).
- Cleanest semantics; expensive at scale.
Sliding Window Counter
Hybrid: weighted blend of two adjacent fixed windows.
current request
window N-1: ████████████ │
window N: ████████████ ────┘ (you're here)
elapsed in current window = 0.4
count = old_window_count * (1 - 0.4) + current_window_count
Properties:
- O(1) memory.
- Smoother than fixed window.
- Approximate but production-good.
Used by Cloudflare, Nginx (limit_req with leaky bucket nuance).
Comparison
| Algorithm | Burst tolerance | Memory | Smoothness | Complexity |
|---|---|---|---|---|
| Token bucket | Up to capacity | O(1) | Medium | Low |
| Leaky bucket | None | O(1) | High | Low |
| Fixed window | Up to 2×limit at boundary | O(1) | Low | Lowest |
| Sliding log | None | O(limit) | Highest | Medium |
| Sliding counter | Modest | O(1) | High | Medium |
Where to Limit
Client Edge / CDN Service
│ per-user, per-key per-IP, per-ASN per-tenant, per-route
▼ │
┌─────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┴────┐
│ App │ ── │ CDN │ ── │ API gateway │ ── │ service │
└─────┘ └──────────┘ └─────────────┘ └───────────────┘
▲ rate-limit rate-limit rate-limit
│
self-throttling (client-side budget)
Limit at every tier — defense in depth. Edge for DDoS; gateway for per-API quotas; service for per-tenant fairness.
Distributed Rate Limiting
When you have N gateway instances, each can't track its own count or you'll allow N × limit requests.
Patterns:
1. Centralized store (Redis):
INCR rate:user:42:60s
EXPIRE rate:user:42:60s 60 (set TTL once)
return value <= limit ? allow : reject
Atomic via INCR. ~100µs per check. Best for medium scale.
2. Lua script for token bucket in Redis:
Atomic refill + consume in one round-trip:
local tokens = tonumber(redis.call('GET', KEYS[1]) or capacity)
local last = tonumber(redis.call('GET', KEYS[2]) or 0)
local now = tonumber(ARGV[1])
tokens = math.min(capacity, tokens + (now - last) * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('SET', KEYS[1], tokens)
redis.call('SET', KEYS[2], now)
return 1
else
return 0
end
3. Local approximation (gossip / probabilistic):
Each instance tracks its own counter; periodically share. Used by Stripe, others for very high QPS. Accepts small overshoot for performance.
4. Sticky routing:
Hash client IP → always same gateway → local counter sufficient. Doesn't survive instance loss; small overshoot.
Quotas vs Rate Limits
- Rate limit: requests per short window (e.g., 100/min).
- Quota: requests per long window (e.g., 10k/day).
Implement both. Quota usually backed by relational DB; rate limit by Redis.
Response Headers
Tell clients what's happening:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1700000060
# On rejection:
HTTP/1.1 429 Too Many Requests
Retry-After: 13
Retry-After lets well-behaved clients back off without retry loops.
Tiered Limits
Differentiate by plan:
free: 10 req/min
pro: 100 req/min
enterprise: 1000 req/min
Implementation: limit lookup by tenant_id → tier → policy.
Common Mistakes
| Mistake | Fix |
|---|---|
Limiting by Remote-Addr behind a CDN | Use X-Forwarded-For correctly, ideally with signed forwarded headers |
| Counting per second only (allows 1-sec spikes) | Pair short + long windows |
| Letting one customer fill the table | Per-tenant quotas |
| No 429 communication | Always return Retry-After |
| Blocking abuse with rate limit alone | Layer with WAF, bot detection, CAPTCHAs |
[!NOTE] Rate limiting is part fairness, part protection, part business policy. Don't conflate them. Fairness = per-user; protection = global QPS; policy = tier-based quotas. Build each axis independently.
Interview Follow-ups
- "How would you build a global rate limiter for a multi-region service?" — Per-region local + sync to global counter periodically; allow small overshoot per region.
- "How do you rate-limit by something other than IP?" — Composite keys: user_id, API key, JWT claim, fingerprint. Pre-hash to avoid PII in cache.
- "What if Redis goes down?" — Fail-open (allow all) for non-abusive scenarios; fail-closed (deny) for security-critical APIs. Pick per endpoint.
Q: Consistent hashing — why, how, virtual nodes.
Answer:
Consistent hashing distributes keys across nodes such that adding/removing one node moves only 1/N of the keys, not "almost all of them" like naïve hash(key) % N. It's the foundation of distributed caches (Memcached, Redis Cluster), wide-column stores (Cassandra, DynamoDB), and CDNs.
The Naïve Approach Fails
N = 4 nodes
shard = hash(key) % 4
Add a 5th node:
shard = hash(key) % 5
Now ~80% of keys remap. Cache is cold; rebalancing is catastrophic.
Consistent Hashing — The Ring
Map both nodes and keys onto the same hash space (often 2^64 or 2^32). Each key is owned by the next node clockwise on the ring.
0
●─────────●
╱ A ╲
╱ ╲
● ●
╱ ╲
╱ ╲
● (keys) ●
D B
● ●
╲ ╱
● ●
╲ ╱
╲ C ╱
●─────────●
2^64/2
Node positions = hash(node_id)
Each key → next node clockwise
Adding a Node
Add node X between A and B.
Before: keys in range (A, B] owned by B.
After: keys in range (A, X] owned by X; (X, B] still owned by B.
Only the keys between A and X move from B to X.
Roughly 1/N of total keys move. Other nodes untouched.
Removing a Node
Remove B.
Keys that B owned (between A and B] → now owned by next node clockwise = C.
Only B's keys move; A, C, D untouched.
The Virtual Nodes Problem
Pure consistent hashing with N=3 nodes places 3 points on the ring. Distribution is uneven — a node "lucky" to sit in a sparse region owns much more than its share.
Hash distribution with 3 nodes (random):
node A: 50% of ring
node B: 30%
node C: 20%
Solution: virtual nodes (vnodes). Each physical node owns many positions on the ring:
3 physical nodes × 100 vnodes each = 300 points on ring
Distribution evens out — average per node ≈ 1/3
Cassandra defaults to 256 vnodes/node. Redis Cluster uses 16,384 fixed "hash slots" assigned to nodes (same idea, fixed-size space).
Replication
To replicate K times, each key is owned by the K successive nodes on the ring clockwise from its hash position.
key = X
primary = next node clockwise = A
replica1 = next node after A = B
replica2 = next node after B = C
This is how Cassandra and DynamoDB pick which N replicas hold which key.
Implementation Sketch (Python)
import bisect
import hashlib
class ConsistentHash:
def __init__(self, vnodes=100):
self.vnodes = vnodes
self.ring = [] # sorted list of (hash, node)
self.nodes = set()
def _hash(self, key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)
def add_node(self, node):
self.nodes.add(node)
for i in range(self.vnodes):
h = self._hash(f"{node}:{i}")
bisect.insort(self.ring, (h, node))
def remove_node(self, node):
self.nodes.discard(node)
self.ring = [(h, n) for h, n in self.ring if n != node]
def get_node(self, key):
if not self.ring:
return None
h = self._hash(key)
idx = bisect.bisect(self.ring, (h, ""))
if idx == len(self.ring):
idx = 0
return self.ring[idx][1]
Time complexity:
- Add/remove node: O(V log V).
- Lookup: O(log(N×V)).
Bounded Loads
Vanilla consistent hashing can still have "lucky" hot nodes. Consistent hashing with bounded loads (Google, 2016) caps each node's load:
on add(key):
primary = ring.next_clockwise(hash(key))
if primary.load < threshold:
return primary
else:
return next_clockwise_until_under_threshold()
Used by Vimeo's load balancer, Google's Maglev, several CDNs.
Rendezvous Hashing (Highest Random Weight, HRW)
Alternative without a ring:
for each node n:
score = hash(key, n)
chosen = node with max score
Properties:
- No precomputed ring.
- O(N) per lookup; fine for N ≤ 1000.
- Better statistical distribution than basic CH.
- Used by some CDNs and HAProxy
hashmode.
Real-World Usage
| System | Mechanism |
|---|---|
| Cassandra | Murmur3 partitioner + 256 vnodes per node |
| DynamoDB | Internal consistent hash, masked from user |
| Redis Cluster | CRC16 → 16384 hash slots → assigned to nodes |
| Memcached (client-side) | Consistent hashing in client library |
| Discord | Consistent hashing for guild sharding |
| Cloudflare | Maglev for L4 LB |
Failover
When a node goes down, its keys are taken over by the next node clockwise. With replication (factor K), the replicas already have the data.
After permanent loss + replacement, anti-entropy / streaming repairs the new node's share.
Common Gotchas
| Gotcha | Reality |
|---|---|
| "Just use hash(key) % N" | Re-shards almost everything on resize |
| Too few vnodes | Distribution stays uneven; default to ≥100 per node |
| Different vnode counts per node | Intentional weighting (bigger nodes get more keys) |
Using a poor hash (e.g., id.hashCode() in Java) | Skew; use MD5, Murmur3, xxHash |
| Hashing on a mutable field | Rebalances when value changes |
When NOT to Use Consistent Hashing
- Very small N (3–5 nodes): a simple lookup table is clearer.
- Dynamic shard sizes (massively skewed): use range partitioning with splits.
- Strict capacity caps: use consistent-hashing-with-bounded-loads or a directory layer.
[!NOTE] Consistent hashing is the right answer when you need a stable, low-friction mapping of keys → nodes that survives membership changes. It's overkill if your node set is fixed.
Interview Follow-ups
- "How would you handle a 'hot shard' in a CH cluster?" — Identify hot key via metrics; either pre-route (special-case in client) or split the key (salting) across multiple shards.
- "What's the trade-off of bigger vs smaller vnode count?" — More vnodes = smoother distribution but bigger ring = slower lookup. 100–256 per node is a good sweet spot.
- "How does Redis Cluster decide which node has which slot?" — Operator command (
CLUSTER ADDSLOTS), or auto via tools. Slots are explicit, not hash-position-derived.
Q: Bloom filters and probabilistic data structures — when to use them.
Answer:
Probabilistic data structures answer set-membership and cardinality questions with much less memory than exact structures, at the cost of small, well-defined error rates. Bloom filters are the most famous; their cousins (HyperLogLog, Count-Min Sketch, Cuckoo filters) handle related problems.
Bloom Filter
A bit array + K hash functions. Answers "is X possibly in the set?":
add(x):
for i in 1..K:
bits[hash_i(x) % m] = 1
contains(x):
return all(bits[hash_i(x) % m] == 1 for i in 1..K)
Returns:
- Definitely not in set — fast no.
- Possibly in set — true membership or false positive.
Never false negative. That's the magic.
Math
For m bits, n elements, k hash functions:
False positive rate ≈ (1 - e^(-kn/m))^k
Optimal k ≈ (m/n) × ln 2
Practical numbers:
- For
n = 1M elements,m = 10M bits ≈ 1.25 MB,k = 7→ ~1% false positive. - For 0.1% false positive: ~14 bits per element.
Exact set in a Java HashSet: ~64 bytes per element. Bloom filter: ~1.25 bytes. 50× less memory.
Use Cases
1. Avoid expensive lookups.
on read(key):
if not bloom.contains(key):
return None # definitely not in DB, skip the disk read
val = db.get(key) # might be present, look up
return val
Cassandra puts a bloom filter in front of every SSTable. Saves disk I/O on misses.
2. CDN / cache existence checks.
"Has user X ever visited this URL?" Bloom filter answers in memory; only fetch DB on possible hit.
3. Distributed system membership.
A node gossips a bloom filter of its keys; peers check before requesting.
4. Bad URL / malware lists.
Browsers carry a bloom filter of malicious URLs; on potential hit, do a real lookup.
5. Network deduplication.
"Have I sent this packet?" before retransmission.
Limitations
- No delete (basic version). Setting a bit to 0 affects other items that hashed to it.
- False positives, no false negatives.
- Sizing is fixed at construction; resize means rebuild from source.
Variants
| Variant | Adds |
|---|---|
| Counting Bloom filter | Each "bit" is a small counter; supports delete |
| Cuckoo filter | Same FPR with smaller size + supports delete + better cache locality |
| Scalable Bloom filter | Adds new layers as it fills; supports growth |
| Partitioned Bloom filter | k disjoint regions, one per hash function |
In practice, Cuckoo filters are often a better modern choice — smaller, deletable, similar FPR.
HyperLogLog (HLL)
Counts unique items ("cardinality") with sub-linear memory.
add(x):
hash x
look at leading zeros in hash
update register based on max-leading-zeros seen
count_estimate():
aggregate registers → estimate cardinality
With 16 KB of memory, estimates billions with ~1% error.
Used by:
- Distinct visitor counts.
- "Unique users this hour."
- Redis
PFADD/PFCOUNT. - Druid / Pinot / BigQuery
APPROX_COUNT_DISTINCT.
Doesn't tell you which items; only how many distinct.
Count-Min Sketch
Approximate frequency of items in a stream. "How many times has this key appeared?"
add(x): increment 4 different buckets (one per hash function)
count(x): return min across the 4 buckets
Properties:
- Overestimates (never under).
- Constant memory per bucket grid.
- Used for: heavy-hitters detection, top-K queries, traffic profiling.
Redis Stream XADD + custom; ClickHouse topK; some load balancers use it for hot-key detection.
Tunable Sketches
t-digest, q-digest: approximate quantiles (percentile distributions) over streams. The right answer for high-cardinality timeseries percentile aggregation.
Used by Datadog, Elastic, Prometheus (histogram_quantile).
Use-Case Cheat Sheet
| Question | Structure |
|---|---|
| Is X in this set? | Bloom / Cuckoo filter |
| How many distinct items? | HyperLogLog |
| How many times has X appeared? | Count-Min Sketch |
| What are the heavy hitters? | Top-K via Count-Min |
| What's the p99 of a stream? | t-digest |
| What's the median? | t-digest or order statistics tree |
Practical Sizing Example
100M unique events/hour, need to dedup:
- Exact set (HashSet): 100M × 64 bytes = 6.4 GB per hour. Painful.
- Bloom filter @ 1% FPR: 100M × 1.25 bytes = 125 MB per hour. Comfortable.
- Bloom filter @ 0.001% FPR: 100M × 2.5 bytes = 250 MB. Still fine.
Pick FPR based on cost of a false positive in your use case.
Common Mistakes
| Mistake | Fix |
|---|---|
| Treating Bloom filter as exact | False positives are guaranteed — design around them |
| Forgetting to size for growth | Plan capacity; use scalable BF or rebuild on threshold |
| One global filter for all keys | Hot key thrashes the bit array; shard or partition |
| Using HLL when you need exact counts | Approximate is the point; otherwise use a real counter |
| Bloom filter without a real-lookup fallback | A hit must be verified; otherwise it's not safe |
[!NOTE] Sketches save memory in exchange for accuracy. Most analytics dashboards don't need 7-decimal precision; pick the right structure, free up gigabytes.
Interview Follow-ups
- "How does Cassandra use Bloom filters?" — Per-SSTable filter; skip SSTables that definitely don't contain the key during reads.
- "How accurate is HyperLogLog at low cardinality?" — Bias-corrected variants (HLL++) handle small counts; vanilla HLL overestimates < 1000.
- "Could you replace a Bloom filter with a cache?" — A LRU cache of recent items is exact but limited. Bloom filter handles arbitrarily many items at the same memory.
Q: Designing geo-distributed systems — patterns for multi-region.
Answer:
Going from one region to many trades latency (closer to users) for complexity (distributed state, replication, conflict resolution). Most services start single-region and only multi-region when justified.
Why Multi-Region
- Lower latency for users worldwide (TCP RTT vs same continent).
- Disaster recovery (regional outages).
- Data residency (EU users' data must stay in EU).
- Compliance (HIPAA, FedRAMP).
Cost: 2–5× infra, dramatically more engineering complexity.
The Four Common Topologies
1. Single Region, Multi-AZ
Most apps. AZs are isolated within one region; survive single-AZ outage.
Latency: ~ms within region.
Capacity: limited by region's total.
2. Active-Passive (DR)
Region A (primary) Region B (standby)
▲ ▲
│ all traffic │
client (hot standby, cold standby)
All traffic hits primary. Standby kept up-to-date via async replication.
On primary failure: DNS / Anycast cuts over.
RTO: minutes (manual or automated). RPO: replication lag (seconds typical).
3. Active-Active (Geo-Distributed)
Region A Region B Region C
▲ ▲ ▲
│ traffic from │ traffic from │ traffic from
│ Americas │ Europe │ Asia
client client client
Each region serves nearby users. Data replicated across regions.
Key challenge: cross-region writes.
- Sync replication: cross-region RTT (80–150 ms) per write. Often too slow.
- Async replication: writes succeed locally, replicate later. Risk of conflicts.
4. Region-Partitioned
User data is partitioned by region; each user has a "home region."
US users → US cluster (all their data)
EU users → EU cluster
APAC users → APAC cluster
No cross-region writes for typical traffic. Travelers may hit a far region for their own data (longer latency).
Used by: Slack, Stripe, Cloudflare (partial), banks (mandated by regulation).
Routing Users
GeoDNS: DNS server returns nearest region's IP based on resolver IP.
- Coarse; works at country level.
- Caches limit failover speed.
Anycast: same IP advertised from multiple PoPs via BGP. Internet routes to nearest.
- Sub-second failover.
- Used by Cloudflare, AWS Global Accelerator, Google.
Client-side: app pings several regions, picks fastest.
- Most accurate.
- Slow first request.
Production: layered. Anycast for L4 entry; app routes to nearest data home.
Data Replication Patterns
| Pattern | Latency | Consistency | Use |
|---|---|---|---|
| Sync cross-region (Spanner) | High write | Strongest | Financial, must-be-consistent |
| Async (Cassandra cross-DC, Postgres logical) | Local-fast | Eventual | Most |
| Multi-leader (e.g., DynamoDB Global Tables) | Local-fast | LWW | Independent regional state |
| Region-partitioned + no replication | Local-fast | Strong per partition | Per-user data with home region |
Conflict Resolution
For multi-leader / active-active:
- LWW (last-write-wins): simple, lossy.
- App-level merge: define per type (union for sets, max for counters).
- CRDTs: mathematically merge-safe.
- Vector clocks: detect concurrent writes; punt to app.
LWW is fine for some workloads (e.g., session prefs), wrong for others (financial).
What Data Goes Where
Categorize your domain:
| Data class | Where |
|---|---|
| Per-user (profile, history) | User's home region; replicate elsewhere if user travels |
| Global lookup (product catalog) | Replicated globally; eventual is fine |
| Truly global ledger (payments) | Distributed SQL (Spanner) or single primary with regional read replicas |
| Compliance-bound (EU data) | Pinned to region by law |
| Operational telemetry | Per-region, aggregated centrally |
Failover and Disaster Recovery
For active-passive:
- Replication lag monitored.
- Quarterly failover drills.
- Documented runbook.
- DNS TTL ≤ 60 seconds.
- Standby region kept warm.
For active-active:
- Each region capacity-sized to absorb peer load (e.g., 3 regions, each sized for ~50% of total).
- Traffic routing must remove failed region quickly.
- Watch for ingress storms when traffic shifts.
Data Residency
For EU users:
- Personal data stored in EU region.
- Audit trail of where data flowed.
- Sub-processors disclosed.
GDPR enforces this; HIPAA, China cybersecurity law, India DPDP do similar things.
Technical: tag data with origin; route storage / processing to correct region; ensure backups don't leak.
Multi-Region Cost
- Compute: 2-3× single region.
- Egress between regions: very expensive ($0.02-$0.09/GB).
- Storage: replicated → 2-3× storage cost.
- Engineering: arguably the biggest line.
Don't go multi-region without a clear ROI.
Common Mistakes
| Mistake | Reality |
|---|---|
| Sync replication across continents | Write latency dominates SLA |
| LWW on multi-region for financial data | Silent data loss |
| Two regions with no failover plan | Disaster doesn't follow your schedule |
| Same database backing both regions over WAN | DB latency kills app |
| Forgetting compliance | Big-deal lawsuits, not just bugs |
[!NOTE] Multi-region is one of the highest-ROI levers for the right workload and one of the most expensive distractions for the wrong one. Justify with users, latency, compliance, or DR; don't do it because "it sounds modern."
Interview Follow-ups
- "How does Spanner support multi-region writes?" — Each "split" (range) is a Paxos group spanning regions; writes commit when majority acks. TrueTime ensures external consistency.
- "How would you handle a user who moves from US to EU?" — Migrate their data to new home region; route from then on. Edge case; usually rare enough to script.
- "What is 'follow-the-sun'?" — Different regions active at different times of day; route to wherever it's daytime. Old pattern; modern systems mostly all-active.
Q: OAuth 2.0 and OpenID Connect — what they are and how they differ.
Answer:
OAuth 2.0 is an authorization framework — it lets a user grant a client app limited access to their resources on another server. OIDC layers authentication on top — it tells the client app who the user is.
OAuth is "what can you do?" OIDC is "who are you?"
Roles
- Resource Owner: the user.
- Client: app wanting access (your web app, mobile app).
- Authorization Server: issues tokens (Auth0, Okta, Google, Cognito).
- Resource Server: API that consumes tokens.
OAuth 2.0 Flows
Authorization Code (with PKCE)
The recommended flow for web/mobile apps:
1. User clicks "Sign in with Google"
2. Client redirects to authorization server with PKCE challenge:
https://auth/authorize?client_id=...&code_challenge=...
3. User authenticates with Google, grants permission.
4. Auth server redirects back with code:
https://app/callback?code=abc123
5. Client exchanges code + verifier for token (server-side):
POST /token { code, code_verifier, client_id }
→ { access_token, refresh_token, id_token (OIDC) }
6. Client uses access_token on API calls.
PKCE (Proof Key for Code Exchange) prevents token interception in mobile apps where the redirect URI isn't fully protected.
Client Credentials
Service-to-service. No user involved.
POST /token { client_id, client_secret, grant_type=client_credentials }
→ { access_token }
For backend daemons, server APIs.
Device Code
CLI tools, smart TVs.
1. Device shows user a code + URL.
2. User opens URL on phone, logs in, enters code.
3. Device polls token endpoint until authorized.
Deprecated / Don't Use
- Implicit grant: tokens in URL fragment; insecure; replaced by Code + PKCE.
- Resource owner password credentials: client collects user password; defeats SSO; don't use.
Tokens
Access Token: bearer credential for API calls. Short-lived (5–60 min). Opaque or JWT.
Refresh Token: long-lived (days, weeks). Used to get new access tokens. Should be revocable.
ID Token (OIDC only): signed JWT containing user identity claims. For the client to identify the user; not for API authorization.
JWT Anatomy
header.payload.signature
header: { "alg": "RS256", "kid": "abc" }
payload: { "sub": "user123", "exp": 1700000000, "iss": "...", ... }
signature: RSA-SHA256(header + "." + payload, private_key)
Verifier checks signature against issuer's public key (JWKS endpoint).
Claims:
iss: issuer URL.sub: subject (user ID).aud: intended audience.exp: expiry.iat: issued at.nbf: not-before.scope: granted scopes.
Scopes
Restrict what the token can do:
scope=read:orders write:orders admin:users
Resource server checks scopes before serving.
OIDC standard scopes:
openid: required for ID token.profile: name, picture.email: email + verified flag.offline_access: refresh tokens.
OIDC vs SAML
- OIDC: modern, JSON/JWT, REST-friendly. Mobile and SPA support.
- SAML: XML-based, enterprise SSO, older.
For new systems: OIDC. SAML still common in enterprises.
Token Storage in Browser
- Access token in memory: safest. Lost on refresh; refetch via cookie session.
- HTTP-only cookie: server sets cookie; protected from XSS.
- localStorage: vulnerable to XSS. Don't do this.
- sessionStorage: same vulnerability, scoped to tab.
Modern: BFF (Backend-for-Frontend) pattern: cookie session to BFF; BFF holds tokens; SPA never sees them.
Token Revocation
Access tokens: short-lived; "revocation" effectively waits for expiry.
Refresh tokens: must be revocable.
- Auth server tracks issued refresh tokens.
- Client calls
/revokeon logout. - Lookup on each refresh; reject if revoked.
JWT-only systems without a revocation store can't truly revoke; mitigate with very short token TTLs.
Common Mistakes
| Mistake | Reality |
|---|---|
| Using OAuth for login (instead of OIDC) | OAuth was never designed for authentication |
| Storing tokens in localStorage | XSS exfiltration |
Trusting iss without checking against expected | Token-injection attack |
| Long-lived access tokens | Stolen token usable for months |
Skipping aud claim verification | Token meant for service A used at B |
| Implicit flow in 2025 | Deprecated; use Code + PKCE |
Production Architecture
client (SPA / mobile)
│
▼ user logs in via redirect
Auth server (Auth0, Okta, custom)
│ returns tokens
▼
client uses access_token → API gateway → backend services
│
▼
token validator (cached JWKS)
API gateway validates JWT once; injects authenticated user context to backend.
Federation
OIDC supports SSO across providers:
client → your-auth-server → "log in with Google/Microsoft/Apple"
↓
Google IdP → tokens
↓
your-auth-server reissues your token
User logs in to one IdP; trusted by all federated apps.
[!NOTE] Use a provider. Auth0, Cognito, Keycloak, Okta. Implementing your own OAuth server is a multi-quarter security investment. Worth it only at very specific scales.
Interview Follow-ups
- "What's the difference between authentication and authorization?" — Authn = "who are you" (OIDC). Authz = "what can you do" (OAuth scopes + custom policies).
- "How would you implement role-based access?" — Roles in JWT claims; backend reads
rolesclaim + per-endpoint check. Or external policy service (OPA). - "What is OAuth 2.1?" — A consolidated update of OAuth 2.0 + best practices: requires PKCE everywhere, deprecates implicit, no password grant. Production default.
Q: JWT vs Session Cookies — picking auth state.
Answer:
Two ways to track "this request belongs to user X":
- Session cookie: opaque ID; server stores the session in DB/Redis.
- JWT: signed self-contained token; server reads claims directly.
JWT is hyped; session cookies are often the better choice for browser apps. Use the right tool.
Session Cookies
1. Client logs in.
2. Server creates session: { user_id: 42 } stored in Redis/DB with TTL.
3. Server sets HTTP-only cookie: Set-Cookie: sid=abc123
4. Browser sends cookie on every request.
5. Server looks up session by sid → has user identity.
Properties:
- Stateful server: session store required.
- Trivial revocation:
DELETE FROM sessions WHERE id = ?. - Small payload: opaque ID, ~30 bytes.
- Browser-native: cookies + same-origin → no XSS exposure (HTTP-only).
JWT
1. Client logs in.
2. Server signs JWT: { sub: 42, exp: ... }
3. Client stores it (in cookie, header, or memory).
4. Client sends JWT in Authorization header on each request.
5. Server verifies signature; reads claims; no DB lookup.
Properties:
- Stateless server: no session store.
- Self-contained: claims travel with the token.
- Bigger payload: 200–1000 bytes.
- Hard to revoke: token valid until expiry unless you add a blocklist.
When To Pick Which
| Scenario | Choice |
|---|---|
| Browser-based monolith / SPA | Session cookies |
| Mobile API client | JWT or opaque token |
| Cross-domain / federated SSO | JWT (OIDC) |
| Server-to-server (service-to-service) | JWT or mTLS |
| Microservices with API gateway | JWT validated at gateway |
| Need fine-grained revocation | Session cookies or short-lived JWT |
| 99% of web apps | Session cookies |
Why JWTs Are Often The Wrong Default
-
No real revocation. Token issued; valid until exp. You can add a blocklist, but now you have stateful sessions — defeats the purpose.
-
Token bloat. Embed roles, claims → 1 KB token sent on every request. Network cost.
-
XSS exfiltration risk. Stored in JS-accessible memory → XSS = token theft.
-
Refresh-token complexity. Short-lived access + long-lived refresh = two tokens to manage.
-
Key rotation pain. JWT signature key change requires careful rollout.
Session cookies are battle-tested for browser apps. JWTs solve a different problem (stateless federated identity).
The Hybrid: BFF Pattern
For SPAs:
SPA ─── session cookie ─── Backend-for-Frontend
│
│ holds OAuth tokens
▼
Backend services
SPA only knows about cookies. BFF holds the JWT to call backends. Best of both:
- No JWT exposed to browser (XSS-safe).
- Stateless backend services (BFF passes JWT).
Revocation
Session cookie: just delete the session.
JWT: options:
- Short TTL (5–15 min): revocation = wait it out.
- Blocklist: add jti to revoked-list (DB/Redis); check on each request — defeats statelessness.
- Token versioning: token has user version; bump on logout; check at request.
Choose by how fast you must revoke.
Common Mistakes
| Mistake | Reality |
|---|---|
| JWT in localStorage | XSS bait |
JWT with alg: none | Forgery; libraries used to accept this |
| JWT secret in client | Defeats signing |
Session cookie with no SameSite=Lax/Strict | CSRF |
| Forever-valid JWT | Stolen token = forever access |
Verifying JWT signature with HS256 shared secret in distributed system | Hard to rotate; use RS256 (asymmetric) |
Security Headers for Cookies
Set-Cookie: sid=abc;
HttpOnly; ← inaccessible to JS
Secure; ← only over HTTPS
SameSite=Lax; ← CSRF defense
Max-Age=86400;
Path=/;
Domain=example.com
JWT Best Practices
- Use RS256 or EdDSA asymmetric signing (not HS256 for multi-service).
- Short TTL (≤ 15 min) + refresh token.
- Verify
iss,aud,exp,nbfon every call. - Cache JWKS; rotate keys without downtime.
- Never accept
alg: none.
Sliding Sessions
Session-cookie variant: extend session expiry on each request (within reason).
User stays logged in as long as they're active. Common UX.
JWT equivalent: refresh on activity. More complex.
[!NOTE] JWTs are great for stateless federated identity (OIDC). They're poor for browser session management, even though tutorials often misuse them. Pick by the actual problem.
Interview Follow-ups
- "Why not store JWT in localStorage?" — XSS reads it. Always cookie (HttpOnly) or memory.
- "How does CSRF apply to JWT?" — If JWT in cookie: same CSRF concerns as session — add
SameSiteand CSRF tokens. If in Authorization header: JS must add it, so no CSRF risk (XSS instead). - "What's session fixation?" — Attacker sets victim's session ID before login; if you don't rotate session ID on auth, attacker can log in as victim. Always regenerate.
Q: Encryption at rest, in transit, end-to-end — what each protects against.
Answer:
Three different threats, three different solutions:
- Encryption in transit (TLS): protects from eavesdroppers on the wire.
- Encryption at rest: protects from someone with disk / backup access.
- End-to-end encryption (E2EE): protects from the server itself.
Get them confused and you'll have false confidence.
Encryption in Transit (TLS)
Almost ubiquitous. Browser ↔ server, service ↔ service.
TLS handshake:
client ─── ClientHello (cipher suites) ──► server
client ◄─── ServerHello + cert ── server
client validates cert (chain to a CA root)
ECDHE key exchange → shared session key
Encrypted application data flows
Modern TLS 1.3:
- AEAD ciphers (AES-GCM, ChaCha20-Poly1305).
- Perfect Forward Secrecy mandatory.
- Faster handshake (1-RTT, 0-RTT for resumed sessions).
What it protects:
- Network sniffers, MITM (if cert verified).
- ISP / proxy observation.
What it doesn't protect:
- Server logs (server sees plaintext).
- Storage (decrypted before storage).
Certificates & PKI
- CA signs server cert.
- Browser trusts CA root.
- Cert validation: chain + hostname + not expired + not revoked.
Let's Encrypt: free certs via ACME protocol; 90-day rotation, automated.
mTLS (mutual TLS): both client and server present certs. Used service-to-service.
Encryption at Rest
Encrypt data on disk so theft of disk / backup is useless.
| Level | Mechanism | What it protects |
|---|---|---|
| Disk encryption | LUKS, BitLocker, AWS EBS encryption | Physical disk theft |
| Filesystem encryption | ZFS native, eCryptfs | Same |
| Application-level: Transparent | Postgres TDE, MySQL TDE | DB files on disk |
| Field-level | App encrypts specific fields before writing | DB compromise, internal access |
A running process still sees plaintext. Doesn't protect against:
- Live attacker on the machine.
- DB credentials leak.
- SQL injection.
Key Management
The hard part of crypto isn't algorithms; it's keys.
- KMS (Key Management Service): AWS KMS, Google Cloud KMS, Azure Key Vault, HashiCorp Vault.
- Envelope encryption: data encrypted with data-key (random); data-key encrypted with master-key (KMS).
- Key rotation: ability to re-encrypt with a new key without downtime.
- HSM: hardware module storing the master key. Used for high-assurance keys.
Never check keys into source. Never log keys. Limit who can access KMS.
End-to-End Encryption (E2EE)
Server can't read the data. Only sender and recipient have keys.
Sender A Server Recipient B
│ encrypt(msg, B's pubkey) │ decrypt(ct, B's privkey)
▼ ▲
ciphertext ────────► relay ────────► ciphertext
│
server sees: random bytes
Used by: Signal, WhatsApp, iMessage, ProtonMail, Apple iCloud Advanced Data Protection.
Properties:
- Server compromise = blobs only.
- Server can't deliver "search inside content."
- Server can't recover lost passwords (data lost too).
- Key management = user's problem.
Signal Protocol (E2EE)
Forward-secret, multi-device, post-compromise security.
- X3DH: initial key agreement using prekeys.
- Double Ratchet: per-message key derivation; symmetric + DH ratchets.
- Each message has a unique key; lost keys can't decrypt past messages (forward secrecy).
- Compromised key gets healed as both sides ratchet (post-compromise security).
WhatsApp, Signal Messenger, parts of Facebook Messenger, Google Messages.
Database Encryption Modes
| Mode | Description |
|---|---|
| TDE (Transparent Data Encryption) | DB encrypts at rest; queries unchanged |
| Column-level | Specific columns encrypted by DB (Postgres pgcrypto, SQL Server Always Encrypted) |
| App-level | App encrypts fields before write; DB sees ciphertext |
| Searchable encryption | Specialized; supports limited query patterns over ciphertext |
App-level encryption protects against DB compromise but loses indexability. Compromise: encrypt sensitive fields only (PII, SSN), keep indexes on hashed/tokenized forms.
Tokenization
Replace sensitive value with random token; map kept in a secured vault.
real CC: 4111-1111-1111-1111
token: tok_abc123 ← stored in your DB
vault: tok_abc123 → 4111-1111-1111-1111 (secured)
Used by PCI-compliant systems to avoid storing card numbers. Vault becomes the only PCI scope.
Hashing for Passwords
Never store passwords plain or encrypted. Hash with a slow KDF:
- bcrypt: legacy, still acceptable.
- scrypt: memory-hard.
- Argon2id: modern default.
password → argon2(password, salt, memory=64MB, iterations=3) → hash
Per-password salt mandatory; per-system pepper recommended.
Common Mistakes
| Mistake | Reality |
|---|---|
| TLS but no cert validation | MITM trivial; verify always |
| AES-ECB | Identical blocks → patterns. Use GCM. |
| MD5 / SHA-1 for new code | Broken; use SHA-256+ |
| Storing AES key alongside ciphertext | Defeats the purpose |
| Implementing your own crypto | Always use a vetted library |
| Encrypting then HMAC vs HMAC-then-encrypt | Order matters; use AEAD (GCM) to avoid the choice |
| Encrypting at rest "because compliance" while plaintext logs leak | Cover the whole data flow |
Threat Model First
Before encrypting:
- Who's the adversary?
- What can they reach?
- What's the cost of breach vs encryption overhead?
A startup probably needs TLS + KMS + bcrypt + HTTP-only cookies. Not Signal Protocol.
[!NOTE] Encryption is one piece of security; access control, monitoring, auditing matter equally. A perfectly encrypted database is undone by an over-privileged service account that decrypts and exports nightly.
Interview Follow-ups
- "How would you encrypt user PII while allowing search?" — Encrypt PII field; for search, store HMAC of normalized value as separate column. Lookups work; raw value protected.
- "What's the difference between symmetric and asymmetric encryption?" — Symmetric: same key both ways (AES). Asymmetric: keypair (RSA, EC). Symmetric is fast; asymmetric is used for key exchange + signatures.
- "What is forward secrecy?" — Compromising long-term key today doesn't decrypt past sessions. TLS 1.3 enforces it.
Q: DDoS protection and rate limiting strategies.
Answer:
DDoS (Distributed Denial of Service) attacks overwhelm services with traffic. Modern mitigation is layered: edge, WAF, rate limit, bot detection, load shedding. Each catches different attacks.
Attack Categories
Volumetric (L3/L4):
- Goal: saturate the network pipe.
- Examples: UDP flood, ICMP flood, amplification (DNS, NTP).
- Defense: ISP-level / CDN absorption (Cloudflare, AWS Shield).
Protocol (L4):
- Goal: exhaust connection / state tables.
- Examples: SYN flood, ACK flood.
- Defense: SYN cookies, connection-rate limits.
Application (L7):
- Goal: exhaust app capacity with valid-looking requests.
- Examples: HTTP flood, slowloris, Layer-7 logical attacks.
- Defense: rate limit, CAPTCHA, behavioral analysis.
Defense In Layers
Internet
│
▼
Anycast L4 absorb ←── volumetric (CDN/CloudFront, Cloudflare)
│
▼
WAF ←── known bad patterns, OWASP Top 10
│
▼
Bot detection ←── JS challenge, fingerprinting
│
▼
Per-IP / per-key rate ←── token bucket / sliding window
│
▼
Per-tenant / per-user ←── quotas
│
▼
Service ←── load shedding when over capacity
Each layer drops a portion. The deeper, the smarter. The wider the funnel at top, the better.
Edge Absorption
For volumetric attacks: only the network can stop it. Cloud providers:
- AWS Shield (Standard free, Advanced paid).
- Cloudflare DDoS Protection (Free tier substantial).
- GCP Cloud Armor.
- Akamai Prolexic.
They have terabits of capacity at PoPs across the world. Your origin couldn't handle 1% of what they absorb.
Web Application Firewall (WAF)
Rule-based filtering:
- SQL injection patterns.
- XSS attempts.
- Path traversal.
- Known-bad User-Agents.
- Geographic blocks (if your service isn't global).
- Custom rules per app.
Examples: Cloudflare WAF, AWS WAF, Imperva. Managed rulesets (OWASP Core Rule Set) catch common attacks.
Rate Limiting
For application-level brute force / scraping:
- Per IP.
- Per API key.
- Per endpoint.
- Per tenant.
Catches: brute-force login, credential stuffing, scraping, runaway clients.
Bot Detection
Distinguish real users from bots:
- JS challenge: serve JS that computes a token; humans pass, simple bots can't.
- TLS / TCP fingerprinting: real browsers have specific TLS signatures (JA3 hash).
- Behavioral: mouse movement, request pacing.
- CAPTCHA: last resort; high friction.
- Device fingerprinting: canvas, fonts, screen size.
Services: Cloudflare Bot Management, Akamai Bot Manager, reCAPTCHA, hCaptcha.
Application Layer Hardening
Connection limits:
- Max concurrent per IP.
- Max in-flight per IP.
- TCP keepalive tuning.
Slowloris defense:
- Read header timeout (5–10 s).
- Max request size.
- Max request duration.
Per-endpoint capacity:
- Heavy endpoints get tighter limits.
- Expensive operations require auth + lower per-user rate.
Authentication Defense
Credential stuffing: attackers use leaked credentials elsewhere.
- Rate-limit login per IP + per username.
- Require CAPTCHA after N failures.
- Detect impossible travel (login from two distant locations within minutes).
- MFA for accounts with elevated privileges.
Account enumeration: don't disclose "user exists" via different error messages.
Load Shedding (When All Else Fails)
When you're being legitimately overwhelmed:
- Shed low-priority traffic first.
- Return clear
503withRetry-After. - Stay alive over serve-perfectly.
See Graceful Degradation.
Costs of Defense
- DDoS service: $20–200K/year at enterprise scale.
- WAF: ~$0.06 per million requests + per-rule cost.
- Bot mitigation: subscription.
- Engineering: incident-response runbook + rehearsals.
For small services: Cloudflare free tier covers most. For enterprise: dedicated DDoS package + WAF.
Detection & Response
Monitor:
- Connection rate.
- Request rate per source.
- 4xx error rate (failed auth = brute force).
- Latency spike (under-provisioned).
Automated responses:
- Auto-block IP if rate > threshold.
- Trigger CAPTCHA challenge.
- Shift to "challenge all" mode at edge.
Runbook for: who decides to block a country, when to engage AWS Shield Response Team, etc.
Common Mistakes
| Mistake | Reality |
|---|---|
| Origin IP exposed | Bypass CDN; attacker hits origin directly |
Trusting Remote-Addr behind proxy | Use X-Forwarded-For with care (verify proxy) |
| Rate-limit only at one layer | Defense in depth — layer them |
| Blocking IPs forever | Botnets cycle IPs; targets shared NATs |
| No CAPTCHA challenge tier | Pure block kills false positives; challenge gives human-vs-bot disambiguation |
Hide Origin
If origin IP is in DNS, attackers bypass your CDN.
- Use a CDN-managed IP.
- Move DNS A records to CDN; origin only accessible via signed CDN routes.
- Firewall: drop traffic not from CDN's IP ranges.
Common Attack Patterns
- Layer 7 GET flood: high QPS to
GET /from many IPs. Mitigate: edge + per-IP rate + WAF. - Reflection attack: spoofed source IP makes amplifier (DNS, NTP) flood you. ISP-level only.
- Application bug DDoS: one specific endpoint exhausts DB. Code fix + rate limit on that endpoint.
- Coordinated bot scraping: many IPs, low individual rate. Bot detection + fingerprinting.
[!NOTE] The first line of defense against DDoS is being behind a major CDN/edge provider. The actual rules are the second line. If you don't have edge absorption, you can't run a public service safely.
Interview Follow-ups
- "What if an attacker uses 100k legitimate residential IPs?" — Per-IP rate limit useless; rely on behavioral + bot fingerprinting + CAPTCHA tiering.
- "How do you protect a write endpoint from being abused?" — Authentication required + per-user quota + cost-per-request analysis + abuse-pattern detection.
- "What's a SYN cookie?" — Server doesn't allocate state on SYN; encodes connection info in initial sequence number. Defeats SYN flood without tracking half-open connections.
Q: Metrics, logs, traces — the three pillars of observability.
Answer:
Three complementary telemetry types:
- Metrics: aggregate numbers over time. Cheap, low resolution.
- Logs: discrete event records. Rich, expensive.
- Traces: causally-linked spans across services. Best for "where did time go?"
Modern observability uses all three, each for what it does best.
Metrics
Time series of aggregated values:
http_requests_total{service="orders", status="200"} → counter
http_request_duration_seconds → histogram
queue_depth → gauge
Properties:
- Cheap: ~1 byte/sample after compression.
- Aggregated: lose per-event detail.
- Bounded cardinality: number of unique series.
- Fast queries over long time ranges.
Use for: dashboards, SLO tracking, alerting, rate-of-change.
Tools: Prometheus, Datadog, Grafana Cloud, M3DB, VictoriaMetrics.
See Metrics System.
Logs
Discrete events with arbitrary structure:
{
"ts": "2025-...",
"level": "ERROR",
"service": "orders",
"trace_id": "...",
"user_id": "u42",
"msg": "payment declined",
"decline_code": "insufficient_funds"
}
Properties:
- High volume: 1 KB / event typical.
- Expensive to store.
- Rich detail: full per-event context.
- Slow to query over long ranges.
Use for: debugging, audit, forensic analysis, low-frequency events.
Tools: Elasticsearch, Loki, Splunk, Datadog Logs, CloudWatch Logs.
Traces
A trace = one user request as it flows through services. Each service contributes a span; spans are nested:
Trace: 5fcd...
┌──── span: gateway (200ms)
│
├──── span: orders-service (180ms)
│ │
│ ├──── span: db-query (15ms)
│ └──── span: payments-service (140ms)
│ │
│ └──── span: stripe-api (130ms)
│
└──── span: emit-event (5ms)
Properties:
- Tells you where time went.
- Per-request, high cardinality.
- Stored sparse (usually sampled).
- Linked to logs via trace ID.
Use for: latency root-cause, distributed debugging.
Tools: Jaeger, Tempo, Datadog APM, Honeycomb, AWS X-Ray.
When To Use Each
| Question | Pillar |
|---|---|
| "What's my error rate?" | Metrics |
| "What's slow today vs last week?" | Metrics |
| "What did this specific failed request do?" | Trace + logs |
| "Why is p99 latency rising?" | Trace (find slow spans) |
| "Did this rare event happen?" | Logs |
| "What's the queue depth right now?" | Metrics |
| "Which user is affected?" | Logs / traces |
Each pillar narrows the search differently. Metrics signal "something is off"; trace shows where; logs reveal what specifically.
OpenTelemetry
Vendor-neutral SDK + protocol for all three pillars:
app instrumented with OTel
│
▼
OTel Collector (filter, batch, route)
│
├──► metrics → Prometheus
├──► traces → Tempo / Jaeger
└──► logs → Loki / ES
You write SDK code once; swap backends freely. Modern default for new systems.
Correlation
The magic is trace ID propagation:
client → gateway → service A → service B
│ │ │
▼ ▼ ▼
trace_id: abc trace_id: abc trace_id: abc
Every log line, metric, span shares the trace_id. Click a trace → see all logs from that request. See a slow query → find which user.
Standard: W3C Trace Context (traceparent header).
Sampling
100% trace collection is too expensive at scale. Sampling:
- Head sampling: gateway decides upfront (e.g., 1% of traffic).
- Tail sampling: collect all; decide after seeing the trace (keep errors, slow, ...).
Head sampling is cheap but you might miss the trace that matters. Tail sampling is smarter but needs a buffer (memory) at the collector.
Cardinality and Cost
| Pillar | Cardinality risk | Cost driver |
|---|---|---|
| Metrics | Series count (label combos) | Storage of TSDB |
| Logs | None — every event is a row | Storage + query CPU |
| Traces | Trace IDs (sampled) | Storage of spans |
Metrics break with high-cardinality labels (don't put user_id). Logs and traces tolerate cardinality but cost more.
SLO-Backed Alerts
Don't alert on every spike. Alert on SLO burn rate (see SLI/SLO/SLA):
- p99 latency violating SLO for sustained period.
- Error rate exceeds budget burn rate.
Metrics drive these alerts. Traces help root-cause.
Common Mistakes
| Mistake | Reality |
|---|---|
| Replacing metrics with logs ("just grep!") | Logs don't aggregate cheaply; latency comparisons painful |
| Replacing logs with metrics | Can't reconstruct a specific event |
| Logging at INFO every request | Volume explosion; sample or move to traces |
| No trace propagation across services | Can't see the request flow |
| High-cardinality labels in metrics | OOM the TSDB |
| Different log formats per service | Can't query unified |
Engineering Standard
A production-grade service emits all three:
metrics:
http_requests_total{service, route, status}
http_request_duration_seconds_bucket{...}
logs:
structured JSON with trace_id, request_id, user_id
traces:
per-RPC spans with attributes (service, method, target)
errors annotated
OpenTelemetry SDK handles all three automatically for typical frameworks.
[!NOTE] The three pillars are not a buffet — they're complementary. Mature teams use all three for distinct purposes. Replacing one with another is a sign of underbuilt observability.
Interview Follow-ups
- "What's the cheapest pillar?" — Metrics, by orders of magnitude. Use them as the alerting layer.
- "How do you investigate a 'why is the system slow today?' question?" — Start metrics (find which service / endpoint slowed). Pull traces from that service to find which span. Click into logs of the slow span for detail.
- "What is eBPF observability?" — Kernel-level tracing without app instrumentation. Tools: Pixie, Cilium Hubble. Promising; still maturing.
Q: Distributed tracing — how it works, what to watch for.
Answer:
A trace follows one request across services. Implemented via propagating a trace context through every call. Critical for understanding latency and failures in microservice systems.
The Data Model
Trace = collection of Spans
Span:
trace_id (unique per request)
span_id (unique per operation)
parent_span_id (causal parent)
name ("GET /orders/123")
start_time
end_time
attributes ({ http.status: 200, db.statement: ... })
events (logs scoped to this span)
status (OK / Error)
Spans nest into a tree.
Propagation
The trace context travels in headers (HTTP, gRPC, Kafka messages).
W3C standard:
traceparent: 00-<trace-id>-<span-id>-01
tracestate: vendor-specific
Every service-to-service call must propagate or context is lost. Most SDKs do this automatically.
Instrumentation
Automatic:
- HTTP middleware wraps incoming requests; starts a server span.
- HTTP/gRPC client wraps outgoing requests; starts a client span as child of current.
- DB driver instrumented; adds a query span.
- Message-queue producer adds trace context to header; consumer continues.
Manual:
with tracer.start_as_current_span("compute_recommendations") as span:
span.set_attribute("user.id", user_id)
span.set_attribute("candidate_count", len(candidates))
result = score(candidates)
Don't go crazy. Auto-instrument the framework + RPC; manually add only what's interesting.
Storage and Display
Spans are sent to a collector (OTel Collector) and stored in a backend (Tempo, Jaeger, Datadog APM, X-Ray, Honeycomb).
Backend stores by trace_id; query gives:
Timeline view:
0ms ─┬─ gateway (200ms)
│
├─ 5ms: orders-service (180ms)
│ │
│ ├─ 6ms: db query (15ms)
│ └─ 22ms: payments call (140ms)
│ │
│ └─ 30ms: stripe API (130ms)
│
└─ 195ms: emit event (5ms)
Visualizing a trace is half the value — "the request spent 130ms in Stripe."
Sampling
Trace collection is expensive. Sample.
Head-based:
- Decide at request entry: keep or drop.
- Random (e.g., 1%).
- Deterministic (e.g., based on hash(trace_id)).
- Cheap; might miss rare critical traces.
Tail-based:
- Collect all spans into a buffer.
- After trace completes, decide based on status / latency.
- Keep all error traces; sample successful.
- Expensive (buffer); smarter.
Production: head + tail combined. Sample 1-5% as baseline; tail-include errors + slow.
Trace-to-Log Correlation
Every log line includes the current trace_id:
2025-... [INFO] service=orders trace_id=abc123 "starting payment"
2025-... [ERROR] service=payments trace_id=abc123 "card declined"
In log UI: click trace_id → see the full trace. In trace UI: click span → see logs.
Mature tools (Datadog, Honeycomb, Grafana) make this seamless.
What to Add as Attributes
Useful:
http.method,http.status_code.db.system,db.statement(truncated; no PII).messaging.system,messaging.destination.user.id(low cardinality if dashboards; otherwise span-only).feature.flag.value(test how flags affect performance).
Avoid:
- Big payloads (truncate or skip).
- Sensitive data (PII, secrets, payment info).
Performance Overhead
- Auto-instrumentation: 1–5% CPU overhead typical.
- Span emission: small bytes/request; batched by collector.
For high-QPS services: heavy sampling (0.1%) + tail-sampling errors.
Errors in Traces
Mark span as error on exception:
span.set_status(StatusCode.ERROR)
span.record_exception(e)
Error spans get red in the UI. Searching "slow + error" gets you the worst spans fast.
Common Mistakes
| Mistake | Fix |
|---|---|
| No trace context propagation | Each service sees a different trace ID; can't link |
| 100% sampling on a 100k-QPS service | Trace storage + collector overwhelmed |
Adding user_email to every span | Cardinality + PII |
| Trace ID in URLs | Leaks |
| No correlation between traces and logs | Half the value lost |
Common Tools
| Tool | Notes |
|---|---|
| Jaeger | Open source; mature |
| Tempo | Grafana's; cheap S3-backed |
| Honeycomb | Best UX for query-style debugging |
| Datadog APM | Full-stack; expensive |
| AWS X-Ray | Native to AWS |
| OpenTelemetry Collector | Vendor-neutral pipeline |
For new systems: instrument with OTel SDK; pick a backend.
Trace-Driven Investigation Loop
- Alert fires: p99 latency above SLO.
- Find slow trace examples (search by p99 percentile or status=error).
- Examine the trace timeline → identify the slow span.
- Open logs for that span (correlated by trace_id) → find the exact failure.
- Fix and verify on traces.
This is the modern debugging workflow. Far better than grep over logs.
[!NOTE] Distributed tracing turns "the system is slow" from a mystery into a diagram. Implementing it requires consistent instrumentation across services — invest once, save thousands of debugging hours.
Interview Follow-ups
- "How does sampling preserve rare events?" — Tail sampling: keep all errors regardless of sample rate; combined with reservoir sampling for slow latencies.
- "What's the difference between a span and an event?" — Span has start + end (a duration). Event is a point-in-time annotation within a span.
- "How does tracing work with async / batch processing?" — Propagate trace context in message headers; consumer creates a span linked to the producer's span via
links(cross-process causal relationship).
Q: Design a URL shortener (bit.ly).
Answer:
Classic interview opener. Looks trivial; the depth comes from key generation, scale, and analytics. Walk through it as a real production design.
Requirements
Functional:
- Shorten a long URL → 7-character short code.
- Resolve short code → 301/302 redirect to long URL.
- Custom aliases (optional).
- Expiration (optional).
- Click analytics (optional).
Non-functional:
- 200M shortenings/day → ~2,300 writes/sec.
- 20B reads/day → ~230,000 reads/sec.
- Read:write ≈ 100:1.
- p99 redirect latency < 50 ms (it's in the user's browser path).
- 5+ year retention.
- High availability — broken short link = broken landing pages.
Capacity Estimation
Storage:
Per record:
short code (7 char) ~7 B
long URL (avg 200 char) ~200 B
metadata (created, owner, ...) ~100 B
indexes overhead ~150 B
Total ~450 B
per day: 200M × 450 B = 90 GB
per year: 33 TB
5 years: ~165 TB
Bandwidth:
Redirect QPS: 230k
Response: ~500 B headers
230k × 500 B ≈ 115 MB/s
Trivially served from a handful of well-cached frontends.
Short Code Design
7 characters in [a-zA-Z0-9] = 62^7 ≈ 3.5 × 10¹². Plenty.
Three generation strategies:
1. Random + collision check.
loop:
code = random(62, 7)
if not exists(code) → atomically insert
Pros: stateless, parallel-friendly. Cons: collisions grow with table size; needs unique-constraint insert.
2. Hash of URL.
code = base62(sha256(url))[:7]
Pros: deterministic; same URL → same code. Cons: collisions (different URLs → same 7-char hash); same URL by two users gets the same code (sometimes desired, sometimes not).
3. Counter + base62 encoding.
id = counter.increment() # global monotonic
code = base62(id)
Pros: zero collisions, ordered. Cons: global counter = single point. Solve with:
- Distributed counter (etcd / ZooKeeper).
- Pre-allocated batches: each app instance gets ranges of 10k IDs.
- Snowflake-style 64-bit IDs split into machine + sequence.
Production pick: pre-allocated batches per instance + base62. Simple, scalable, predictable.
API
POST /v1/shorten
body: { url, custom_alias?, expires_at? }
resp: { short_url: "https://sho.rt/abc1234" }
GET /v1/{code}
resp: 301 Location: <long_url>
GET /v1/{code}/stats
resp: { clicks, geo, browsers, ... }
Idempotency: Idempotency-Key so client retries don't create duplicate codes.
Data Model
CREATE TABLE urls (
code VARCHAR(10) PRIMARY KEY,
long_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ,
-- consider: INDEX on (user_id, created_at)
);
CREATE TABLE clicks (
code VARCHAR(10),
clicked_at TIMESTAMPTZ,
ip INET,
user_agent TEXT,
referer TEXT
);
For 5-year × 230k QPS reads, Postgres alone can't serve from disk. We need caching.
High-Level Architecture
┌──── CDN (cache 30s)
Browser ───────────────────────► │
▼
LB (ALB / Cloudflare)
│
▼
Stateless API
/ \
▼ ▼
Redis cache Counter service
│ miss
▼
Primary DB (Postgres)
│ async
▼
Kafka ── click events ──► Analytics (ClickHouse)
│
▼
S3 cold archive
Read Path
Most reads are repeat redirects to the same popular URLs (Pareto):
- CDN cache for popular codes (TTL ~30s).
- Redis:
GET code→ return long URL. - Miss → Postgres → fill Redis with TTL.
Cache hit ratio targets > 95%. At 230k QPS, the DB only sees ~10k QPS — manageable.
Write Path
POST /shorten
├── reserve code via local counter batch (or random + insert)
├── INSERT INTO urls ON CONFLICT do nothing
├── publish event (optional)
└── return short URL
Write QPS is low (2.3k); single Postgres comfortably handles it. No sharding needed yet.
Sharding (when growth demands)
When Postgres becomes the bottleneck (~10-20× current scale):
- Shard by code hash → keys evenly distributed. Range queries irrelevant for this domain.
- Replication factor 3.
- Vitess for MySQL, Citus for Postgres, or split application-side.
Analytics
Click events are append-only, high-volume, never-updated. Wrong DB to put in Postgres — use ClickHouse:
on redirect:
├── return 301 immediately
└── async send event to Kafka
│
▼
ClickHouse: clicks (code, ts, ip, ua)
ClickHouse stores billions of rows cheaply; rolls up for the stats endpoint.
Failure Modes & Handling
| Failure | Mitigation |
|---|---|
| Postgres primary down | Standby replica + auto-failover (Patroni); brief read-only window |
| Redis down | Fallback to DB; brief latency spike but no errors |
| One PoP down | CDN reroutes around it |
| Code counter exhausts batch | Re-fetch next batch; instance briefly throttles writes |
| Spam shortenings of malicious URLs | Block list / safe-browsing API on insert |
Security
- Open redirect abuse:
bit.ly/abc1234→evil.com. Mitigate via:- Phishing-host block lists.
- Safe Browsing API integration.
- Throttle per-source-IP shortenings.
- Manual review of high-fanout shorteners.
- Custom aliases: rate-limit per user; profanity / impersonation filter.
Custom Aliases
POST /shorten { custom_alias: "summer-sale" }
→ INSERT ... ON CONFLICT (code) DO NOTHING
→ 409 if taken
Reserve namespace: any custom alias must be > 4 chars to avoid collision with generated 7-char codes; or use a separate prefix (/c/summer-sale).
Expiration & Deletion
Cron job purges expired rows. Or set TTL in Redis; lazy delete in DB on read.
GDPR: when user deletes account, hard-delete their urls rows (audit-log the deletion).
Trade-offs
301 vs 302:
- 301 = permanent; browsers cache aggressively → great for performance, bad for click tracking.
- 302 = temporary; every click hits the server → analytics work, latency slightly worse.
Production bit.ly used 301 with separate fingerprint for tracking. Modern: 302 + edge analytics.
Extensions (if asked "what would you add?")
- Geo-routing: serve from nearest region (Cloudflare KV / Edge cache).
- Multi-tenant isolation: per-account rate limits, vanity domains.
- Branded URLs: customer's domain → mapping.
- A/B testing: short code resolves to one of N URLs by user bucket.
- QR codes on shorten response.
Common Mistakes
| Mistake | Better |
|---|---|
| Hash of URL as code without dedup logic | Deal with hash collisions explicitly |
| Random with no collision check | Eventually breaks at scale |
| Storing clicks in same DB as URLs | Different access pattern; mix slows everyone |
| No CDN | Origin sees 230k QPS unnecessarily |
| Treating analytics as a feature, not a stream | Real-time pipeline matters at scale |
[!NOTE] The depth in this interview is scale + key generation. A candidate who says "use bcrypt" or "hash collisions never happen" hasn't done the math.
Interview Follow-ups
- "Can you make redirect latency < 10ms?" — Edge KV (Cloudflare Workers KV, AWS Lambda@Edge with DynamoDB). Resolve at the CDN; only fall back to origin on miss.
- "How would you handle a single viral URL with 1M QPS?" — CDN absorbs; if origin cache hit, even origin can sustain a few hundred QPS. Edge caching is mandatory.
- "How would you migrate from random codes to sequential?" — Coexist. Code length grows naturally; sequential codes start above the random space (e.g., 8 chars).
Q: Design a distributed rate limiter.
Answer:
Standard interview problem combining algorithms (token bucket etc.) with distributed-system design (where state lives, how to scale).
Requirements
Functional:
- Reject requests above the threshold per identifier (user, IP, API key).
- Multiple tiers (free 10/min, pro 100/min).
- Support short windows (per second) and long windows (per day).
- Return clear error with
Retry-After.
Non-functional:
- Latency overhead < 5 ms p99 (it's in every request path).
- Survive a Redis outage gracefully.
- Globally consistent enough across many gateway pods (some overshoot OK).
- Scale to 1M+ QPS limiter checks.
High-Level Design
┌──────────────┐
client ───────► │ API Gateway │ ────► origin service
└──────┬───────┘
│ check limit
▼
┌──────────────┐
│ Limiter logic │ (in process)
└──────┬───────┘
│ atomic op
▼
┌──────────────┐
│ Redis Cluster │
└──────────────┘
Limiter logic runs in-process at the gateway; state is in Redis (shared across pods).
Algorithm Choice
For most use cases: token bucket (allows bursts) or sliding window counter (smoother). See Rate Limiting Algorithms.
Default pick: sliding window counter for fairness + O(1) memory.
Atomic Counter in Redis
key = "rl:user:{user_id}:{minute_bucket}"
INCR key
EXPIRE key 120 ← set on first INCR to avoid drift
if value > limit:
reject
Atomic via INCR. Two ops; round-trip ~0.5–1 ms in DC.
Bonus: use INCR + EXPIRE in one Redis pipeline.
Token Bucket via Lua
Atomic refill + consume in one script:
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, 60)
return 1 -- allowed
else
return 0 -- rejected
end
One round-trip, atomic, deterministic.
Sliding Window Counter
prev_window = floor((now - window_size) / window_size)
curr_window = floor(now / window_size)
prev_count = GET "rl:user:42:" + prev_window
curr_count = GET "rl:user:42:" + curr_window
elapsed_in_curr = (now % window_size) / window_size
weighted = prev_count * (1 - elapsed_in_curr) + curr_count
if weighted >= limit:
reject
else:
INCR curr_window
EXPIRE 2 * window_size
Two reads + one write; close to perfect smoothing.
Scale to 1M+ QPS
Single Redis caps around 100–200k ops/sec/instance. Strategies:
1. Redis Cluster. Shard keys by user_id hash. Limiter calls go to the right shard.
2. Local + Redis hybrid.
- Each gateway pod tracks local count.
- Periodically (every 100 ms) reconcile with Redis.
- Accepts small overshoot (matters for advisory limits, not security).
3. Per-tier strategies.
- Per-IP: cheap; LRU cache + Redis.
- Per-API-key: more important, full Redis precision.
- Per-tenant quota (daily): Postgres or a quota service.
Handle Redis Outage
Two failure modes to choose from:
| Mode | Behavior on Redis down |
|---|---|
| Fail-open | Allow all requests (no limiting) |
| Fail-closed | Reject all requests (deny by default) |
For DDoS protection: fail-open is dangerous; abuser gets a window of unbounded access. Mitigate with local circuit breaker + secondary cluster.
For free-tier quotas: fail-open is usually fine (revenue not at risk).
Pick per use case. Document the decision.
Distributed Limiter Patterns
1. Centralized counter (Redis): what we described.
2. Token-bucket-on-leader-then-broadcast: Pick one node as leader; it owns the bucket. Other nodes ask. Adds latency; rarely worth it.
3. Random tax / probabilistic limiting: At high QPS, sampling-based limiting is cheaper than per-request counting.
4. Local + sync (Stripe pattern): Each pod tracks local bucket; periodically syncs with global pool. Accepts brief overshoot for huge throughput.
Rate-Limit Headers
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 72
X-RateLimit-Reset: 1700000123 (unix time of next window)
On 429:
HTTP/1.1 429 Too Many Requests
Retry-After: 13 (seconds)
Good clients honor Retry-After. Bad clients don't; they get a circuit breaker.
Tier Lookup
GET /users/{id}
→ user.plan = "pro"
policies = {
free: { rpm: 10, daily: 1000 },
pro: { rpm: 100, daily: 100000 }
}
policy = policies[user.plan]
Cache the policy. Plan changes can invalidate via a short TTL or pub/sub.
Multi-Region
Each region runs its own limiter. Two questions:
1. Per-region quota or global?
Per-region: each region enforces 100 rps independently → 100 × N regions = N × allowed
Global: central counter (still Redis) → cross-region latency added
For most APIs, per-region is fine. For security-critical (auth/login), global with eventual penalty.
2. Global cap, regional enforcement:
- Allocate each region 80% of its share to start.
- Bidirectional gossip to redistribute unused capacity.
- Stripe / Cloudflare patterns.
Edge Rate Limiting
Closer to the user = better DDoS mitigation.
- L4 (Cloudflare, AWS Shield): connection-level limit, very fast.
- L7 at CDN edge: per-IP/per-URL.
- At gateway: per-API-key, per-tenant.
- At service: per-endpoint, fine-grained.
Layered defense. Each tier protects the next.
Failure Modes & Mitigations
| Failure | Mitigation |
|---|---|
| Redis slow → request latency spike | Aggressive timeout (5 ms); fail-open with local fallback |
| Hot key (one user with massive traffic) | Shard their counter (bucket = user_id + random_n); aggregate |
| Limiter itself becomes target | Front with CDN / WAF |
| Counter never expires (bug) | TTL set on every INCR; sanity job clears stale |
Observability
Metrics:
rate_limit_allowed_total,rate_limit_rejected_totalper route + tier.- p99 limiter latency.
- Redis hit ratio, latency.
- Top rejected sources (IP, API key) for abuse investigation.
Common Mistakes
| Mistake | Fix |
|---|---|
| INCR without EXPIRE | Counter persists forever |
| Per-pod limits in front of global LB | N pods × limit = wrong total |
429 with no Retry-After | Clients hammer; cascading retries |
| Rate-limit by IP behind a proxy | Use X-Forwarded-For, with signature/trust |
| Same limit for all routes | Heavy endpoints need tighter limits than light ones |
[!NOTE] Build the limiter as a library, not a separate service. Network hop per request is too expensive. Keep state in Redis but logic in-process.
Interview Follow-ups
- "Could you build this without Redis?" — Distributed token bucket in-process with gossip (Stripe-style). More complex; appropriate at massive scale.
- "How does it interact with caching?" — Limiter sits before cache; counts every request including cache hits. Or count only origin-bound requests if your use case allows.
- "How would you debug 'why is my user getting throttled?'" — Per-request log line with bucket key + count + limit; tail it filtered by user_id.
Q: Design Twitter / a news feed.
Answer:
Canonical "high read, fan-out" problem. The interesting tension is fan-out on write (push to followers' feeds) vs fan-out on read (pull from followees' tweets). A real system uses both selectively.
Requirements
Functional:
- Post tweets (text, ~280 chars).
- Follow / unfollow users.
- Home timeline: chronological (or ranked) feed of followed users' tweets.
- User timeline: tweets by one user.
Non-functional:
- 200M DAU.
- Avg 1 post / user / day, 100 reads / user / day → 230k QPS reads, 2.3k QPS writes.
- p99 timeline load < 200 ms.
- Some users have 100M followers (celebrities).
Capacity Estimation
Tweets per day: 200M. Storage per tweet: ~300 B (text + metadata). Daily growth: 60 GB; 22 TB/year text only.
Home timeline reads: 230k QPS. Each loads ~50 tweets. → ~12M tweet-reads/sec.
Fan-Out Strategies
Push (fan-out on write)
On tweet:
for each follower:
insert tweet_id into follower's timeline
On read: just SELECT * FROM timelines WHERE user_id = X ORDER BY ts DESC LIMIT 50
Read is trivial. Write multiplies by followers.
Pull (fan-out on read)
On tweet: insert into tweets table only.
On read:
for each user I follow:
fetch their latest tweets
merge, sort, paginate
Write is cheap. Read fetches from many sources.
The hybrid (Twitter actual)
- Push for typical users (followers in thousands).
- Pull for celebrities (followers in millions/100M+).
- On timeline read: merge pre-built timeline + last-tweets from celebrities the user follows.
Why: pushing one Justin Bieber tweet to 100M followers is a 100M-row insert. Untenable.
Data Model
Tweets (write-once, key by tweet_id):
tweets {
id BIGINT (snowflake)
user_id BIGINT
body TEXT
created_at TIMESTAMPTZ
parent_id BIGINT NULL (for replies)
}
Store in Cassandra/DynamoDB partitioned by user_id + created_at for user-timeline reads.
Follows:
followers {
user_id BIGINT (who is being followed)
follower_id BIGINT
created_at TIMESTAMPTZ
}
following {
user_id BIGINT (who is doing the following)
followed_id BIGINT
}
Two tables for O(1) lookup in both directions.
Timeline cache (Redis sorted set per user):
timeline:user_42 = ZSET of (tweet_id, score=timestamp)
Cache last ~800 entries (covering ~weeks for typical users).
Architecture
Client (mobile, web)
│
▼
LB / CDN
│
┌─────────────┼──────────────┐
▼ ▼ ▼
Write API Read API User API
│ │ │
▼ ▼ ▼
Tweet Service Timeline User / Follow
│ Service Service
│ │ │
▼ ▼ ▼
Cassandra Redis Postgres
(timelines)
▲
│
Fan-out worker (Kafka consumer)
▲
│
Kafka topic "tweets-posted"
▲
│
Tweet Service
Write Path
POST /tweet
1. INSERT INTO tweets (id, user_id, body, created_at)
2. PUBLISH to Kafka topic "tweets-posted"
3. Return 201 to user
Async fan-out worker:
consumer reads "tweets-posted"
for each tweet:
is the author a celebrity? (> 1M followers)
yes: skip push (pull path will fetch)
no: for each follower:
ZADD timeline:user_<follower> <ts> <tweet_id>
ZREMRANGEBYRANK timeline:user_<follower> 0 -801 (cap at 800)
Read Path
GET /home
1. tl = ZREVRANGE timeline:user_<me> 0 49
2. for each celebrity I follow:
latest = fetch their tweets > my last_seen
merge into tl
3. fetch tweet bodies for each tweet_id (multi-get from cache or Cassandra)
4. return sorted by timestamp
Typical user follows 0–10 celebrities; the merge is fast.
Hot Spots
Celebrity tweet: 100M followers. Fan-out push would crush the system. Hybrid pull path handles it.
Celebrity timeline read (visiting Elon's profile): high QPS on one partition. Cache aggressively at CDN/edge.
Viral tweet: one tweet getting millions of reads. Standard hot-key problem; cache + CDN.
Pagination / Cursors
Don't use offset-pagination at scale. Use cursor:
GET /home?cursor=<last_tweet_id>
Cursor encodes (timestamp, tweet_id). ZRANGEBYSCORE in Redis.
Storage Decisions
| Data | Store | Why |
|---|---|---|
| Tweets | Cassandra | Massive write throughput, partition by user_id |
| Timelines | Redis (ZSET) | Sorted, fast, capped size |
| Follows | DynamoDB / Postgres | Frequent both-direction lookup |
| User profile | Postgres | ACID, identity |
| Search | Elasticsearch | Text search, hashtag |
| Trends, counts | ClickHouse / Druid | Aggregations, time windows |
| Media | S3 | Photos, video |
Search / Hashtag
Indexed separately in Elasticsearch (or a tweet ingestion pipeline → search index).
on tweet posted:
→ publish to Kafka
→ indexer consumer:
parse hashtags, mentions
index into Elasticsearch (text, tags, user, ts)
Search QPS handled by ES cluster, independent of timeline path.
Ranking (Optional)
Pure chronological is the baseline. Ranked feed:
- Score each candidate tweet (recency × author affinity × engagement).
- ML model serves scores; ranker picks top N.
- Pre-rank candidates offline; rerank online with light model.
Failure Modes
| Failure | Handling |
|---|---|
| Fan-out worker behind | Lag increases; user sees stale timeline. Add workers. |
| Redis timeline cache cold | Reconstruct from Cassandra on demand; cache warm. |
| One Cassandra node down | Replication factor 3, quorum reads. |
| Celebrity user posts during outage | Fan-out delayed; pull path still works. |
| Tweet duplicated on retry | Idempotency via client-provided tweet_id. |
Multi-Region
- Tweets: globally replicated (Cassandra cross-DC).
- Timelines: regional (rebuilt from tweet stream).
- Follows: globally replicated; eventual.
Trade: a follow done in EU shows up in US within seconds. Acceptable.
Trade-offs Discussion
Cost of push fan-out:
- 200M users × avg ~200 followers = 40B inserts/day = 460k inserts/sec into timelines.
- Mitigated by: skipping celebrities, batching, write-back caching, capping timeline size.
Cost of pull fan-out:
- 230k home reads × N followees per user (avg 200) = 46M backend reads/sec.
- Untenable without aggressive caching.
The hybrid is the right answer because real social graphs are heavy-tailed: median user has few followees, celebrities have millions of followers.
Extensions
- Read receipts / seen indicators (Snapchat-like): per-user-per-tweet state — denormalized, eventually consistent.
- Threading / replies: tweet has
parent_id; threaded view fetches whole conversation. - Notifications: separate service triggered on mention/follow/retweet. See Notification System.
- Trends: HLL + Count-Min Sketch over the tweet stream for real-time top hashtags.
Common Mistakes
| Mistake | Fix |
|---|---|
| Pure push or pure pull | Production is hybrid |
| Storing timelines in RDBMS | At this scale, no |
| Synchronous fan-out in the write path | Latency unacceptable; always async |
| One celebrity's followers all push together | Worker queue; deduplicate work |
| Offset pagination | Breaks past page 50 |
[!NOTE] The defining insight is graph asymmetry. A pull-only system is wrong for the median user; a push-only system is wrong for celebrities. Pick the right path per author.
Interview Follow-ups
- "How do you handle a flood of tweets from one user?" — Per-user rate limit; spam detection; tier celebrities into separate fan-out paths.
- "How do you do real-time updates (live timeline)?" — Long-lived connection (WebSocket / SSE); push tweet IDs as they arrive; client prepends.
- "How would unfollow work?" — Remove from
follows; lazy-cleanup of stale tweets in timeline (or live filter on read using current follow set).
Q: Design a chat system (WhatsApp / Slack / Discord).
Answer:
Classic real-time problem. Tests your ability to combine persistent connections, message ordering, delivery guarantees, and multi-device sync.
Requirements
Functional:
- 1:1 and group chat (up to 200+ members).
- Real-time delivery; sent / delivered / read receipts.
- Message history.
- Online presence.
- Attachments / images.
- Push notifications when offline.
Non-functional:
- 1B users, 50M concurrent connections.
- Messages delivered within 1 second.
- Persistent history across devices.
- Sent messages are durable (no loss).
Capacity Estimation
- 50M concurrent → connection servers.
- 100B msgs/day → 1.1M msgs/sec.
- Storage per message: 200 B body + 100 B metadata → 30 TB/day for text.
- Media (separate storage): vastly larger; defer to object storage.
High-Level Architecture
Mobile / Web client
│ persistent WebSocket
▼
┌──────────────┐
│ Connection │ ← stateful gateway, holds N WS connections
│ Service │
└──────┬───────┘
│ Kafka (per-user routing)
▼
┌──────────────┐
│ Chat Service │ ← stateless, applies business rules
└──────┬───────┘
▼
┌──────────────┐ ┌─────────────────┐
│ Messages DB │ │ Presence Service│
│ (Cassandra) │ │ (Redis) │
└──────────────┘ └─────────────────┘
│
▼
Push gateway (APNs / FCM) for offline users
Connection Layer
WebSocket for browser/mobile bidirectional. Alternative: gRPC bidi streams (mobile), MQTT (low-power IoT).
Connection service:
- Maintains map
user_id + device_id → connection. - Sticky LB: hash by user_id so the user's traffic always lands on the same gateway.
- On disconnect: enqueue offline messages.
Single Java/Go process can hold 50–100k WS connections; cluster to N servers for 50M total.
Message Flow (1:1)
1. Alice sends "hi" via her WS to her gateway.
2. Gateway publishes to Kafka topic, key=conversation_id.
3. Chat service consumes:
- Assign message_id (snowflake, monotonic within conversation).
- Persist to messages DB.
- Publish to "delivery" topic key=recipient_user_id.
4. Delivery worker consumes:
- Look up Bob's gateway (via Redis presence).
- If online: forward via WS.
- If offline: write to "undelivered" set, fire push notification.
5. Alice sees "sent" tick when Kafka ack returns.
6. Bob's client ACKs receipt → "delivered" tick.
7. Bob views message → "read" tick.
Group Chat
For groups up to ~200 members, fan-out is per-message:
- Lookup group members.
- Publish a delivery event per member.
For larger groups (Slack channels with thousands):
- Use a pull model for inactive members; only push to actively connected ones.
- Channel feed pre-built lazily.
Message Ordering
Per-conversation ordering is critical (replies must come after their parent).
Achieve via:
- Per-conversation Kafka partition (key=conversation_id).
- Snowflake IDs that encode timestamp → client orders by ID.
- Server assigns the ID (clients don't generate; their clocks lie).
Across conversations, no global order required.
Data Model
Messages (Cassandra):
messages:
partition key: conversation_id
clustering key: message_id (snowflake, ts-ordered)
fields: sender_id, body, attachments, sent_at
Read pattern: SELECT * WHERE conversation_id = X AND message_id < Y LIMIT 50 — for "load more" history. Cassandra fast for this.
Conversations:
user_conversations (DynamoDB / Postgres):
user_id, conversation_id, last_read_message_id, unread_count
For inbox view: "list conversations sorted by latest message."
Presence (Redis):
SET presence:user:42 "online" EX 30 ← refresh on each heartbeat
TTL handles "offline" detection without explicit disconnect (covers crashes, dropped networks).
Delivery & Read Receipts
Three states per message per recipient:
- Sent (server ack).
- Delivered (recipient device ACKed).
- Read (user opened conversation past this ID).
Stored as last_delivered_message_id and last_read_message_id per recipient per conversation. Updated on receipt; broadcast back to sender.
Multi-Device
User has phone + laptop. All devices receive each message; reads sync.
Maintain device list per user; deliver to each device with its own WS. Sync last_read cross-device.
Offline / Push Notifications
When recipient offline:
1. Store message in user's pending queue (Redis list or DynamoDB).
2. Fire push notification (APNs for iOS, FCM for Android).
3. On reconnect, client requests undelivered messages since `last_seen_id`.
Push is best-effort; don't rely on it for delivery — full sync on reconnect is the source of truth.
End-to-End Encryption (Signal Protocol)
WhatsApp / Signal / iMessage encrypt messages so the server can't read them:
- Double Ratchet: forward-secret keys per message, ratcheted on every send.
- X3DH: initial key agreement using prekeys.
- Multi-device: each device has its own keypair; messages encrypted per device.
Server's role: ferry encrypted blobs. Server never sees plaintext.
Trade: server can't index/search; backups are encrypted too. Group chat key management is hard.
Search
If E2E: search must be on-device.
If not E2E: index in Elasticsearch keyed by user × conversation, with per-user ACL filters at query time.
Failure Modes
| Failure | Handling |
|---|---|
| Connection gateway crashes | Client reconnects; pulls undelivered since last_message_id |
| Message DB write fails | Producer retries; client retries; idempotent by client-message-id |
| Kafka partition unavailable | Producer buffers; longer windows tolerable |
| Push gateway down | Message still stored; delivered when user opens app |
Always design for "message reaches the DB" — every other layer is a delivery optimization.
Idempotency
Each client message has a client_message_id (UUID). Server dedups so retries don't duplicate.
Scale
50M concurrent → ~500 gateways at 100k conns each
1M msgs/sec → 10–20 Kafka brokers, multi-topic
30 TB/day → Cassandra cluster sized for ~1 year retention then archive
Multi-Region
- Hot conversations: route to home region of either user; sync across regions async.
- Long-distance conversations: pick a "home region" per conversation (e.g., creator's region) and route there.
- Latency hit: ~100 ms extra for cross-region delivery — usually acceptable for chat.
Common Mistakes
| Mistake | Fix |
|---|---|
| HTTP long-polling instead of WS | Higher overhead, worse latency |
| Same DB for messages + counts + presence | Different access patterns; split |
| Storing every "delivered/read" event in a topic | Massive write amplification; coalesce server-side |
| Skipping idempotency | Duplicate messages on retry |
| Trusting client timestamps | Clocks lie; server stamps |
[!NOTE] The hardest part isn't real-time; it's reconnect / offline / multi-device sync. Design that path first; live messaging is the easy case.
Interview Follow-ups
- "How do you handle a user who opens the app after a week offline?" — Client sends
last_seen_message_idper conversation; server returns delta; pagination if large. - "How do you scale to 10M users in one group (broadcast channel)?" — Pure fan-out is too expensive; use a pub/sub model — server stores once, all clients poll/subscribe. Hybrid with the small-group fan-out for engagement features.
- "What's the difference between WhatsApp and Slack design?" — WhatsApp: P2P-ish, E2E, ephemeral history. Slack: workspace-centric, channels are first-class, search/retention paramount, federated identity. Different priorities → different architectures.
Q: Design a ride-sharing service (Uber / Lyft).
Answer:
Combines real-time geospatial matching, location tracking, trip lifecycle, payments, surge pricing. The interesting parts are matching and geo-indexing at scale.
Requirements
Functional:
- Rider requests a ride from A to B.
- Match nearby driver, show ETA.
- Real-time location tracking (driver and rider see each other).
- Trip lifecycle: requested → matched → enroute → in-trip → completed.
- Pricing including surge.
- Payments.
Non-functional:
- 10M drivers, 100M riders.
- Driver location updates every 4 seconds while online.
- Match in < 5 seconds.
- Geographic correctness; never match an out-of-range driver.
Capacity Estimation
- 10M drivers online during peak in a region (e.g., NYC: 100k drivers).
- Position updates: 10M × 1 / 4 sec = 2.5M updates/sec globally.
- Trip requests: 1M trips/hour globally → 300/sec peak in busy cities.
Scale is dominated by location ingest and geo-queries, not trip volume.
High-Level Architecture
Driver app Rider app
│ │
│ location updates │ trip requests
▼ ▼
Location ingest ──► Kafka ──► Geo Index Service ──► Matching Service
│ │ │
▼ ▼ ▼
DriverState DB Geohash / Redis Trip Service
(DynamoDB) │
▼
Trip DB (Postgres)
│
▼
Payments / Notifications
Driver Location Tracking
- Driver app sends
(driver_id, lat, lng, heading, ts)every 4 seconds via WebSocket or HTTP/gRPC. - Ingest writes to DriverState (latest position only, key=driver_id) — fast key-value, DynamoDB or Cassandra.
- Also publishes to Kafka for downstream (analytics, ML, history).
Don't store every position in a transactional DB. Use a hot store for "latest" and a stream for "history."
Geo-Indexing
Need: given a rider's location, find drivers within ~3 km, sorted by distance.
Options:
1. Geohash buckets.
Divide world into geohash cells. Bucket key = geohash prefix.
Driver at (40.71, -74.01) → geohash "dr5ru..."
Index: geohash_prefix_5 → set of driver_ids
Rider query at "dr5ru":
candidates = drivers in cell "dr5ru" + 8 neighboring cells
filter by exact distance, sort
Implementation: Redis sorted set per geohash cell, or specialized service like Tile38, Uber's H3.
2. H3 (Uber's hexagonal grid).
Hexagons over Earth at multiple resolutions. Each cell ID is a 64-bit int.
cell = h3.geoToH3(lat, lng, resolution=9) // ~100 m hexagons
drivers indexed by cell. Query: cell + k-ring of neighbors.
Hexagons have uniform distance to neighbors (squares/lat-lng don't). Mathematically cleaner for matching.
3. R-tree / k-d tree.
For low-volume queries. Postgres GIST index on point works for small-scale.
For real-time at scale: H3 + Redis is the production answer.
Location Update Flow
driver app ──► WS gateway ──► location service:
1. update DriverState (latest)
2. update H3 index:
old_cell.remove(driver_id)
new_cell.add(driver_id)
3. publish to Kafka "driver-pings"
old_cell.remove only if cell changed — most pings don't change cells.
Matching Algorithm
rider requests ride at (lat, lng):
candidate_cells = h3.kRing(cell, k=2) # ~300m radius
candidates = union(cell.drivers for cell in candidate_cells)
filter: driver.status == "available"
filter: driver.capacity satisfies request
rank by (distance, ETA, driver-rating, surge)
send offer to top-K candidates
driver accepts:
atomic state transition: driver "available" → "matched"
notify rider
If no driver accepts within timeout, retry with wider radius.
Trip Lifecycle State Machine
requested → matched → enroute_pickup → arrived → in_trip → completed
↘─ cancelled (by rider/driver/timeout)
Each transition is a row update + event publish. Trip data in Postgres (ACID for billing).
Pricing / Surge
- Base fare + per-km + per-min.
- Surge multiplier per cell, updated by supply/demand:
- High request rate, low driver density → 1.5×, 2×.
- ML-driven in production; rules-based in interview.
- Lock the price at request time (don't change mid-trip).
Payments
Separate service:
- Authorize hold at trip start.
- Capture on completion.
- Refund on cancellation policy match.
- Settle to driver weekly.
Idempotency key per trip. Use a payments provider (Stripe / Adyen); don't store cards.
Real-Time UI Updates
Both apps need to see each other's position during a trip:
- WebSocket from driver → server → rider.
- Server forwards position only to the matched rider (privacy).
- Tail: 5–10 ms latency from driver ping to rider screen.
Storage Choices
| Data | Store | Why |
|---|---|---|
| Driver latest position | DynamoDB / Redis | Key-value, hot writes |
| Geo index | Redis (H3 sets) | Range queries on cells |
| Trips | Postgres | ACID for money |
| Trip history archive | S3 + Athena | Cheap long-term |
| Driver profile | Postgres | Identity, vehicle, docs |
| Location history | Kafka → S3 | Streaming + archive |
| Real-time analytics | ClickHouse / Druid | Live dashboards |
Failure Modes
| Failure | Handling |
|---|---|
| Driver app loses connection mid-trip | Trip continues based on last known state; reconnect catches up |
| Matching service overloaded in a city | Per-region matching cluster; shard by city |
| Payment provider down at trip end | Capture deferred; trip ends; retry payment async |
| Geo index hot cell (peak Times Square) | Shard within cell by driver_id hash |
| Driver disappears off-map | Heartbeat timeout → mark offline; in-trip case → alert ops |
Multi-Region
- Each city's matching runs in nearest region.
- Driver state and geo index local to region.
- Cross-region trip (very rare) → coordinate via city-of-origin.
Privacy
- Geofence driver-visible-to-rider window (only during trip).
- Mask phone numbers via proxy (twilio number).
- Pixelate exact home/work locations in history.
- Don't store driver location history beyond regulatory minimum.
Common Mistakes
| Mistake | Fix |
|---|---|
| Lat-lng range query in Postgres at scale | Use H3 or geohash + Redis |
| Storing every location update | Stream to Kafka; archive to S3; keep only latest hot |
| Sync matching loop in API request | Async via queue; rider sees "matching..." state |
| One global matching DB | City-sharded; matching is local |
| Treating it like a CRUD app | The hard part is real-time geospatial + state machines |
[!NOTE] The 80% of complexity is in matching and state machines. Payments are well-trodden territory; pricing is ML; the unique problem is "find a nearby driver and lock them in 5 seconds at planetary scale."
Interview Follow-ups
- "How do you keep driver location private to the matched rider only?" — Server enforces ACL; only stream to a rider's WS during the trip duration.
- "How do you handle a driver who can't be reached after acceptance?" — Timeout on
matchedstate (30s); reassign; possibly penalize driver. - "What's H3 vs S2?" — Both are hierarchical geospatial indexes. H3 = hexagons (Uber). S2 = squares (Google). Hexagons have uniform distance to all 6 neighbors; squares have non-uniform diagonals.
Q: Design a distributed cache (memcached / Redis Cluster).
Answer:
The interview tests whether you understand consistent hashing, replication, eviction, and failure modes — not whether you can write a from-scratch cache server.
Requirements
Functional:
- Key-value
GET/SET/DELETE. - TTL support.
- Optional: increment counters, bulk ops.
Non-functional:
- p99 < 5 ms.
- 1M+ QPS aggregate.
- Memory-bounded; evict on pressure.
- Survive node loss without data loss for cache (cache may rebuild from origin).
Choices Up Front
Single-node vs distributed: at > ~100 GB working set or > 100k QPS, you need a cluster.
Persistence: pure cache → no. As primary store → yes (Redis AOF / RDB).
Replication: critical for availability, optional for cache (warm-up acceptable).
High-Level Architecture
Clients
│
▼
┌─────────┐
│ Proxy │ optional layer (twemproxy / envoy)
└────┬────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐
│ N1 │ │ N2 │ │ N3 │ ← cache nodes
└─────┘ └─────┘ └─────┘
primary primary primary
│ │ │
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐
│ R1 │ │ R2 │ │ R3 │ ← replicas
└─────┘ └─────┘ └─────┘
Each node owns a portion of the key space (consistent hashing). Replicas serve as failovers (and optionally reads).
Key Distribution
Consistent hashing (see Consistent Hashing). Each node owns multiple vnodes on the ring. Adding/removing a node moves only 1/N of keys.
Redis Cluster: 16,384 hash slots distributed across nodes. CRC16 of key → slot → node. Re-sharding moves slots.
Client-side hashing: cache client library knows the topology; routes directly. No proxy needed but the client is fatter.
Proxy-side hashing: clients connect to a thin proxy that routes. Easier to manage topology; one more hop.
Node Internals
Each node:
- Hash map (key → value).
- LRU/LFU list for eviction.
- Expiration: lazy (check on read) + active (periodic scan).
- Networking: epoll/io_uring, single-threaded event loop (Redis) or per-core sharding (KeyDB, Dragonfly).
Memory layout matters at scale:
- Use slab allocators (memcached) to avoid fragmentation.
- Pack small values inline; large values pointer-referenced.
Eviction Policies
When memory full:
- LRU: evict least recently used. Default for most caches.
- LFU: evict least frequently used. Better when access patterns are skewed.
- Random: surprisingly effective; cheap.
- TTL-aware: evict the soonest-to-expire first.
- No-eviction: reject writes (used for queues / counters).
Redis supports approximate LRU/LFU (samples a few keys, evicts the least-good among them).
Replication
Async master → replica replication.
- Write goes to primary.
- Primary streams changes to replica.
- On primary loss, replica promoted.
For cache: replicas mainly for failover, sometimes for read scaling. Stale reads possible (replication lag); usually fine for cache.
Failure Detection & Failover
- Gossip: every node exchanges health info with peers (Redis Cluster does this).
- Sentinel / external coordinator: separate process monitors and orchestrates failover.
On primary failure:
- Quorum agrees primary is down.
- Promote replica.
- Tell clients to re-route (slot map update).
Consistency
Cache is eventually consistent by default:
- Lossy on node failure (data not yet replicated).
- Race conditions on cache-aside (delete vs concurrent fill).
For strict consistency, you'd need:
- Synchronous replication (slow).
- Distributed locks for write paths (slower).
Most caches accept eventual consistency for performance.
Cache Stampede / Thundering Herd
When a hot key expires, hundreds of clients hit the origin simultaneously.
Mitigations:
- Lock + fetch: first miss takes the lock, others wait.
- Refresh-ahead: refresh near expiry, in the background.
- Stale-while-revalidate: serve stale during async refresh.
- Probabilistic early expiration: each client treats TTL with random jitter; one of them refreshes early.
See Cache Stampede.
Sharding Topology
For 1 TB cache:
cluster: 12 nodes × ~100 GB each
replicas: each node has 1 replica (24 instances total)
slots: 16,384 distributed evenly
network: 10 Gbps per node
Add capacity: bring up new node → re-slot → cluster rebalances live.
Memory Sizing
Pick by working set, not by total data:
hot set = top 20% of keys
if hot set fits in RAM → hit rate ≈ 99%
Track: evictions/sec, hit_ratio. Rising evictions = working set outgrowing memory.
Client Library
Cache client must:
- Cache slot → node map.
- Re-route on
MOVED(Redis) /SERVER_ERRORresponses. - Pool connections per node.
- Pipeline batched requests.
- Circuit-break on dead nodes.
Hot Key Mitigation
One key takes 80% of QPS (celebrity user's session, viral post).
Options:
- Replicate the key across all nodes (Redis Cluster doesn't support natively; do at app layer).
- Client-side fanout: read from any of N replicas of the hot key.
- Local cache layer in front (process-local L1).
Common Mistakes
| Mistake | Fix |
|---|---|
| Using cache as primary store without persistence | Data loss on restart |
| Same DB connection pool size as cache QPS | Cache miss flood crushes DB |
| Big values (> 100 KB) in Redis | Latency spikes; split or move to S3 |
| No metrics on hit ratio | Don't know if cache is doing work |
| Cache invalidation across many nodes | Use TTLs + delete-on-write |
[!NOTE] The core of a distributed cache is two ideas: consistent hashing for routing and eventual consistency for replication. Most other features (TTL, eviction, pipelining) are local-node concerns.
Interview Follow-ups
- "How does Redis Cluster handle a node-add?" — Move slots from existing nodes to the new one in batches; clients get
MOVEDredirects during the migration. - "How would you build a cache for sub-microsecond reads?" — Off the network; use in-process LRU (Caffeine for JVM, lru-cache for Node) backed by a distributed cache for warmup.
- "How do you cache binary blobs?" — Either small (< 100 KB) in Redis, or pointer + S3 URL stored in cache, blob in S3 with CDN in front.
Q: Design a notification system (push, email, SMS).
Answer:
Multi-channel delivery, deduplication, per-user preferences, rate limits, retries. The interview tests fan-out, idempotency, third-party integration, and failure handling.
Requirements
Functional:
- Multiple channels: push (mobile), email, SMS, in-app.
- Per-user channel preferences.
- Templates with variable substitution.
- Scheduling (send now / send later).
- Deduplication (don't notify twice for one event).
- Aggregation ("you have 5 new likes" instead of 5 separate notifications).
- Delivery / open / click tracking.
Non-functional:
- 100M users.
- 10B notifications/day → ~120k/sec.
- < 1 minute end-to-end latency for "send now."
- Provider failures (APNs/FCM/Twilio) don't break flow.
High-Level Architecture
Producer services (orders, social, security)
│
▼
Kafka topic "notifications"
│
▼
Notification Service
├── preference check
├── deduplication
├── template render
├── rate limit per user
▼
Per-channel workers
├── Push (APNs/FCM)
├── Email (SES/Sendgrid)
├── SMS (Twilio)
└── In-app (WS push or store)
│
▼
Provider APIs
│
▼
Bounce / delivery tracker
Notification Lifecycle
1. Producer emits event ("order_placed", user_id=42)
2. Notification service consumes:
- Look up user.preferences[event_type]
- Render template
- Check user rate limit (max N notif / day)
- Dedup by (user_id, event_id, channel)
- Route to per-channel worker
3. Channel worker calls provider API
4. Provider acks; worker stores delivery state
5. Provider webhooks confirm delivered/opened/bounced
Producer API
POST /v1/notify
{
"event": "order_shipped",
"user_id": "u42",
"data": { "order_id": "o123", "tracking_url": "..." },
"channels": ["push", "email"], // optional override
"dedup_key": "order_shipped_o123" // idempotency
}
dedup_key prevents re-notification on event replay (essential when producers are at-least-once).
Per-User Preferences
user_id | event_type | channels | quiet_hours
u42 | order_shipped | push, email | 22:00–07:00
u42 | promotional_email | (none)
Look up cheap: Postgres or DynamoDB by user_id. Cache hot users in Redis.
Templates
templates table:
template_id | channel | locale | subject | body
Render with variable substitution (Mustache / Handlebars). Pre-compile templates; A/B test versions; locale-aware.
Deduplication
INSERT INTO notification_log (dedup_key, user_id, sent_at)
VALUES (?, ?, now())
ON CONFLICT (dedup_key) DO NOTHING
RETURNING ...
If insert returns nothing, skip — already sent.
dedup_key namespace: <event_type>:<entity_id>:<user_id>:<channel> typically.
Aggregation
User receives 100 "your photo was liked" notifications in 10 minutes. Batch:
on like event:
add to "pending:user:u42:photo_liked" set with timestamp
schedule flush in 5 minutes (if not already scheduled)
on flush:
if count > threshold:
"X people liked your photo"
else:
individual notifications
Implementation: Redis sorted set per user-event-type with TTL; cron consumer.
Rate Limiting (per user)
Hard limit: e.g., max 10 notifications/day. Anti-spam protection.
INCR rate:user:u42:daily
EXPIRE 86400
if value > 10: drop
Different limits per channel and per priority (critical security notifications bypass).
Channel Workers
Each provider is a separate service; isolation prevents one provider's outage from cascading.
Push (APNs / FCM):
- HTTP/2 connections, persistent.
- Token management: store APNs/FCM tokens per device per user.
- Stale tokens cleanup (on bounce).
Email (SES / Sendgrid):
- Per-domain sending limits (reputation management).
- Bounce / complaint webhooks → mark user as undeliverable.
SMS (Twilio):
- Cost per message; rate-limit aggressively.
- Country-specific regulations.
In-app:
- WebSocket push for online users.
- Persisted to DB for offline; fetched on open.
Provider Failures
Each call to a provider can fail (5xx, timeout). Pattern:
retry with exponential backoff (up to 3–5 attempts)
if all fail → write to DLQ for manual / scheduled retry
Circuit breaker per provider — open if error rate exceeds threshold.
Fallback channel: if push fails 3 times, try email.
Delivery Tracking
- Provider webhook → tracker service.
- Persist state machine: queued → sent → delivered → opened → clicked / bounced.
- Stream to analytics (ClickHouse) for dashboards.
Scheduling
"Send tomorrow at 9 AM" notifications.
- Insert to
scheduled_notifications (id, fire_at, payload). - Cron worker:
SELECT FOR UPDATE SKIP LOCKED WHERE fire_at < now() LIMIT 1000. - Enqueue to main pipeline.
Or use a job scheduler (Sidekiq, Temporal, AWS EventBridge Scheduler).
Quiet Hours / Time Zone
User in NYC at 22:00 doesn't want a push. Solutions:
- Look up user TZ.
- Defer non-critical notifications until morning.
- Critical (security, payments) bypass.
Multi-Region
- Each region runs the pipeline locally.
- Push tokens region-aware (Apple's CDN routes globally; FCM same).
- SMS provider may charge less from one region; route accordingly.
Storage
| Data | Store | Why |
|---|---|---|
| Templates | Postgres | Versioned, cached in app |
| User preferences | DynamoDB | Per-user key lookup |
| Device tokens | DynamoDB | (user_id, device_id) → token |
| Notification log | Cassandra | Append-only, high write |
| Scheduled jobs | Postgres | ACID for "send once" |
| Delivery tracking | ClickHouse | Aggregations |
Failure Modes
| Failure | Handling |
|---|---|
| Provider 5xx | Retry; circuit-break; fallback channel |
| Stale push token | Provider returns "Unregistered" → mark token dead |
| Producer floods | Per-event-type rate limit; backpressure |
| Template bug formats wrong | Canary releases; per-template error rate alert |
| Same notification sent twice | Dedup key insert with unique constraint |
Common Mistakes
| Mistake | Fix |
|---|---|
| Sending notifications synchronously in HTTP handler | Async via queue |
| One template per provider per channel manually maintained | Template service with composition |
| No per-user rate limit | One bad producer spams users |
| No bounce handling | Send to dead emails forever, hurt sender reputation |
| Storing entire payload in queue | Use IDs + fetch from DB |
[!NOTE] The defining challenge is isolation: one channel/provider/template/user must never affect others. Per-provider workers, per-user limits, per-tenant quotas — all layers of isolation.
Interview Follow-ups
- "How would you A/B test notification text?" — Variant assignment on send; track delivery → open → action per variant; pick winner.
- "How do you not wake users at 3 AM?" — Per-user TZ; deferral queue for low-priority; bypass for critical.
- "How do you handle a user with 100 devices?" — Each device tokens are independent; fan-out per token; coalesce when one device acknowledges read.
Q: Design a search autocomplete (typeahead).
Answer:
Type "fa" → see "facebook," "facetime," "facelift" instantly. Tests trie / prefix data structures, ranking, personalization, and scale at fan-out.
Requirements
Functional:
- Suggest top-K completions as user types.
- Ranked by popularity, recency, personalization.
- Multi-word queries.
- Misspelling tolerance (optional but expected).
Non-functional:
- p99 < 100 ms (it's per keystroke).
- 50k+ QPS at peak.
- Real-time updates as trending terms emerge.
Capacity Estimation
- 10B searches/day → suggestions are typed ~3× more often → 30B keystrokes/day.
- ~350k QPS, peak 700k.
- Latency budget extremely tight; can't hit a remote DB per keystroke.
Architecture
client keystroke
│
▼ debounced (~100 ms)
CDN / edge
│
▼
Autocomplete service (stateless)
│ in-memory trie / inverted index
▼
prefix lookup, rank, return top K
In-memory data structure is mandatory for the QPS + latency.
Data Structures
Trie:
(root)
│
a─── b─── o─── u─── ...
│
c─── i─── ...
│
e─── ...
│
"ace" (popularity 100k)
"ace ventura" (popularity 5k)
Each leaf node stores top-K completions sorted by score. Walk to the prefix node; return its cached top-K.
Pros: O(prefix length) lookup, naturally hierarchical. Cons: memory-heavy (one node per char × many strings); inefficient if many keys with the same prefix.
Compressed Trie (Patricia / Radix):
Merges single-child chains. Same operations, less memory.
Inverted index of n-grams:
For each prefix length 1–5, map prefix → top-K completions.
"fa" -> ["facebook", "facetime", "facelift", ...]
"fac" -> ["facebook", "facetime", "facelift", ...]
"face"-> ["facebook", "facetime", "facelift", ...]
Pros: O(1) lookup. Cons: storage = sum over prefix lengths; precompute step.
Production: hybrid — compressed trie + cached top-K at each node.
Ranking
Initial score = popularity (search count). Refinements:
- Recency: decay older searches.
- Personalization: bias by user's history, location, language.
- Diversity: don't return 10 variations of "facebook"; spread suggestions.
- Type: brand vs query vs entity vs longtail.
Score updated periodically; trie reflects fresh scores after batch rebuild.
Build Pipeline
Search logs (Kafka)
│
▼
Aggregator (Flink / Spark)
│
▼
Top-N queries per prefix
│
▼
Trie builder: produces compact binary trie
│
▼
Distribute to autocomplete servers
│
▼
Servers atomically swap to new trie
Rebuild cadence: hourly for recent trends; daily for full rebuild.
Real-Time Updates
Pure batch is slow. To capture surging trends:
- Stream recent searches.
- Per-prefix counter (Count-Min Sketch) updated live.
- Online ranker blends batch trie + recent counts.
Serving
Each autocomplete server holds the full trie in RAM (~few GB for English). LB routes any request to any server (stateless).
on request:
1. tokenize query into prefix
2. walk trie to prefix node
3. fetch node.top_k
4. apply personalization rerank
5. return top 10
Per-server: single-digit ms response.
Misspelling Tolerance
Edit-distance search:
- BK-tree (Burkhard-Keller) for fuzzy matching.
- Phonetic encoding (Soundex / Metaphone).
- Symspell algorithm: precompute deletes within edit distance N.
For autocomplete: light correction (edit distance 1) on the prefix; mark suggestions as corrections.
Personalization
Per-user trie / index would explode memory. Practical:
- Personal recent searches in a small per-user cache.
- Merge with global top-K at rerank time.
- Heavier personalization via separate ML model called after global lookup.
Caching at CDN
Repeated queries from many users for the same prefix:
- Cache at edge for 1–5 minutes.
- Cache hit ratio > 50% on common prefixes.
- Personalization breaks caching; restrict to non-personalized tier or use Edge KV.
Multi-Lingual
Per-language trie. Detect language from query, browser locale, account setting. Route to the appropriate trie.
Scale Numbers
- 700k QPS at peak.
- Each server can serve ~50k QPS in-memory.
- → 15–20 servers behind LB.
- Trie size: ~5 GB English; servers commodity 64 GB RAM.
Failure Modes
| Failure | Handling |
|---|---|
| Server dies | LB removes; warm spare takes over (trie loaded at startup) |
| Trie corrupt | Roll back to previous snapshot; alert |
| Trending term not in trie yet | Live counter blend catches it |
| Spam abuse pushes "evil_term" up | Anti-spam filters at log ingest |
Observability
- Per-prefix latency.
- Top suggestions tracked for monitoring (sanity).
- Click-through rate on suggestions = quality metric.
Common Mistakes
| Mistake | Fix |
|---|---|
| Hitting DB per keystroke | Service-level cache or in-memory trie |
| Recomputing top-K on every request | Pre-compute at trie build time |
| No popularity decay | Stale rankings forever |
| Storing search history of every user in autocomplete server | Privacy + memory; use separate personal index |
| Returning unsanitized strings | XSS risk; encode |
[!NOTE] The decisive design choice is where the trie lives — in-memory in the server, or as a managed index (Elasticsearch suggester). In-memory wins on latency; ES wins on operational simplicity at moderate QPS.
Interview Follow-ups
- "How would you A/B test suggestion ranking?" — Split traffic; log impressions + clicks; compare CTR. Pick winner.
- "How do you handle multi-word prefix?" — Tokenize last token; suggest completions of last token + use earlier tokens as context for rerank.
- "How would you do this without trees in production?" — Elasticsearch's edge-ngram tokenizer + completion suggester. Slower than custom but operationally simpler.
Q: Design a video streaming service (YouTube / Netflix).
Answer:
Combines massive storage, adaptive bitrate streaming, global CDN, and content workflow. The interview focuses on the delivery pipeline, not the user feed.
Requirements
Functional:
- Upload videos.
- Transcode to multiple resolutions/bitrates.
- Stream playback (adaptive bitrate).
- Subtitles, multiple audio tracks.
- Likes/views/recommendations (touch lightly).
Non-functional:
- Petabyte-scale storage.
- Tens of Gbps egress per popular video.
- p99 startup < 2 s; rebuffer < 1% of viewing time.
- Multi-region delivery.
Capacity
YouTube-style:
- 500 hours/min uploaded.
- 1 hour 4K ≈ 7 GB raw; transcoded to multiple variants ≈ 15 GB total per source hour.
- 500h/min × 60 min × 15 GB = 450 TB ingested/min.
Egress dwarfs ingest. Bytes flowing out: 100× upload.
Pipeline Overview
Upload → Object Storage (raw) → Transcode Pipeline → Object Storage (chunks + manifests)
│
▼
CDN (edge cache)
│
▼
Player (HLS / DASH)
Upload
- Client → presigned URL → S3 (or GCS, Azure Blob).
- Multipart upload for large files (resumable).
- On complete: enqueue transcode job.
POST /upload/init → { upload_id, presigned_urls[] }
PUT presigned_url (each chunk)
POST /upload/complete → enqueue("transcode", upload_id)
Object storage handles durability (11 9s); we don't manage that.
Transcoding
For each upload, produce multiple representations:
1080p / 5 Mbps ┐
720p / 2.5 Mbps│
480p / 1 Mbps │ HLS or DASH variants
360p / 600 Kbps│
240p / 300 Kbps┘
audio (multiple bitrates)
subtitles (VTT)
Each representation chunked into ~2–6s segments. Manifest (m3u8 for HLS, mpd for DASH) lists chunks.
Transcoding is CPU-heavy. Use:
- Workers reading from queue, writing to object storage.
- GPU encoders (NVENC) for cost/speed.
- Cloud-managed: AWS MediaConvert, GCP Transcoder, Mux.
Per-video transcode cost ~1× video duration on GPU, ~10× on CPU. Plan capacity by upload rate.
Adaptive Bitrate (ABR) Streaming
Player monitors bandwidth + buffer; switches representation on the fly:
network slow → switch to 480p
network fast → switch to 1080p
HLS / DASH define the protocol; player libraries (hls.js, Shaka) implement.
Delivery via CDN
client ──► nearest CDN PoP ──► origin shield ──► object storage
Most chunks served from CDN — origin sees ~1% of traffic.
Cache TTL:
- Chunks: weeks/months (immutable; ID-based).
- Manifests: short (~minutes); changes on live or DVR re-segmenting.
Open-source/cheap CDN options: Cloudflare Stream, AWS CloudFront, Akamai. Tier-1 video providers (Netflix) run their own CDN appliances at ISPs (Open Connect).
Storage Tiering
hot (90% of views, recent uploads): SSD / S3 standard
warm (older but still watched): S3 Infrequent Access
cold (long-tail, archive): S3 Glacier
deleted-by-DMCA / takedown: tombstone for legal compliance
Move tiers automatically based on access frequency.
Metadata
videos (Postgres):
id, owner_id, title, description, duration, status (transcoding/ready),
created_at, privacy, thumbnails_url, manifest_url
view_events (ClickHouse):
video_id, user_id, watch_time, ts
Counts (views, likes) — denormalize into the videos row periodically. Don't try to keep exact-on-write counts at this scale.
Recommendations / Feed
Out of scope for the streaming design — but the interviewer may ask:
- Offline pipeline: watch logs → ML embedding → candidate generation.
- Online: rerank candidates per user; serve via low-latency service.
- A/B testing infrastructure.
Live Streaming Variant
Live = same pipeline, harder:
- Ingest via RTMP / WebRTC / SRT.
- Real-time transcoding (lower latency than VOD).
- Origin caches latest manifest aggressively (short TTL).
- Low-latency HLS / DASH (chunks of ~1s with shared chunk transfer encoding).
End-to-end latency: traditional HLS 30s; LL-HLS 3-6s; WebRTC sub-second.
DRM / Encryption
Premium content:
- Encrypt chunks at rest (AES-128).
- License server: client requests key after auth check.
- Widevine (Chrome/Android), FairPlay (Apple), PlayReady (Edge/Xbox).
Watermarking / Anti-Piracy
- Forensic watermark per session (slight visual perturbation traceable to user).
- Token-signed manifest URLs (expire in minutes).
Failure Modes
| Failure | Handling |
|---|---|
| Transcode fails for one variant | Retry; alert; serve other variants |
| Origin overload during viral event | Origin shield + larger CDN TTL |
| Single PoP down | DNS routes around |
| Manifest corrupt | Roll back via versioned URL |
| Storage 5xx | CDN serves cached; retry origin |
Scale Numbers
YouTube-class:
- Storage: > 1 EB total.
- Egress: hundreds of Tbps.
- Active streams: 10s of millions concurrent.
Infrastructure cost dominated by egress and transcoding.
Optimization
- CMAF: shared chunk format for HLS + DASH; one set of files serves both.
- Per-title encoding: optimize bitrate per video (Netflix paper); animation needs less bitrate than action.
- AV1 / HEVC: newer codecs save 30–50% bandwidth at same quality.
- TCP BBR: better congestion control than CUBIC for streaming.
Common Mistakes
| Mistake | Fix |
|---|---|
| Single bitrate, no ABR | Mobile users buffer forever |
| Encoding on the request path | Pre-transcode; on-demand encoding only for long-tail |
| No CDN | Origin egress costs 10× CDN egress |
| Same DB for views + metadata | Views are append-heavy; split to ClickHouse |
| Synchronous transcode in upload handler | Async queue; client polls or webhooks |
[!NOTE] Video is a bandwidth problem disguised as a streaming problem. The unique architecture is the pipeline that turns one upload into many representations + chunks + a manifest, then ships those bytes via CDN.
Interview Follow-ups
- "How does Netflix's Open Connect work?" — Custom appliances deployed inside ISP networks; serve from there → no transit cost; ISP wins (less peering).
- "How would you handle a sudden viral video?" — CDN absorbs; pre-warm cache to many PoPs; origin shield prevents stampede.
- "How would you support seeking to arbitrary timestamps?" — Chunked architecture is inherently seek-friendly; client jumps to nearest segment.
Q: Design a payment system (Stripe-like).
Answer:
Money-moving system. ACID and idempotency are non-negotiable. The interview tests double-entry bookkeeping, idempotency, external integrations (card networks, banks), and reconciliation.
Requirements
Functional:
- Authorize a card / payment method.
- Capture / refund.
- Multi-currency.
- Subscriptions / recurring.
- Disputes & chargebacks.
- Payouts to merchants.
Non-functional:
- Strict consistency: no double-charge, no lost charge.
- Audit trail mandatory (regulatory).
- PCI compliance (handle card data with care).
- Provider outages (Visa/Mastercard, banks) tolerated.
High-Level Architecture
Merchant API ────► Payment Gateway ────► Card Network / Bank / Wallet
│
▼
Ledger Service (double-entry)
│
▼
Postgres (immutable ledger)
│
▼
Reconciliation jobs
│
▼
Payout Service → Bank ACH/SEPA
Ledger — The Core
Double-entry bookkeeping: every transaction has matching debit + credit entries.
CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
txn_id UUID NOT NULL, -- groups paired entries
account_id BIGINT NOT NULL,
amount NUMERIC(18, 4) NOT NULL, -- positive or negative
currency CHAR(3) NOT NULL,
type TEXT NOT NULL, -- 'authorization', 'capture', 'refund', ...
posted_at TIMESTAMPTZ DEFAULT now(),
metadata JSONB
);
CREATE INDEX ON ledger_entries (txn_id);
CREATE INDEX ON ledger_entries (account_id, posted_at);
Immutable: never UPDATE or DELETE. Corrections = compensating entries.
For each transaction, total = 0 (debits + credits balance). Enforced by check:
SELECT txn_id, sum(amount) FROM ledger_entries GROUP BY txn_id HAVING sum(amount) != 0;
-- must return zero rows; otherwise inconsistency
Charge Lifecycle
1. Authorize: hold $100 on card
ledger: customer_holds += 100, customer_funds -= 100
2. Capture: actually take the money
ledger: customer_funds_held -= 100, merchant_pending += 100
3. Payout: transfer to merchant bank
ledger: merchant_pending -= 100, bank_outflow += 100
4. (or) Refund: ledger: undo earlier entries
State machine on charges table tracks current lifecycle stage.
Idempotency
Critical. Every external API call uses Idempotency-Key:
POST /v1/charges
Idempotency-Key: abc123-...
{
"amount": 100, "currency": "USD",
"source": "tok_xyz", "description": "..."
}
Server stores the result keyed by (merchant_id, idempotency_key). Replay returns cached response — no double-charge.
See Idempotent REST APIs.
Concurrency on Hot Accounts
A merchant account might receive 1000 charges/sec. Naive row-level lock would serialize.
Approaches:
- Optimistic (
@Version/ version column) — retry on conflict; fine for moderate contention. - Sharded balance — N sub-accounts; pick one randomly; aggregate periodically.
- Append-only ledger + materialized balance — never lock; recompute balance lazily or via a stream.
The last is what most modern ledger systems do (Square, Modern Treasury). No row-locks on the hot path.
External Provider Integration
Card networks, ACH, bank APIs. Properties:
- Slow (100s of ms).
- Eventually consistent (webhooks notify final state).
- Can fail in many ways (decline, network, fraud).
Pattern:
POST /charges
├── INSERT pending charge (DB, atomic with idempotency record)
├── async call provider
├── return 200 with pending state
└── webhook from provider → update state machine
Never call provider inside a DB transaction.
Reconciliation
Daily job compares your ledger to provider's statements:
For each charge:
Compare: our_record.captured_amount vs provider_settlement.amount
Flag mismatches for investigation.
Discrepancies happen — provider downtime, network drops, edge-case fees. Reconciliation catches them.
Disputes & Chargebacks
Bank initiates a chargeback months after the original charge. Steps:
- Webhook from provider → create
disputerecord. - Merchant has X days to submit evidence.
- Final ruling: funds returned to customer (
refundledger entries) or upheld.
Money is reversible until ~120 days post-charge. Plan reserve accounts accordingly.
Multi-Currency
Store amount in smallest currency unit (amount INT in cents):
$1.00 → 100
¥10 → 10 (yen has no decimal)
Never store as floating-point. NUMERIC in Postgres or scaled int.
Conversion: lock the FX rate at the time of charge; store (amount, currency, rate_used).
Subscriptions / Recurring
subscriptions (id, customer_id, plan_id, status, next_billing_at)
Cron job picks subscriptions whose next_billing_at < now(). Charge them. Update next.
Failure handling: retry with backoff, eventually mark past_due, then cancel. Email customer at each step.
Payouts to Merchants
Periodic (daily/weekly) sweep:
- Sum merchant's pending balance.
- ACH/SEPA transfer.
- Decrement pending, increment paid_out in ledger.
Hold period (3–7 days) before payout to cover disputes.
Storage Picks
| Data | Store |
|---|---|
| Ledger | Postgres (single primary, replicated; ACID) |
| Charges, customers, subscriptions | Postgres |
| Idempotency cache | Postgres or Redis with backup |
| Audit log of API calls | Append-only Postgres or Kafka → S3 |
| Reporting / dashboards | ClickHouse, materialized from ledger |
Postgres at the center because ACID. Distributed SQL (Spanner, CockroachDB) for global single-source-of-truth ledger.
PCI Compliance
- Never store raw card numbers unless PCI Level 1 certified.
- Tokenize via provider: card → opaque
tok_xyz; only the provider sees PAN. - Limit access logs to who handled tokens.
Fraud / Risk
- Real-time scoring: device fingerprint, IP, velocity, history.
- ML model output: allow / review / reject.
- Off-the-shelf: Stripe Radar, Sift, Riskified.
Failure Modes
| Failure | Handling |
|---|---|
| Provider 5xx | Retry with backoff; idempotency dedups |
| DB primary down | Block writes; promote replica |
| Ledger inconsistency detected | Halt new charges; investigate; correcting entries |
| Wrong amount captured | Issue refund + new capture; never edit historical entries |
Multi-Region
Money systems are typically single-primary even when global. Latency added for non-primary regions, but the trade is correctness > latency for money.
Distributed SQL (Spanner / Cockroach) lets you have linearizable global writes for a latency cost.
Common Mistakes
| Mistake | Reality |
|---|---|
| Float for money | Rounding loss; use NUMERIC / int cents |
| No idempotency key | Duplicate charges on retry |
| Updates to ledger rows | Lose audit trail; use append-only |
| Single API for charge + capture + payout | Different lifecycles; split services |
| Trust your DB lag for reconciliation | Periodic exact reconciliation against provider |
[!NOTE] Two non-negotiables in payments: idempotency on every external mutation and immutable ledger as source of truth. Get those right; everything else is plumbing.
Interview Follow-ups
- "How do you handle 'we charged but customer says they didn't get the service'?" — Dispute workflow → temporary hold of funds → evidence → resolution. Recorded in ledger throughout.
- "How do you scale across regions while keeping correctness?" — Single primary DB or distributed SQL with cross-region linearizability. Accept latency; never accept eventual consistency for money.
- "How do you test payment systems safely?" — Provider sandbox environments; test mode credentials; shadow ledger that doesn't move real money.
Q: Design an ad-click aggregation pipeline.
Answer:
High-throughput event ingestion + aggregation + fraud detection. Tests streaming, deduplication, windowing, and OLAP.
Requirements
Functional:
- Capture every ad click event.
- Aggregate per-ad clicks every minute / hour / day.
- Detect fraud / bot clicks.
- Report to advertisers in near-real-time.
- Bill advertisers accurately.
Non-functional:
- 1M clicks/sec peak.
- < 1 minute aggregation lag.
- Exact billing (no double-counting, no missed clicks).
- Replayability (recompute aggregations from events).
Architecture
ad servers / SDK
│
▼
Event Collector (stateless, geo-distributed)
│
▼
Kafka (partitioned by ad_id or campaign_id)
│
├──► Fraud Detection (stream)
│ │
│ ▼
│ blocklist / Redis
│
▼
Aggregator (Flink / Spark Streaming)
│
▼
OLAP DB (ClickHouse / Druid)
│
▼
Reporting API + Billing
Event Collection
Edge collectors (close to user) ingest clicks:
GET /click?ad_id=A1&user_id=U2&placement=P3&ts=&signature=...
Properties:
- Signed by ad server to prevent forgery.
- Return immediately (1×1 transparent pixel or redirect).
- Buffer locally; flush to Kafka in batches.
Throughput: ~1M events/sec. Stateless. Sized by traffic.
Kafka
Partition by ad_id (or campaign_id):
- Ordering within an ad.
- Parallel consumers per partition.
Retention: 7 days for replay; longer in archive (S3).
Deduplication
Same click might be reported twice (network retry, browser instability). Each event has unique event_id:
seen_events (Redis SET with TTL)
on event:
if SADD seen:event_id (returns 0): skip
else: process
For exactness: dedup by event_id in the aggregator's state (Flink's keyed state).
Fraud Detection
Real-time streaming filters:
- Velocity: clicks per IP per minute > N → mark suspicious.
- Bot patterns: identical user-agent, missing referer, headless browser detection.
- Honeypot: trap clicks (invisible to humans) that bots hit.
- Repeat clicks: same user_id clicking same ad more than once in T → discount.
ML model scores each click; rule-based blocks obvious bots; flagged events excluded from billing.
Aggregation
Stream processor windowed per minute:
keyBy (ad_id, minute_window)
.sum(clicks)
.filter(not fraudulent)
.writeTo(ClickHouse)
Flink / Spark Streaming / Kafka Streams handle this. State stored locally, checkpointed to durable store.
Storage for Aggregations
ClickHouse (or Druid, Pinot):
- Append-only fact table.
- Columnar, compressed, fast aggregations.
- Stores per-minute counts; reports roll-up to hour/day at query time.
CREATE TABLE ad_clicks (
ad_id UUID,
minute DateTime,
clicks UInt32,
impressions UInt32,
spend Decimal(18, 4)
) ENGINE = SummingMergeTree()
ORDER BY (ad_id, minute);
Billing Accuracy
Two-step process:
- Real-time aggregation for dashboards (approximate, near-real-time).
- End-of-day reconciliation (exact):
- Re-process the day's events from Kafka (or S3 archive).
- Apply final fraud rules.
- Produce billable amounts.
- Compare to real-time; flag discrepancies.
Advertisers billed on the reconciled number, not the live dashboard.
Exactly-Once Semantics
Required for billing.
- Producer: Kafka idempotent + transactional → no duplicates in Kafka.
- Consumer (Flink): two-phase commit between Kafka offset commit and ClickHouse write.
- Or: append-only with
event_idunique constraint at the OLAP layer.
In practice, exactly-once effect is achieved by at-least-once + dedup at the aggregator.
Multi-Region
- Each region has local collectors + Kafka cluster.
- Cross-region replication (MirrorMaker) merges to a global topic for the aggregator OR each region aggregates locally and the central reporting sums up regions.
Trade-off: local aggregation = lower latency; central = simpler dedup.
Data Retention
| Tier | Retention |
|---|---|
| Raw events in Kafka | 7 days |
| Raw events in S3 (archive) | 2 years (compliance) |
| Per-minute aggregates | 90 days |
| Per-hour aggregates | 1 year |
| Per-day aggregates | Forever |
Older granularities rolled up; raw events purged after retention.
Replay
Bug in aggregator? Replay from Kafka:
1. Disable live aggregator.
2. Replay window from offset X.
3. Rewrite aggregations for the period.
4. Resume live.
Idempotent writes to ClickHouse (or full-replace partition) make this safe.
API for Reporting
GET /reports?ad_id=A1&from=...&to=...&granularity=hour
Backed by ClickHouse aggregate queries. Cached for popular dashboards.
Failure Modes
| Failure | Handling |
|---|---|
| Collector dies | LB drains; replicas absorb; tiny event loss possible |
| Kafka partition lost | Replication factor 3 — survives 1 broker loss |
| Aggregator crash | Resume from last checkpoint |
| ClickHouse shard down | Replicated; read from replica |
| Bad fraud rule false-positives many real clicks | Roll back rule; replay; refund advertisers |
Common Mistakes
| Mistake | Fix |
|---|---|
| Writing each click direct to OLAP DB | DB collapses; use stream |
| Synchronous fraud check in click path | Latency; do real-time async + post-hoc detection |
| One global counter incremented per click | Bottleneck; shard or aggregate |
| Billing from live dashboard | Live is approximate; bill from reconciled batch |
| No replay capability | Lost when bug deployed |
[!NOTE] Two systems coexist: a fast loop for the dashboard and a slow loop for billing. The fast loop accepts small inaccuracy for latency; the slow loop accepts latency for exactness. Never run billing off the fast loop.
Interview Follow-ups
- "How would you handle a viral ad with 100k clicks/sec on one ad?" — Salt the key (split partition by
ad_id + bucket_n); aggregate sub-keys then sum. - "How do you detect distributed bot networks?" — Fingerprint patterns across IPs; ML over device + behavior + temporal features.
- "How does this differ from page-view counting?" — Same architecture; ad clicks need stricter exactness (money), heavier fraud filtering, and a billing reconciliation pass.
Q: Design a recommendation system.
Answer:
Tests the two-stage retrieval + ranking architecture, embedding-based similarity, online vs offline pipeline, and scale considerations.
Requirements
Functional:
- Given a user, return top-N items (videos, products, posts).
- Update recommendations as user behavior changes.
- Cold-start for new users.
- Diversity / freshness rules.
Non-functional:
- 100M users, 100M items.
- p99 < 100 ms recommendation lookup.
- Updates within hours of new behavior.
The Two-Stage Architecture
User request
│
▼
[Candidate Generation] ← retrieve 1000s of candidates
│
▼
[Ranking] ← ML model scores top 100
│
▼
[Reranking / Diversity] ← apply business rules
│
▼
Top 10
This pattern fits YouTube, Pinterest, TikTok, Spotify, Amazon. The stages have different scale + complexity profiles.
Stage 1: Candidate Generation
Fast retrieval of "potentially relevant" items. Sources:
- Collaborative filtering: users who watched X also watched Y.
- Content-based: items similar to what the user has interacted with.
- Embedding nearest-neighbor: vector similarity search.
- Popular: trending items in user's region/category.
- Recently watched: time-based history.
Each generates ~1000 candidates. Union, dedup.
Embedding-Based Retrieval
Train a model to produce user embeddings and item embeddings in the same space:
user vector = encoder(user_history)
item vector = encoder(item_features)
dot product ≈ predicted affinity
Find top-K items via Approximate Nearest Neighbor (ANN) search:
- Faiss (Meta), HNSW, ScaNN (Google), Annoy (Spotify).
- Sub-millisecond on millions of items.
- Hosted services: Pinecone, Weaviate, Qdrant, Vertex AI Matching Engine.
Stage 2: Ranking
The 1000 candidates go through a heavier ML model:
- Features: user × item × context × cross-features.
- Model: gradient-boosted trees (LightGBM, XGBoost) or DNN.
- Output: predicted CTR / engagement score.
Optimization target depends on platform — clicks, watch time, purchases.
Stage 3: Reranking
Apply business rules:
- Diversity (don't return 10 of the same category).
- Freshness (boost recent items).
- Filtering (don't repeat what user already watched).
- Calibration (sponsored items mixed in).
Architecture
client request
│
▼
Rec Service (online)
│
┌───────────────────┼──────────────────┐
▼ ▼ ▼
User Profile Candidate Sources Feature Store
(Redis) ├── ANN index (per-user features)
├── popularity cache (per-item features)
└── recently-watched
│
▼
Ranker (ML model)
│
▼
Top N
Offline Pipeline
Heavy lifting happens offline:
1. Logs (impressions, clicks) → Kafka → S3
2. Daily/hourly training jobs (Spark / Flink):
- Compute user features (last-N actions).
- Compute item features (popularity, embedding).
- Train ranker.
3. Push artifacts to online stores (Redis, vector DB).
Cadence:
- Item embeddings: nightly.
- User features: every few minutes (recent behavior).
- Model: weekly or daily retraining.
Online Pipeline
Real-time:
on user action (click, like):
stream → Kafka → online feature updater
update user's recent-actions feature → Redis (latency < 100 ms)
Recommendations served < 100 ms later reflect the action.
Storage
| Data | Store |
|---|---|
| User profile + history | Redis / DynamoDB |
| Item embedding index | Faiss / Pinecone |
| Feature store | Redis (online) + Parquet (offline) |
| Logs (training data) | Kafka → S3 |
| Model artifacts | S3 / model registry |
Cold Start
New user: no history.
- Onboarding form (interests).
- Default to popular items in user's region.
- Demographic-based recommendations.
New item: no engagement signal.
- Content-based features (text embedding from title/description).
- Exploration boost (intentionally show to small audience to gather signal).
A/B Testing
Recommendations are highly empirical. Standard:
- Bucket users (consistent hash).
- Serve different models / candidate sets per bucket.
- Track engagement metrics per bucket.
- Promote winners.
Online evaluation > offline metrics. Many improvements look great offline and fail in A/B test.
Fairness / Filter Bubbles
- Diversity reranking caps single-category in top N.
- Exploration: occasionally surface items outside the user's typical preference.
- Bias audits across protected attributes.
Scaling Numbers
Candidate generation:
ANN lookup on 100M items, top 1000 → ~5 ms
Other sources (popular, recent): in-memory, ~1 ms
Ranking:
1000 candidates × N features → light DNN forward pass → ~20 ms
Total online budget: ~50 ms — leaving margin for network + serialization
Inference often on GPU servers; one box can serve thousands of QPS.
Failure Modes
| Failure | Handling |
|---|---|
| ANN index unavailable | Fall back to popular items |
| Feature store down | Use cached features from app memory |
| ML model returns bad scores | Sanity caps; failover to previous model version |
| Cold start for new user | Demographic fallback |
Common Mistakes
| Mistake | Fix |
|---|---|
| One huge model for candidates + ranking | Two-stage is efficient and modular |
| Recompute embeddings online | Batch precompute; ANN at query time |
| Recommendations latency 500 ms+ | Pre-fetch on session start; cache results 1 min |
| No A/B testing infrastructure | Can't tell if changes help; build platform first |
| Ignoring diversity | All TVs → user fatigue |
[!NOTE] The interesting design choice is what counts as a candidate. Get the candidate set right and ranking is easy; get it wrong and no model rescues you.
Interview Follow-ups
- "What's the difference between collaborative and content-based?" — Collaborative uses user-item interaction patterns. Content-based uses item features (text, image). Most production systems blend both.
- "How would you build for explainability?" — Track which candidate source produced each recommendation; surface as "because you watched X."
- "How do you handle adversarial content (clickbait)?" — Train on long-term engagement (watch time, return visits) not just clicks; explicit downranking signals from users.
Q: Design a file storage service (Dropbox / Google Drive).
Answer:
Tests chunking, deduplication, sync, conflict resolution, sharing. The hard parts are the sync protocol and per-user-per-device state.
Requirements
Functional:
- Upload / download / delete files.
- Sync across devices.
- Share files / folders.
- Versioning.
- Offline access.
- Large files (multi-GB).
Non-functional:
- 500M users.
- p99 first-byte download < 500 ms.
- Resumable uploads.
- Dedup to save storage.
High-Level Architecture
Client (desktop, mobile, web)
│ block uploads + metadata sync
▼
Edge / CDN
│
▼
Metadata Service ◄──► Sync Service ◄──► Notification Service
│ │
▼ ▼
Postgres (metadata) WebSocket / push
│
▼
Block Storage (S3) — actual file content
│
▼
Dedup Service (content-addressed)
Two distinct planes: metadata (small, transactional) and blocks (large, immutable).
Chunking
A file is split client-side into fixed-size chunks (e.g., 4 MB):
photo.jpg (24 MB) → [c1, c2, c3, c4, c5, c6]
each chunk: SHA-256 → content_hash
Benefits:
- Resumable upload — retry only failed chunks.
- Dedup: same chunk uploaded once, regardless of which file.
- Parallel upload of chunks.
Variable-length chunking (Rabin fingerprinting) handles inserts/deletes mid-file better; used in production at Dropbox.
Deduplication
Content-addressed storage:
chunk_blob in S3: key = "blocks/<sha256>"
Upload flow:
client computes hash for each chunk
client asks: "do you have these hashes?"
server replies: "missing c2 and c5"
client uploads only c2 and c5
Massive bandwidth savings for common content (system DLLs, popular files).
Metadata Model
files (
file_id,
user_id,
folder_id,
name,
size,
modified_at,
version,
is_deleted
)
file_chunks (
file_id,
version,
index,
chunk_hash
)
users, folders, sharing_acl, ...
Postgres for transactional integrity.
Upload Flow
1. Client: split file → chunks → hashes.
2. POST /files/init {filename, chunks: [{hash, size}, ...]}
→ server returns presigned URLs for missing chunks.
3. Client uploads missing chunks to S3 in parallel.
4. POST /files/commit {file_id, version, chunks: [hashes]}
→ server records metadata; triggers notify to other devices.
Idempotent via the commit step. Resumable: re-init returns the same missing list if anything failed.
Sync Protocol
Each device tracks a sync_token (cursor over server changes).
GET /sync?since=token
→ returns list of changes (files added/modified/deleted)
→ new token
client applies changes; downloads new chunks via dedup check
Server pushes via WebSocket / long-poll for instant sync.
Conflict Resolution
Two devices edit offline → conflict on sync.
Strategies:
- Last-write-wins: easiest, loses data.
- Both versions kept: rename one to
file (Alice's conflict copy).docx. Dropbox's approach. - CRDT for text files: collaborative editing (Google Docs uses Operational Transformation; modern apps use CRDTs).
For general files, "keep both" is standard.
Versioning
Each commit increments version. Old versions kept for N days:
files: { id, latest_version }
file_versions: { file_id, version, modified_at, chunks: [hash...] }
Restore = re-point latest to older version. Cheap (metadata only); chunks already in storage.
Sharing
Two patterns:
Link sharing: share_token → ACL: anyone/with-link/specific-users.
Folder sharing: ACL on folder; users see it appear in their tree.
Permissions: read / comment / edit / owner.
Cache permissions per (user, file) in Redis with invalidation on ACL change.
Storage Backend
- Block store: S3 or equivalent. Immutable, content-addressed. 11 9s durability.
- Tier: hot S3 for recent; Glacier for old / cold.
- Encryption: at rest (S3 SSE-KMS); in transit (TLS).
- Per-user encryption (client-side): zero-knowledge mode like Mega/Boxcryptor; trades indexing/search.
Network Optimization
- Delta sync: only changed chunks transferred.
- LAN sync: peer-to-peer chunk transfer between same-LAN devices (Dropbox).
- Block-level dedup: as above.
Multi-Region
- Metadata: replicated globally; primary per region or distributed SQL.
- Blocks: stored in nearest region; replicated lazily across regions for redundancy.
- Reads: served from nearest replica.
Scale Numbers
500M users × avg 10 GB → 5 EB total. Trillions of chunks.
- Metadata: ~10s of TB; fits in sharded Postgres or distributed SQL.
- Block store: provider problem (S3 handles).
- Sync events: 100k+/sec at peak; Kafka.
Failure Modes
| Failure | Handling |
|---|---|
| Upload interrupted | Resume from last successful chunk |
| Sync conflict | Conflict copy; user resolves |
| Block missing in storage (rare) | Re-upload from any device that has it |
| User deletes accidentally | Soft-delete + version restore |
| Massive bulk delete (account compromise) | Rate-limit + delayed actual delete |
Search
Indexed metadata (filename, content type) in Elasticsearch.
Full-text search of file content:
- Index text-extractable files (
.txt,.pdf,.docx) post-upload. - OCR for images (optional).
- Trade index storage for searchability.
Trash / Soft Delete
on delete: set is_deleted = true, deleted_at = now()
purge job: actually delete blocks (after 30d retention)
Allows undo, audit, partial-account-recovery.
Common Mistakes
| Mistake | Fix |
|---|---|
| Storing whole files in DB | Metadata in DB; bytes in object store |
| No dedup | Storage costs 10× more for popular content |
| Sync over polling only | Use push (WS) for instant updates |
| Holding sync state in client only | Server is source of truth; client cache |
| Last-write-wins on text edits | Lose data; use conflict copies or CRDT |
[!NOTE] The hard parts of Dropbox-like systems are sync semantics and conflict handling, not storage. Block dedup + S3 handles bytes; the user-facing magic is "my files are everywhere and never lost."
Interview Follow-ups
- "How would you handle a corrupt sync state on client?" — Detect via checksum mismatch; trigger full re-sync from server; quarantine local conflicting copy.
- "How big can a single file be?" — No real limit; just more chunks. Multi-part upload to S3 handles up to 5 TB per object.
- "How do you do 'recently modified by team'?" — Activity log table; index by folder + time; subscribe to changes via WS.
Q: Design a web crawler.
Answer:
Classic distributed-system problem. Tests URL frontier management, politeness, duplicate detection, content storage, and scaling crawl rate.
Requirements
Functional:
- Crawl billions of URLs.
- Respect
robots.txtand rate limits per host. - Detect duplicates (same URL, same content).
- Extract links to extend frontier.
- Re-crawl based on freshness policy.
Non-functional:
- Throughput: 1B pages/month → ~400 QPS sustained.
- Storage: ~10 KB per page average → 10 TB/month.
- Politeness: don't get banned.
High-Level Architecture
┌──────────────┐
│ Seed URLs │
└──────┬───────┘
▼
┌──────────────────┐
│ URL Frontier │ ← priority + politeness queues
└──────┬───────────┘
▼
┌──────────────────┐
│ Fetcher Pool │ ← N workers; DNS, HTTP
└──────┬───────────┘
▼
┌──────────────────┐
│ Parser │ ← HTML/JS, extract links + content
└──────┬───────────┘
▼
┌───────────────┼────────────────┐
▼ ▼ ▼
Content Link extraction Duplicate
Storage to frontier detection
(S3) (Bloom + content hash)
URL Frontier
The work queue of URLs to fetch. Two requirements:
- Priority: important URLs (high PageRank, fresh news) crawled sooner.
- Politeness: same host crawled with delay (no DDoS).
Implementation:
- Top level: priority queue by URL score.
- Second level: per-host queue with rate limit.
priority_queue → host_queues[host] → fetch_workers
Each host queue dequeues at the host's allowed rate (e.g., 1 req/sec per host).
DNS
DNS is a bottleneck. Each fetch needs an A-record lookup.
- Cache DNS results aggressively (TTL).
- Run a DNS resolver fleet (Unbound, dnsmasq) close to fetchers.
- Pre-resolve for known hosts.
Politeness
robots.txt:
- Fetch once per host per day.
- Respect
Disallow,Crawl-delay. - Cache locally.
Rate limit per host:
- Token bucket per host, refilled by
Crawl-delayor default (1 req/sec, conservative). - User-Agent identifying the bot + contact info.
Fetching
HTTP GET. Considerations:
- HTTP/2: persistent connections, multiplex. Faster.
- Robots-respect: skip disallowed paths.
- Timeouts: 10–30s.
- Size limits: 10 MB max page; drop larger.
- Redirects: follow up to 5.
- Status codes: 200 → parse; 3xx → follow; 4xx → blacklist; 5xx → retry later.
Some pages are JS-rendered. Headless browsers (Chrome via Puppeteer/Playwright) handle them but cost 10–100× more.
Production: lightweight HTTP for most; headless for known SPA hosts.
Parsing
- HTML parsing: BeautifulSoup, lxml, or Cheerio.
- Extract: text content, links (
<a href>), meta tags, canonical URL. - Normalize URLs: lowercase host, strip trailing slash, sort query params, remove fragments.
canonical("HTTPS://Example.COM/PATH/?b=1&a=2#frag")
→ "https://example.com/path?a=1&b=2"
Duplicate Detection
Two kinds:
1. URL dedup: have I seen this URL?
Bloom filter: 1B URLs × 8 bits ≈ 1 GB; 0.1% false-positive
Exact check on hit: lookup in DB
2. Content dedup: same page, different URL.
content_hash = SHA-256(normalized_text)
seen_hashes (Cassandra) holds content_hash → first_url
Common at scale: mirrors, syndicated content, duplicate sitemaps. Skip if content matches.
Scheduling for Freshness
Different sites change at different rates. Track Last-Modified + observed change frequency:
news.example.com → re-crawl every hour
wikipedia.org/page → re-crawl every day
archive.org → re-crawl monthly
Promote pages back to frontier based on schedule.
Storage
| Data | Store |
|---|---|
| Raw HTML | S3 / object storage (cheap, write-once) |
| Parsed text + metadata | HBase / Cassandra |
| URL frontier | Kafka or Redis (priority queue) |
| Visited URLs | Bloom filter + Cassandra |
| Link graph (for PageRank) | Graph DB or wide-column |
| Search index | Elasticsearch / inverted index |
Distributed Coordination
Fetchers are stateless workers. Coordination via:
- Frontier queue (Kafka): partitioned by host hash.
- Locks per host (Redis SETNX or per-shard ownership) to enforce politeness.
If a fetcher crashes, its host queue is reassigned. URLs in-flight may be retried (at-least-once).
Scaling Beyond One DC
- Each region crawls hosts closest to it (lower latency, better robots-compliance via IP).
- Discovered URLs flow back to global frontier.
- Deduplication happens globally (one source of truth for "seen" set).
Trap Avoidance
Bad sites can trap crawlers:
- Infinite calendars (
/calendar/2024/01/01,/calendar/2024/01/02, ...). - Session-ID URLs (every link different).
- Spider traps with fake links.
Mitigations:
- Per-host page count cap.
- Pattern detection (path depth > N, calendar-like patterns).
- Diff URLs against canonical forms.
Adversarial Content
- Malware-serving pages → safe-browse check on download.
- Rate-limit re-crawl of malware hosts to zero.
- Don't follow
nofollowlinks (per robots conventions).
Failure Modes
| Failure | Handling |
|---|---|
| One host returns 500s | Backoff, eventually skip |
| Network partition for a region | Other regions continue; reconnect resumes |
| Frontier exhausted | Re-seed from priority list / sitemap discovery |
| Bloom filter false positive (URL marked seen but wasn't) | Acceptable; eventually re-discovered |
| Cassandra slow | Buffer locally; backpressure to fetchers |
Common Mistakes
| Mistake | Fix |
|---|---|
| Single frontier without per-host queues | DDoSing hosts; getting banned |
| No URL normalization | Crawling the same page N times under different URLs |
| Synchronous DNS | DNS bottleneck; cache + async resolver |
| One giant DB for everything | Different access patterns; separate stores |
| No robots.txt respect | Legal + ethical issue + bans |
[!NOTE] Politeness is a first-class design constraint, not an afterthought. A 1000-worker crawler that ignores
robots.txtgets your ASN banned from the internet in days.
Interview Follow-ups
- "How do you crawl JS-heavy sites?" — Headless browser tier; sample-based (don't run heavy renderer on every page).
- "How do you discover new URLs?" — Sitemaps, RSS feeds, social media references, partner data feeds.
- "What's PageRank's role in priority?" — Higher PageRank → higher crawl priority (more freshness). Computed offline; injected as URL metadata.
Q: Design a distributed job scheduler (cron at scale).
Answer:
Run scheduled or delayed jobs at scale across many workers. Tests leader election, idempotency, at-least-once execution, time skew, and scaling out.
Requirements
Functional:
- Schedule a job: now, at a future time, or on a cron schedule.
- Cancel a scheduled job.
- Retry on failure with policy.
- Run jobs across many workers.
Non-functional:
- 10M scheduled jobs.
- 10k+ jobs/sec firing.
- p99 fire-time accuracy < 1 second.
- Survive node failure with no missed jobs.
High-Level Architecture
Producer ──► Scheduler API ──► Job Store (Postgres / DDB)
│
▼
Dispatcher (one or more)
│
▼
Kafka / SQS
│
▼
Worker pool
│
▼
Result + retry logic
Job Store
CREATE TABLE jobs (
id UUID PRIMARY KEY,
schedule_type ENUM ('once', 'cron'),
fire_at TIMESTAMPTZ, -- next fire time
cron_expr TEXT,
payload JSONB,
status ENUM ('pending', 'in_progress', 'completed', 'failed'),
retries INT DEFAULT 0,
max_retries INT DEFAULT 3,
locked_by TEXT,
locked_until TIMESTAMPTZ
);
CREATE INDEX ON jobs (fire_at) WHERE status = 'pending';
Index on fire_at for the polling query.
Dispatcher Loop
loop:
fired = SELECT * FROM jobs
WHERE status = 'pending' AND fire_at <= now()
ORDER BY fire_at
LIMIT 1000
FOR UPDATE SKIP LOCKED;
for j in fired:
publish j to Kafka topic
UPDATE jobs SET status='in_progress', locked_by=worker_id, locked_until=now()+5min
WHERE id = j.id;
sleep 100ms;
FOR UPDATE SKIP LOCKED (Postgres ≥ 9.5) lets many dispatcher instances run in parallel without conflicts.
Worker Loop
on message from Kafka:
try:
execute(payload)
UPDATE jobs SET status='completed' WHERE id = j.id;
except RetryableError:
retries += 1
if retries < max_retries:
UPDATE jobs SET status='pending', fire_at=now()+backoff, retries=retries
else:
UPDATE jobs SET status='failed'
publish to DLQ
Idempotency
Jobs may run more than once (at-least-once delivery). Workers must dedup:
on execute(job):
INSERT INTO job_log (job_id, attempt) ON CONFLICT DO NOTHING
if conflict: skip
else: do the work
Or design the work itself to be idempotent (which is best).
Cron Schedules
For recurring jobs, after firing:
UPDATE jobs
SET fire_at = next_fire_time(cron_expr, now()),
status = 'pending',
retries = 0
WHERE id = j.id;
Compute next fire using a cron library; advance fire_at.
Scaling
Vertical: one dispatcher polls a Postgres table — easy to ~10k jobs/sec.
Horizontal: multiple dispatchers, sharded by job ID hash. Or by region/tenant.
Tiered:
- Short delays (< 1 hour) → Redis sorted set (ZSET) with score = fire_at; dispatcher pops due jobs.
- Medium / long → DB-backed table as above.
Time Skew
Clocks drift. With NTP, ~50 ms typical. For tighter accuracy:
- One leader dispatcher (via etcd lease).
- Or: tolerate ~1 second jitter; for sub-second precision, use a dedicated time service.
Cancellation
UPDATE jobs SET status='cancelled' WHERE id = ?;
Race: dispatcher may have already picked it up. Worker checks status before executing:
on execute:
SELECT status FROM jobs WHERE id = ?;
if cancelled: skip
Or use cancellation token in payload that the work itself respects.
Retry / Backoff
Configurable per job type. Default: exponential with jitter.
retries=0 → backoff 30s
retries=1 → backoff 2min + jitter
retries=2 → backoff 10min + jitter
retries=3 → DLQ
Failure Modes
| Failure | Handling |
|---|---|
| Dispatcher crashes mid-publish | Job stays in_progress until locked_until expires; re-dispatched |
| Worker crashes mid-execution | Same; idempotent execution avoids issues |
| Kafka unavailable | Dispatcher pauses; jobs accumulate in DB; resume after recovery |
| Job creates infinite loop (work spawns more jobs) | Rate-limit job creation; quota per tenant |
| Clock skew on worker | Use server time, not worker time |
Observability
pending_jobs_countby fire_at bucket.firing_lag(fire_at vs actual execution).dispatch_latency_p99.- Failed jobs in DLQ.
Alert if pending_jobs > N ahead of schedule (dispatcher behind).
Storage Picks
- Postgres: jobs table; ACID; row locking via
FOR UPDATE SKIP LOCKED. - Redis ZSET: short-delay jobs (timer wheel pattern).
- Kafka: dispatch / DLQ topics.
- DynamoDB: at extreme scale, sharded by
fire_atbucket.
Variants
Workflow scheduler (Temporal, Cadence, Step Functions):
- Stateful workflows with retries, sleep, signals.
- Built on a job scheduler at the bottom.
- Right answer when "scheduled job" → "5-step workflow with conditional branches."
Cron service (Kubernetes CronJob):
- For job types (one schedule, many runs over time).
- Less granular than per-event schedulers.
Common Mistakes
| Mistake | Fix |
|---|---|
| Single dispatcher, no leader election | SPOF; use Raft / lease |
Not handling in_progress job after crash | Stuck forever; expire locked_until |
| Workers fetch from DB directly | DB contention; use queue between dispatcher and workers |
| No idempotency | At-least-once dupes data |
| Retry without backoff | Hammers downstream during outages |
[!NOTE] Cron at scale is two things: a fast index on "fire_at" and at-least-once delivery to workers with idempotency. Get those right; everything else is plumbing.
Interview Follow-ups
- "How do you handle 1M jobs firing at midnight?" — Spread fires with random jitter; tiered dispatchers; pre-load due jobs ahead of time.
- "How do you support 'run every minute on Tuesdays at 3PM Pacific'?" — Cron expression library + per-job timezone.
- "What if a job takes 2 hours?" — Long-running jobs use heartbeats to extend
locked_until; if heartbeat stops, redispatch.
Q: Design a metrics / monitoring system (Prometheus / Datadog-scale).
Answer:
Ingest and query high-cardinality time series at scale. Tests TSDB internals, labels / cardinality, downsampling, query routing, and alerting.
Requirements
Functional:
- Ingest metrics from millions of agents.
- Query (rate, sum, p99) over arbitrary time ranges.
- Alert when metrics breach thresholds.
- Dashboards (Grafana-like).
- Retention: 15 days hot, 1 year cold.
Non-functional:
- 10M+ unique series.
- 10M data points/sec ingest.
- p99 query < 1 s for hot data.
- High availability.
What a Time Series Is
metric_name { label_k1=v1, label_k2=v2, ... } ts=12345 value=42
A series = unique combination of (metric_name, labels). One series produces many (timestamp, value) samples over time.
Storage Model
Each series is a sequence of (ts, value) pairs. Optimized writes are append-only.
Block layout (typical TSDB):
index: series_id → list of (ts_block, offset)
chunks: ts_block → compressed (ts, val) pairs
Compression: Gorilla algorithm (delta-of-delta on timestamps, XOR on float values) → ~1.4 bytes/sample average.
10M samples/sec × 1.4 B × 86400 s ≈ 1.2 TB/day raw.
Cardinality
Each unique label combination is a series. Bad labels explode cardinality:
{ user_id=42 } → 100M users = 100M series ← death
{ status=200 } → small cardinality ← good
A series has fixed metadata cost; explosion = OOM.
Rule: labels are categorical, low-cardinality. Anything user-bound, request-bound, time-bound → not a label.
Architecture
agents (every host / pod)
│ push or scrape
▼
Ingest nodes (load-balanced, stateless)
│
├─► write to local TSDB shard
│
▼
Replication (Raft / quorum)
│
▼
Long-term storage (S3 / object store with index)
│
▼
Query layer (parallel fanout)
│
▼
Alertmanager / Dashboard
Push vs Scrape
- Scrape (Prometheus): monitoring server polls each target's
/metricsHTTP endpoint.- Pros: explicit service discovery; aliveness from scrape success.
- Cons: target must be reachable from monitor; firewall complications.
- Push (Statsd, Datadog, OpenTelemetry): agent on host pushes to ingest.
- Pros: agents behind NAT, easy multi-tenant.
- Cons: harder to detect "missing host."
Modern systems support both via OpenTelemetry collector.
Ingest Path
ingestion API receives batch
→ validate (cardinality, schema)
→ write to in-memory + WAL
→ ack
Backpressure: reject when shard near capacity (return 429).
Sharding: hash by series ID → ingest shard. Each shard owns ~1/N series.
Replication
For each shard:
- 3-node Raft cluster.
- Write to majority before ack.
- Read from any (eventual; reads from leader for strong).
Long-Term Storage
Hot data: local SSD on ingest nodes (last 24–48 h). Warm: queryable object storage (e.g., Thanos, Cortex, Mimir → S3). Cold: archived; queryable with higher latency.
Downsampling: aggregate to 1-min, 5-min, 1-hour buckets for long retention.
hot (raw 15s): 2 days
medium (1 min): 30 days
cold (5 min): 180 days
deep (1 hour): 2 years
Query Path
PromQL example:
sum(rate(http_requests_total{status=~"5.."}[5m])) by (service)
Query engine:
- Parse PromQL.
- Identify which series match labels.
- Fetch samples for each series.
- Apply functions (rate, sum, histogram_quantile).
- Group by labels.
For long ranges, route to downsampled tier.
Alerting
Alerts evaluated periodically (every 30s/1min):
expr: rate(errors[5m]) > 100
for: 5m # threshold must hold for 5 minutes
Firing alert → Alertmanager → routing → notification (PagerDuty, Slack).
Suppression: alert dependencies, maintenance windows, deduplication of identical alerts.
Service Discovery
- Kubernetes API: list pods + labels → scrape targets.
- Cloud provider APIs: EC2 instances, GCE VMs.
- Consul, etcd, DNS-based.
Scaling
- Single Prometheus: ~1M series, ~100k samples/sec — fine for medium clusters.
- Federated Prometheus: shard per cluster, aggregate via federation.
- Cortex / Thanos / Mimir: distributed, multi-tenant TSDB on S3.
- Datadog / Grafana Cloud / NewRelic: managed SaaS.
Multi-Tenant Considerations
- Per-tenant rate limits + cardinality caps.
- Per-tenant retention.
- Quotas with billing implications.
Failure Modes
| Failure | Handling |
|---|---|
| Ingest node crashes | Targets retry; data buffered briefly at agent |
| Sharding hot key | Hash uneven; rebalance shards |
| Query takes forever | Limit max series, max samples, max duration |
| Storage backend lag (S3) | Reads block; serve from hot tier if possible |
Common Mistakes
| Mistake | Fix |
|---|---|
Using user_id as a label | Cardinality explosion; use a span/log instead |
| Querying months at raw resolution | Use downsampled tier |
| No retention policy | Disk fills; queries slow |
| Single Prometheus for whole org | Hits limits; federate or use Cortex/Mimir |
| Alert evaluation every 1s | Massive load; 30s is usually fine |
[!NOTE] The dominant constraint in metrics systems is cardinality, not data volume. Engineers add a "user_id" label once and bring the whole TSDB to its knees. Cardinality budgets are real; enforce them.
Interview Follow-ups
- "Difference between metrics, logs, traces?" — Metrics: aggregate counters over time. Logs: discrete events. Traces: causally-linked spans across services. Different stores, different costs, different uses.
- "How is OpenTelemetry related?" — Vendor-neutral SDK + protocol for emitting all three; you choose the backend (Prometheus, Tempo, Loki, Datadog).
- "What is histogram_quantile?" — Computes percentiles from server-side histograms; client-side percentiles can't be aggregated, server-side ones can.
Q: Design a distributed counter (likes, views, ad impressions).
Answer:
Looks trivial; gets hard fast. The naive UPDATE counter += 1 doesn't scale past a few thousand QPS on one row. Real systems use sharded counters, batching, or CRDTs.
Requirements
Functional:
- Increment / decrement.
- Read current value.
- Multiple counters (per video, per ad, per user).
Non-functional:
- 1M increments/sec aggregate, spikes on viral content.
- Read latency < 100 ms.
- Approximate is OK for views/likes; exact for billing.
Why It's Hard
UPDATE videos SET views = views + 1 WHERE id = X;
At high QPS this single row becomes a:
- Row-lock contention point.
- Write-throughput bottleneck.
- Replication lag amplifier.
A celebrity's video at 100k views/sec destroys a Postgres row.
Approach 1: Sharded Counter
Split one logical counter into N physical sub-counters:
counter[X] is the sum of:
counter:X:0
counter:X:1
...
counter:X:99
increment: pick a random shard, += 1
read: sum all shards
Write QPS / N per shard. With N=100, one shard takes 1% of writes.
Read more expensive: SELECT sum(c) FROM shards WHERE id = X. Cache the sum.
Used by Google App Engine, many large-scale counters.
Approach 2: Batched Increment
Coalesce increments locally before writing:
in-process buffer:
++local_count[X]
every 100 ms, flush:
UPDATE counter SET v = v + local_count[X] WHERE id = X
local_count[X] = 0
One write per 100 ms × #processes. Cuts DB load 100×+.
Downside: brief inconsistency (100ms lag). Acceptable for views/likes.
Approach 3: Stream-Based (Kafka)
events ──► Kafka topic ──► consumer ──► +N to DB row (batched)
│
▼
hot cache (Redis) for reads
Producer fires +1 event; consumer aggregates by key in a tumbling window; writes increment.
Decouples write path from DB throughput. Lag observable as Kafka consumer lag.
Approach 4: Redis with Periodic Sync
Hot writes go to Redis (INCR), periodically flushed to durable DB:
on increment: INCR counter:X (~100k ops/sec on Redis)
on read: GET counter:X
every minute, flush dirty keys to Postgres
Redis is the source of truth for "current"; Postgres is durable backup.
Risk: Redis crash loses the unflushed deltas. Mitigate with AOF persistence or counter snapshots.
Approach 5: CRDT G-Counter
For multi-region active-active where each region writes independently:
state per region: { region1: 100, region2: 50, region3: 25 }
local increment: increment own region's slot
total = sum across regions
merge two states: max per region
G-Counters merge mathematically — no conflict possible. Used for "likes," "views" across regions.
Trade: can't decrement (use PN-Counter — pair of G-Counters for + and −).
Hybrid Production Pattern
Combine the above:
client increment ─► gateway ─► Kafka ─► aggregator window (10s) ─► Redis (hot)
│
every 1 min flush
▼
Postgres
- Hot reads from Redis, < 1 ms.
- Eventual durability via Postgres flush.
- Multi-region via per-region aggregator + CRDT merge if needed.
Choosing By Use Case
| Use case | Approach |
|---|---|
| Per-post like count (huge fanout, approximate OK) | Sharded counter + cache |
| Page view count (huge volume, eventual) | Stream aggregator + Redis |
| Ad impression count (billing, must be exact) | Stream with exactly-once dedup + reconciliation |
| Distributed user score | CRDT G-Counter |
| Rate-limit counter (per second) | Redis INCR with TTL |
Read Semantics
| Read type | Mechanism |
|---|---|
| Approximate current | Cache (Redis); refresh from DB lazily |
| Up-to-the-millisecond exact | Read all shards + sum; expensive |
| Eventually exact | Background aggregator computes; cache result |
Decide what users actually need. "1.2K likes" doesn't need to be exact.
Negative Increments
Decrement (unlike, retract impression):
- For sharded counter:
--on same shard the original was on (track) or random (acceptable for approximate). - CRDT: separate P (positive) and N (negative) counters; result = sum(P) - sum(N).
Idempotency
Network retries → same event twice → double-counting.
- Each event has a unique ID.
- Aggregator dedups by ID before incrementing.
- DB upsert "if not seen, +=N."
For ad billing, deduplication is mandatory and audited.
Time-Windowed Counters
"Views in the last 5 minutes":
- Sliding window over event stream (Flink, Kafka Streams).
- Or HyperLogLog if approximate unique count is enough.
Top-K Counters
"Top 10 trending videos":
- Each video has counter.
- Top-K maintained via Count-Min Sketch or sorted set (Redis ZSET).
- Refresh window: 1 min for "trending," real-time for live counts.
Failure Modes
| Failure | Handling |
|---|---|
| Redis crash before flush | Lose ~minute of increments (acceptable for likes; not for billing) |
| Aggregator backed up | Counter lag grows; alert |
| One shard hot (skew across shards) | Add more shards or rebalance |
| Spam ("like bot") | Deduplicate by user_id at producer; rate-limit |
Common Mistakes
| Mistake | Fix |
|---|---|
Direct UPDATE for hot counter | Throughput ceiling hit fast |
| One row per like (no aggregation) | DB hammered; aggregate in stream |
| No batching | Each event a transaction; throughput tanks |
| Cache without invalidation policy | Stale values for hours |
| Ignoring multi-region | LWW destroys counts; use CRDT or single owner |
[!NOTE] Distributed counters are a perfect example of "what looks like a one-line problem requires a 100-line architecture at scale." The right answer depends on whether you need exact or approximate, real-time or eventual, single-region or global.
Interview Follow-ups
- "How do you keep ad impression counts exact?" — Idempotent ingestion (event_id dedup); end-of-day reconciliation; double-entry-style audit log.
- "What about decrement when the cache lag is unknown?" — Track positives and negatives separately (G-Counter / PN-Counter); merge correctly.
- "How would you show 'live view count' on a video?" — Sliding window over Kafka events; push deltas to clients via WebSocket every few seconds.
Q: Design a geo-proximity service (Yelp / "find restaurants near me").
Answer:
Spatial-index problem. Tests geo-hashing / quad-trees / R-trees, trade-offs between range query and point-in-bbox, and scale considerations.
Requirements
Functional:
- Find places within radius R of (lat, lng).
- Filter by category, hours, rating.
- Sort by distance + ranking.
- Return a few hundred results.
Non-functional:
- 200M users, 10M places.
- p99 < 200 ms.
- High read, write rare (places change slowly).
Spatial Index Options
| Structure | Best for | Notes |
|---|---|---|
| Geohash | KV / Redis | Hierarchical string keys |
| H3 / S2 | Uniform cells | Hexagons (H3) / squares (S2); used by Uber, Google |
| Quad-tree | Adaptive density | Cells split when too many points |
| R-tree | Bounding boxes | Used in Postgres GIST, Elasticsearch |
| KD-tree | Small, in-memory | Bad for high dimensions |
| PostGIS | Postgres extension | Production-grade SQL spatial |
Geohash Primer
Geohash encodes (lat, lng) → string. Prefix length controls cell size:
length 1: ~5,000 km cell
length 4: ~20 km
length 5: ~5 km
length 6: ~1 km
length 7: ~150 m
length 8: ~38 m
Same prefix = nearby. Inverse: query by prefix returns nearby points.
place "dr5ru..." (NYC)
query "find places near dr5ru..."
→ check cell "dr5ru" + 8 neighbors
→ filter by exact distance
Architecture
client ──► API ──► spatial index ──► metadata DB
│ │
▼ ▼
Redis (geo) Postgres (places)
or Elasticsearch or DynamoDB
Storage
Place metadata (Postgres):
places (
id, name, lat, lng, category, hours, rating, ...
)
Spatial index (Redis, Elasticsearch, or PostGIS):
Redis GEO commands (uses geohash internally):
GEOADD places 40.7128 -74.0060 "place_42"
GEOSEARCH places FROMLONLAT -74.0060 40.7128 BYRADIUS 1 km ASC COUNT 100
Elasticsearch geo_point field:
{ "query": { "geo_distance": { "distance": "1km", "location": {...} } } }
PostGIS:
SELECT * FROM places WHERE ST_DWithin(
geom, ST_MakePoint(-74.0060, 40.7128)::geography, 1000
);
For very high QPS, Redis GEO or Elasticsearch wins.
Quad-Tree
Recursively split cells when too many points:
+----------+----------+
| A | B | cell B is dense — split:
| | | +----+----+
+----------+----------+ | B1 | B2 |
| C | D | +----+----+
| | | | B3 | B4 |
+----------+----------+ +----+----+
Pros: adapts to data density. Cons: tree balancing on updates.
Used historically; modern systems often prefer fixed-cell H3 / S2 + per-cell sorted set.
Query Algorithm
def find_nearby(lat, lng, radius_m, filters):
# 1. determine cell precision for radius
precision = pick_geohash_precision(radius_m)
# 2. compute target cell + neighbors
cells = neighbors_within_radius(geohash(lat, lng, precision), radius_m)
# 3. fetch candidates from each cell
candidates = []
for cell in cells:
candidates.extend(redis.smembers(f"cell:{cell}"))
# 4. fetch metadata, filter, sort by distance
places = db.batch_get(candidates)
results = [(p, distance(p, lat, lng)) for p in places
if matches_filters(p, filters) and distance(p, lat, lng) <= radius_m]
results.sort(key=lambda x: x[1])
return results[:limit]
Hot path is the cell membership lookup. With Redis: microseconds.
Filtering
Pre-filter at the index when possible:
- Category-specific index:
cell:dr5ru:restaurant. - Open-now filter applied in app (depends on time of query).
Many filters → split indexes by major category. Trade index storage for query speed.
Density Skew
Manhattan has 100× more places per cell than rural areas. Pure fixed-grid → some cells huge.
Mitigations:
- Adaptive grid (quad-tree).
- Multi-level indexing (broad → narrow).
- Limit candidates per cell, defer to follow-up query.
Write Path
POST /place
├── INSERT INTO places (Postgres)
├── GEOADD to Redis
└── async index into Elasticsearch for text search
Writes are rare (10M places, low add rate). Don't optimize.
Read Path
GET /search?lat=...&lng=...&radius=...&category=...
├── lookup index (Redis GEO)
├── filter + rerank
└── return JSON with distance + place data
Pagination
Cursor-based: cursor = last_distance + last_id. Stable across writes.
Caching
Identical queries (same lat-lng-radius bucket) hit cache:
cache_key = hash(geohash(lat,lng,7), radius, filters)
TTL ~30s for popular spots
CDN cache for non-personalized queries; sub-second response on hits.
Ranking Beyond Distance
Yelp ranks by:
- Distance.
- Rating.
- Review count.
- Sponsorship.
- Personalization.
Cap candidates at e.g. 200 by distance; ML rerank top set.
Scaling
- Index in Redis Cluster — shard by geohash prefix.
- Hot cities (NYC, LA) get more shards.
- Cross-region: each region has full index; data replicated async.
Failure Modes
| Failure | Handling |
|---|---|
| Redis shard down | Fallback to Postgres+PostGIS (slower); alert |
| One cell hot (Times Square) | Sub-shard by sub-precision |
| Place data drifted between Postgres and index | Reconcile via daily job |
Common Mistakes
| Mistake | Fix |
|---|---|
| Lat-lng full-scan filter on every request | Use a spatial index |
| Including all metadata in spatial cell | Cell stores IDs only; metadata separate |
| Single global cell size | Adaptive or hierarchical |
| Computing Haversine for every place ever | Pre-filter by cell first |
[!NOTE] The defining decision is the cell granularity vs cell-membership cost trade. Too small = many cells per query; too big = filtering huge candidate lists. Tune per traffic shape.
Interview Follow-ups
- "How would you serve millions of moving entities (Uber drivers)?" — Same idea, but live updates: each entity updates its cell on movement; query a cell + neighbors. Tile38 / Redis GEO scale to this.
- "How does Yelp differ from a maps service?" — Maps stores roads, polygons, terrain (much richer). Yelp is point-of-interest with attributes.
- "What about indoor / floor-level proximity?" — Add altitude/floor to the index; usually a separate dataset because indoor maps are richer than (lat, lng).