Working through the classic designs
By the end: You can handle the standard conventional design questions confidently.
- A URL shortener, as a warm-up
- A rate limiter
- A news feed
- The questions behind every design
New to distributed systems? One machine can only do so much work, and will eventually fall over. Distributed systems is the accumulated technique for spreading a job across many machines and still getting a correct answer: sending each request to a free machine, splitting data so no single machine holds all of it, keeping copies in step, queuing work that cannot be done right now, and choosing what to give up when the network between them breaks. Why it matters
System design interviews reuse a small set of problems, and working through them is the fastest way to find which parts of your model of distributed systems are vague. This section does three: a URL shortener, a rate limiter and a news feed. The rate limiter is not only an exercise — every model API has one, and you will be writing the client side of it. Underneath, each asks the same questions about reads against writes, where state lives, and what happens when one component disappears.
A URL shortener#
The standard warm-up, because it exercises the basics without complication. Generate a short key, store the mapping, redirect on lookup. The interesting parts are key generation without collisions, and the read-heavy traffic, which makes caching the dominant design decision.
Key generation is where the arithmetic lives, and it is worth doing rather than asserting, because the usual answer — seven base-62 characters, three and a half trillion combinations, plenty — is wrong in a specific and instructive way. Collisions between random keys follow the birthday pattern: what matters is not the keyspace against one key, but the keyspace against the square of how many keys you have already stored.
import math
ALPHABET = 62 # a-z A-Z 0-9
print("key length keyspace after 1 billion URLs: expected collisions")
for length in [5, 6, 7, 8, 10]:
space = ALPHABET ** length
n = 1e9
expected = n * n / (2 * space) # birthday approximation
print(f"{length:10} {space:.3e} {expected:38,.1f}")
print()
print("chance the NEXT random key collides, at 1 billion keys already stored")
for length in [6, 7, 8]:
space = ALPHABET ** length
print(f" {length} chars: {1e9/space:.2e} -> one retry every "
f"{space/1e9:,.0f} inserts on average")
print()
print("counter-based keys instead: no collisions by construction")
for n in [1e6, 1e9, 1e12]:
print(f" {n:.0e} URLs needs {math.ceil(math.log(n, ALPHABET))} base-62 characters")
# key length keyspace after 1 billion URLs: expected collisions
# 5 9.161e+08 545,772,384.2
# 6 5.680e+10 8,802,780.4
# 7 3.522e+12 141,980.3
# 8 2.183e+14 2,290.0
# 10 8.393e+17 0.6
#
# chance the NEXT random key collides, at 1 billion keys already stored
# 6 chars: 1.76e-02 -> one retry every 57 inserts on average
# 7 chars: 2.84e-04 -> one retry every 3,522 inserts on average
# 8 chars: 4.58e-06 -> one retry every 218,340 inserts on average
#
# counter-based keys instead: no collisions by construction
# 1e+06 URLs needs 4 base-62 characters
# 1e+09 URLs needs 6 base-62 characters
# 1e+12 URLs needs 7 base-62 charactersSo the answer has two halves. Either generate randomly and insert with a uniqueness constraint, retrying on conflict — cheap, because the retry rate is one in a few thousand, and safe, because the database and not your code decides who won. Or drop randomness and encode an incrementing counter in base 62, which cannot collide at all and needs only six characters for a billion URLs. The counter version costs you a coordination point for the counter and makes your keys guessable and your total volume public, which is a real product objection and the reason the random version is common despite needing the retry.
A rate limiter#
Directly relevant to AI work, since every model API has one. The token bucket algorithm refills tokens at a fixed rate and spends one per request, allowing bursts up to the bucket size. The distributed version needs shared state, usually in a fast store, and has to handle the race between checking and decrementing.
Token bucket
capacity 100, refill 10 per second
request arrives: if tokens >= 1, spend one and allow
otherwise reject with 429
Allows a burst of 100, then sustains 10 per second.Written out, it is about ten lines, and the one subtlety is that you never run a timer. You store the token count and the timestamp of the last request, and refill lazily by multiplying the elapsed time by the rate when the next request arrives. That is what makes it cheap enough to run per tenant across millions of keys.
class TokenBucket:
def __init__(self, capacity, refill_per_second, now=0.0):
self.capacity, self.rate = capacity, refill_per_second
self.tokens, self.stamp = float(capacity), now
def allow(self, now):
self.tokens = min(self.capacity, self.tokens + (now - self.stamp) * self.rate)
self.stamp = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
bucket = TokenBucket(capacity=100, refill_per_second=10)
allowed = []
for second in range(6):
n = sum(bucket.allow(second + i * 0.004) for i in range(200)) # 200 requests/s
allowed.append(n)
print("200 requests a second, bucket capacity 100, refill 10/s")
print(" allowed per second:", allowed)
print(" total allowed", sum(allowed), "of 1200; the first second spends the burst,")
print(" after that the caller gets the refill rate and nothing more")
# 200 requests a second, bucket capacity 100, refill 10/s
# allowed per second: [107, 10, 10, 10, 10, 10]
# total allowed 157 of 1200; the first second spends the burst,
# after that the caller gets the refill rate and nothing moreNow the distributed part, which is where the question is actually decided. With several API servers the bucket has to live in a shared store, and the naive implementation reads the count, compares it to the limit and writes the new value — three steps with a network round trip between them, during which every other server is doing the same thing.
import threading, time
LIMIT = 100
class Store:
"""Stands in for Redis. Every operation pays a small network delay."""
def __init__(self):
self.n = 0
self.lock = threading.Lock()
def get(self):
time.sleep(0.0005)
return self.n
def set(self, v):
time.sleep(0.0005)
self.n = v
def incr_if_below(self, limit): # one atomic server-side operation
with self.lock:
time.sleep(0.001)
if self.n < limit:
self.n += 1
return True
return False
def race(store, atomic):
allowed = []
def worker():
for _ in range(20):
if atomic:
ok = store.incr_if_below(LIMIT)
else:
n = store.get() # read
ok = n < LIMIT
if ok:
store.set(n + 1) # write, after someone else may have read
allowed.append(ok)
threads = [threading.Thread(target=worker) for _ in range(20)]
for t in threads: t.start()
for t in threads: t.join()
return sum(allowed)
for label, atomic in [("read then write", False), ("atomic check-and-increment", True)]:
s = Store()
n = race(s, atomic)
print(f"{label:28} allowed {n:4} of 400 requests against a limit of {LIMIT}"
f" {'OVER BY ' + str(n - LIMIT) if n > LIMIT else 'exact'}")
# read then write allowed 400 of 400 requests against a limit of 100 OVER BY 300
# atomic check-and-increment allowed 100 of 400 requests against a limit of 100 exactThe fix is to make the check and the decrement one operation the store executes itself — an atomic counter, a compare-and-set, or a small script run server-side. Then answer the follow-up before it is asked: if that store is unavailable, you choose between failing open, which drops the limit entirely and lets a caller overwhelm you, and failing closed, which rejects everyone because your limiter is down. The usual answer is to fail open for a short window with a conservative local limit per server, on the reasoning that a rate limiter exists to protect you from overload and should not itself become the outage.
A news feed#
The key decision is when to assemble a user's feed. Fan-out on write pushes each new post into followers' feeds immediately, making reads fast and writes expensive. Fan-out on read assembles at request time, the opposite trade. Real systems do both, with fan-out on read for accounts with enormous follower counts.
Why the hybrid exists is a distribution argument, and putting numbers on it is what separates a memorised answer from a designed one. Follower counts follow a heavy-tailed distribution, so the mean is meaningless and a tiny number of accounts dominate the write volume.
import random
random.seed(4)
USERS = 1_000_000
# A realistic shape: nearly everyone small, a thin tail of enormous accounts.
followers = [int(random.paretovariate(1.2) * 30) for _ in range(USERS)]
followers[0] = 50_000_000 # one celebrity
posts_per_user_per_day = 0.5
write_cost = sum(f * posts_per_user_per_day for f in followers)
print(f"fan-out on write: {write_cost:,.0f} feed rows written a day")
print(f" the single largest account is "
f"{followers[0]*posts_per_user_per_day/write_cost:.0%} of that")
# Hybrid: anyone above the threshold is pulled at read time instead.
for threshold in [10_000, 100_000, 1_000_000]:
written = sum(f * posts_per_user_per_day for f in followers if f <= threshold)
big = sum(1 for f in followers if f > threshold)
print(f" threshold {threshold:>9,}: {written:15,.0f} rows written a day, "
f"{big:3} accounts pulled at read time")
print(f"average account follows {sum(followers)/USERS:.0f} others, so a read-time "
f"assembly touches that many timelines")
# fan-out on write: 124,530,152 feed rows written a day
# the single largest account is 20% of that
# threshold 10,000: 61,457,893 rows written a day, 919 accounts pulled at read time
# threshold 100,000: 71,206,486 rows written a day, 58 accounts pulled at read time
# threshold 1,000,000: 77,776,094 rows written a day, 6 accounts pulled at read time
# average account follows 249 others, so a read-time assembly touches that many timelinesSay the threshold out loud and say what it costs, because that is the graded part. Below it, push on write and reads are one range scan. Above it, pull on read and merge. The reader's request then does one lookup for the pushed part plus a merge over however many large accounts they follow, which is a handful rather than the 249 timelines a pure read-time assembly would touch. And note the ordering problem the hybrid creates: two sources of posts have to be merged into one ranked list, so whatever ordering key you use has to be comparable across both.
Every design comes back to the same questions
What is the read to write ratio. What must be consistent and what can be eventual. Where is the hot path. What fails, and what happens then. Asking these explicitly structures any design round.
All three designs above answer those four questions and nothing else. The shortener is overwhelmingly reads, nothing needs to be consistent beyond the mapping existing, the hot path is the redirect, and a cache miss is the failure. The rate limiter is balanced, the counter must be atomic, the hot path is the shared store, and that store dying is the failure you must have an answer for. The feed is read-heavy with a pathological write tail, staleness of seconds is fine, the hot path is the read, and the failure is a celebrity posting. Once you can produce those four sentences for a problem you have not seen before, you are doing the round properly.
What to keep and what to skip#
- Keep: the four questions, and ask them before naming a single component.
- Keep: random keys collide at a rate you can compute, so the insert must handle a conflict rather than assume one away.
- Keep: a distributed rate limiter needs one atomic operation, and needs a stated behaviour when its store is down.
- Keep: the hybrid feed threshold and roughly why it is where it is.
- Skip: memorising a canonical architecture diagram per problem. Interviewers change one constraint to find out whether you memorised it.
- Skip: naming specific products. Say what the component must do; the brand adds nothing and invites a question about internals you do not need.
Where people go wrong
Launching into components before asking about the read to write ratio. It decides most of the design, and asking it first is the signal interviewers are listening for.
If this comes up in an interview
How would you design a rate limiter?
Token bucket: tokens refill at a fixed rate, each request spends one, and bursts are allowed up to the bucket size. Distributed, it needs shared state in a fast store with atomic check-and-decrement to avoid races between servers.
Fan-out on write or fan-out on read for a news feed?
Both. Fan-out on write makes reads fast at the cost of expensive writes, which fails for accounts with millions of followers. Those use fan-out on read. The hybrid is the standard answer.
Build this: Design a rate limiter for a model API serving many tenants, out loud against a timer. Cover the algorithm, where the state lives, and what happens when that store is down.
Watch it explained
This is one section of Distributed systems, 17 hours across 4 sections. Read it in full to get the whole topic in order.
Stop 38 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.