Design a URL Shortener
A complete 50-minute system design loop, turn by turn — with the whiteboard as it was actually drawn, and a margin note wherever the answer moved the score.
Companion to the URL Shortener System Design guide — that post is the answer key, this one is someone answering under a clock.
How to read this transcript
Framing, and the requirement that reshapes everything
Being assessed: does the candidate accept the problem as stated, or find the version of it that is actually hard? At L6+ the opening nine minutes are the only place you can still change what you're graded on.
Thanks for making the time. Fifty minutes, and I'd rather go deep on three things than shallow on ten — so I'll cut you off when I think we've got what we need from a topic. Don't read anything into it.
The prompt is the classic one: design a URL shortener. Ask me whatever you need.
One question before I draw anything, because it changes almost everything downstream. Is this a standalone consumer product — a bit.ly, where shortening is the product — or is it a platform component inside a larger company, where every link posted into a feed or a DM gets wrapped?
Take the second. You're inside a company with a large social product. Every outbound link in a post or a message goes through your service.
Then three things are true that wouldn't be for bit.ly, and I want to check each one with you.
One — traffic isn't uniform, it's correlated. A consumer shortener grows smoothly. A link wrapper inherits the distribution of the feed, which means one post going viral is a single key taking millions of requests per second while the other billion keys sit idle. The hard problem isn't aggregate QPS, it's the shape.
Two — we become a security surface. Once every link in the product resolves through us, we're the natural place to intercept malware and phishing, and we're also the natural place to attack. That's a functional requirement, not an operational afterthought.
Three — we're in someone else's critical path. Our availability target isn't ours to pick. If the feed is four nines, a dependency of the feed has to be better than four nines, or we're the reason the feed misses.
Does that match how you'd frame it, or am I inventing constraints?
(writing) That's the right framing. Keep going — what's in scope?
Sam spent 90 seconds converting a generic prompt into a specific one, and did it by asking a disambiguating question rather than a checklist question. "What's the QPS?" gets you a number. "Is this a product or a platform?" gets you a different interview.
Note the third point especially. Deriving an availability target from a caller's SLO — rather than reciting "four nines" — is the first hint this is a senior candidate.
Functional, in priority order: create a short code for a long URL; resolve a code to a URL with a redirect; resolve in bulk, because the feed will want to render link previews for fifty links in one API call and I don't want fifty round trips; record a click; and take a link down.
Explicitly out of scope, and I want to say these out loud rather than quietly skip them: custom vanity domains, a user-facing analytics dashboard, and link editing — changing where an existing code points. I'll come back to that last one, it's more interesting than it looks.
Why is takedown in scope when a dashboard isn't? Both look like product features to me.
Because a dashboard is a feature and takedown is an obligation. If someone puts a phishing page behind one of our codes and it's spreading through DMs, the question "how fast can we stop it resolving, everywhere" has a legal and a trust answer, not a product answer. And more to the point for this conversation — it's the only requirement in the list that reaches all the way down and constrains the cache layer.
So let me ask the number directly: what's the required takedown propagation time? Not the average. The worst case.
Trust and Safety wants five minutes, globally, p100.
Then that's the most constraining requirement in the room, and it's the one I'd have missed if I'd started with QPS.
Five minutes p100 means I cannot put a long TTL in front of this system and walk away. Every caching layer I add — edge, in-process, Redis — spends from that same five minutes. It also means a plain HTTP 301 is off the table, because a 301 cached in a browser is a cache I have no ability to invalidate at all. I'll design the invalidation path before I design the cache, because the reverse order is how you end up with a system that can't meet this.
A policy number ("five minutes") was handed over, and Sam immediately converted it into three architectural constraints: a TTL ceiling on every cache layer, the elimination of 301, and an ordering constraint on the design work itself.
This is the behaviour that separates levels more reliably than any amount of storage math. Most candidates hear "five minutes" and write it in the corner of the whiteboard. The requirement then has no effect on anything they draw.
Non-functionals, then. Let me write them as targets I'm willing to be held to.
(writing, talking through it) Availability first, and I'm going to split it, because redirect and create are not the same system as far as a user is concerned. A redirect is someone else's click — if it fails, a person who never chose us sees a broken link, so I want real headroom over the feed that depends on us. A create failing shows up as a red toast in front of a human at a compose box who can press the button again. If I write one number across both, I'm buying uptime on the create path that nobody asked for, and paying for it in the redirect path's complexity budget.
Latency the same way, and for the same reason. Thirty milliseconds p99 at the edge on redirect, because we are a hop inserted into a click that isn't ours; the correct feeling is that we weren't there. Two hundred on create, and I'm writing that down deliberately generous. The expensive abuse work is asynchronous — fetch the target, reputation lookups, all of that happens after we've handed back a code, and a bad verdict blocks the link on the same channel as a takedown. The slack is for the synchronous part: a blocklist hit, a rate limit on the creator, reserved words. That has to land before I return anything, and I'd rather have the room now than discover halfway through that I've already committed it.
Then the last two, which are the ones I'd defend if you pushed on them. Durability I'm writing as “no silent loss” rather than a nines number, because a lost mapping isn't degraded service — it's a permanent 404 on a link that is already printed, posted, and out of our reach. And click accuracy I'm writing down loose on purpose: one percent, minutes late. That's the budget I'm spending to buy write throughput, and I'd rather it be on the board now as a stated trade than something I quietly assume when I get to the storage section.
-
redirect avail.99.995%
Half a nine better than the feed that depends on us. ~2 min/month.
-
create avail.99.9%
A failed create surfaces to a human at a compose box who can retry. Asymmetric on purpose.
-
redirect latencyp99 < 30 ms @ edge
It’s a hop inserted into someone else’s click. It has to be invisible.
-
create latencyp99 < 200 ms
Inline with posting. Generous, and I intend to use the slack.
-
durability of mappingsno silent loss
A lost mapping is an unrecoverable 404 on a link already in the wild.
-
takedown propagation< 5 min p100, global
Given. Drives the entire cache design.
-
click-count accuracy±1%, minutes late
Product metric, not a ledger. I’m spending this one to buy throughput.
Capacity, and a deliberate refusal to use the average
Being assessed: arithmetic fluency is table stakes. The real test is whether the numbers you produce go on to constrain a decision later, or just sit on the board.
I'll size this quickly — five minutes, and I'm only chasing the numbers that will change a decision. Give me a starting point: how many links created per day?
A hundred million a day.
(writing on the board) So 100M a day over 86,400 seconds is 100,000,000 ÷ 86,400 s = 1,157/sA day is 86,400 seconds. Rounded up to 1,200 because everything downstream of this is order-of-magnitude work, not accounting.. With a 3× diurnal peak, call it 1,157/s × 3 = 3,471/sThe 3× diurnal peak is the busy-hour rate over the 24-hour mean — traffic bunches into waking hours. Sam takes the multiplier as given rather than deriving it, which is normal at this stage.. That is — and I want to be blunt about this — 3,471 writes/s ÷ ~20,000 inserts/s per primary ≈ 17%A single NVMe-backed Postgres primary does tens of thousands of simple indexed inserts a second. The peak write rate is a fraction of one box — which is why Sam refuses to spend any more of the fifty minutes on it.. A single well-tuned Postgres box does that. The write path is not where this system is hard, and I'm going to stop thinking about write throughput now.
Reads. I'm going to assume 100 to 1, which gets me 10 billion redirects a day, 100M × 100 = 10B reads/day
10,000,000,000 ÷ 86,400 = 115,741/sThe 100:1 read/write ratio is an assumption Sam states rather than one he was given — and it is the one Dana attacks seventy seconds later., and with 115,741/s × 4 ≈ 463,000/sA heavier 4× multiplier than the writes got, then rounded up to 500K for headroom — a mean-derived peak hides event spikes entirely. The Zipf discussion at 10:26 is what makes that rounding look inadequate..
Where does 100 to 1 come from? You just asserted it.
Fair — it's a rule of thumb and I shouldn't hide behind it. Let me rebuild it from the product instead. A link goes in a post; the post reaches some audience; some fraction of that audience clicks. Median post, small audience, maybe single-digit clicks, and a lot of links get zero. But a post from a large account reaches tens of millions.
Which means 100:1 is an average over a distribution that has no meaningful average. The number I actually need isn't the mean, it's the tail: what does the single hottest key look like? Because the mean tells me how many servers to buy, and the tail tells me what the architecture has to be.
(draws a decay curve in the corner of the board) My working assumption: something like a Zipf distribution, where the top 0.1% of codes carry the majority of reads, and the hottest single code during a major event can be doing seven figures a second on its own. I'd want to validate that against real feed data on day one, but I'll design for it.
Caught using a rule of thumb, Sam didn't defend it and didn't abandon the estimate — Sam rebuilt it from the product's mechanics and then argued the mean was the wrong statistic.
"The average is a lie here, the distribution is the requirement" is the sentence that makes the hot-key work in Phase 04 feel inevitable rather than bolted on. Estimation is supposed to load the gun you fire later.
Storage: 100M/day × 365 × 5 = 182,500,000,000Five years is the retention horizon Sam picks; nothing in the requirements fixed it. Links are effectively permanent, so this is a floor, not a ceiling. over five years, a long URL averages maybe 200 bytes, plus metadata and indexes, call it 500 bytes a row. That's 182.5B rows × 500 B = 91.25 TB
36.5B rows/yr × 500 B = 18.25 TB/yr500 bytes a row is the ~200-byte URL plus metadata and index overhead — roughly 2.5× the payload, which is the usual rule of thumb for an indexed store., growing 18 a year. Big enough that it's a partitioned store and ~18 TB today → 91 TB by year five
practical single node: ~30–60 TB usableCapacity is only the first reason. One node also caps IOPS for a 500K/s read path, and a rebuild at that size runs into days — so the partitioning is forced by recovery time as much as by disk., small enough that it is completely unremarkable in 2026.
The cache is the number I actually care about. If I hold the links created in the last week plus the persistent long tail — order 100M/day × 7 = 700M created last week
≈300M of those still see traffic
+ ~100M older long-tail codes ≈ 400MA working estimate, not a measurement. Most created links are never clicked at all — which is why 700M is a large over-count. The minority that do earn traffic are clicked hard for a day or two and then go quiet; the long tail is the residue of older codes that keep earning their slot. Sam would want real hit-rate data before sizing hardware on it. at ~280 bytes — that's 400,000,000 × 280 B = 112 GB~280 bytes is the URL plus Redis’ per-key serialisation overhead — key, pointer and expiry metadata, not just the string. Sam says “around 110” out loud because the 400M input is an estimate, and a rounded number is honest about that.. 112 GB ÷ ~20 GB usable per node ≈ 6A 32 GB instance holds roughly 20 GB you can actually fill, once you leave room for fragmentation, replication buffers and the slots a surviving node has to absorb when a primary dies. Six is the floor, not the answer — in a real sizing doc Sam would round to eight so that losing a node is not also a capacity event. with replicas. Also unremarkable. Which tells me the interesting problem is definitely not how much I cache, it's how fast I can un-cache, because of your five-minute number.
Good. Draw me the system.
The box diagram, and the one decision you can't take back
Being assessed: can the candidate hold a whole system in their head and still know which single component deserves four of the fifty minutes? Short codes are permanent public artifacts. Every other box on this board can be rewritten; the ID scheme cannot.
(drawing) Here's the whole thing, and then I'll pick one box and go deep.
Read path: client hits an PoP = point of presenceA rack of CDN servers in a metro near the user; Cloudflare, Fastly and CloudFront each run a few hundred. The request terminates there instead of crossing an ocean, so a redirect answered at the edge costs 10–30 ms rather than the ~150 ms a round trip to a single origin region would. That is what makes the 30 ms p99 target reachable at all.; the worker there holds a small, short-lived cache of code-to-URL mappings and can answer without leaving the PoP. On a miss it goes to the API tier, which has an in-process LRU, then a Redis cluster, then the KV store. Four layers, and I'll justify each one by what it's buying.
And the TTLs, because those are what actually spend your five-minute budget. Sixty seconds at the edge. Ten in the API process — that one exists to collapse bursts on a single host, not really to hold data. Three hundred in Redis, which is the real cache and wants the longest life I can give it. Each number sized for what its own layer is buying.
Write path: POST /shorten hits a separate pool, pulls an ID, writes to the KV store, returns. Notice there's no cache write on create — I'll explain why when we get to it.
One box is deliberately unbranded. It says KV store, not a product name, because which product it is doesn't move a single arrow on this board — and a name I can't yet defend is worse than a blank, because it invites you to argue it before I've shown you the access pattern. I'll fill it in when we do the data model.
Two things I'm deliberately drawing that most people leave off. The click stream is a dashed line, because the day analytics is in the redirect's critical path is the day a Kafka hiccup takes down the feed. And this red band across the top is the takedown blocklist — its own control-plane channel that reaches every caching layer independently. I'm drawing it first because of your five-minute number, not last.
Take me into the ID allocator. How do you generate a code?
This is the box I wanted to spend time on, so good. Four options, and three of them die on numbers we already have on the board.
Hash the URL, truncate to seven characters. Attractive because it's stateless and gives you free deduplication. It's dead. We computed that 182 billion rows in a 3.5-trillion keyspace is 182.5e9 ÷ 3.52e12 = 5.18%
1 ÷ 0.0518 ≈ 1 in 19Occupancy is what matters, not birthday-paradox math: each new code is drawn against a keyspace already 5% full, so it lands on a taken code about one time in nineteen. Sam rounds to one in twenty., so roughly one insert in twenty collides. That means a read-before-write on every single create to check for a collision, plus a retry loop — and now my "stateless" scheme requires a consistent read against a partitioned store on the write path. I've paid the coordination cost anyway and got a worse system.
Random seven characters. Same collision arithmetic, no dedup upside. Dead for the same reason.
A global counter, base62-encoded. Collision-free, which is the thing I want. Two problems. It's a coordination point on every write — and, worse, codes become sequential, so anyone can walk the entire corpus of links our users have shared by incrementing. At a social company that's a privacy incident, not an inconvenience.
What I'd actually build: a leased-block counter with a keyed permutation on the output. You get the counter's collision-freedom, the random scheme's resistance to enumeration, and near-zero coordination.
Spell out the permutation. What exactly are you applying?
A small Feistel network — format-preserving encryption, essentially. 62⁷ is about 62⁷ = 3,521,614,606,20862 symbols — a–z, A–Z, 0–9 — to the seventh power. 2⁴¹ = 2.20e12 and 2⁴² = 4.40e12, so it sits between them, which is why 42 bits is the right cipher width., which sits between 2⁴¹ and 2⁴². So I build a keyed Feistel cipher over 42 bits. It's a bijection on [0, 2⁴²) by construction, so two distinct counter values can never map to the same output. That's the whole trick: collision-freedom is a property of the maths, not something I check for at runtime.
The one wrinkle is that 2⁴² is bigger than 62⁷, so some outputs fall outside the encodable range. You handle that by cycle-walking: if the output is ≥ 62⁷, re-encrypt it. The ratio is 4.4 over 3.5, so the expected number of rounds is 2⁴² ÷ 62⁷ = 4.398e12 ÷ 3.522e12 = 1.249Each encryption lands inside the encodable subset with probability 1/1.249 ≈ 0.80, so the expected number of passes is 1.25. The tail is geometric: three passes or more happens about 4% of the time, four or more under 1%., and cycle-walking preserves the bijection because you're just following a permutation cycle until you land back in the subset.
Then base62 the result and you have a seven-character code that is collision-free, non-sequential without the key, and cost zero coordination to produce.
What it does not give me is secrecy, and I want to be exact about that because it's easy to oversell. At five percent occupancy a random seven-character string is a live code about one time in twenty — a scanner finds real links by sampling whether or not I permute anything. What the cipher buys is that you can't walk the corpus: no code tells you the next one. If we ever need links that are genuinely private, that's a longer code and a different conversation.
Counter value n comes from a locally-held block, so no network call. x = E_k(n) where E_k is a balanced Feistel permutation on 42 bits with a secret key. While x ≥ 62⁷, set x = E_k(x) — expected 1.25 iterations. Emit base62(x).
Two properties fall out for free and both matter later: it is invertible with the key, so a code can be validated as well-formed before any lookup (cheap junk-traffic rejection). The key is then fixed for the life of the namespace — rotating it would re-map the counter and hand me back the collision problem the bijection exists to remove.
A host dies holding a leased block. What happens?
We burn the remainder of the block. Nothing is corrupted — the block is never reissued — we just lose some IDs out of 3.5 trillion, which is the correct trade. I'd rather waste keyspace than coordinate on the write path.
Although — let me actually check the rate before I say that so confidently. If a block is a million IDs and we have a couple of hundred hosts redeploying, say, ten times a day, that's two thousand burned blocks a day. Two billion IDs a day burned against a hundred million used. That's twenty times more waste than use. Over five years I'd chew through the whole keyspace.
(erasing) So a million-ID block is wrong. Make it ten thousand. At our write rate each host burns a block every nine minutes or so, the allocator sees well under one lease per second, and a deploy now costs ten thousand IDs, not a million. And on a graceful shutdown the host returns its remainder, so only hard crashes cost anything at all.
The block size is a three-way knob, and I had it set for the wrong one: it trades allocator load against ID waste against how long a host can keep serving writes when the allocator is down.
Sam stated a confident answer, immediately stress-tested it with arithmetic, found it wrong by a factor of twenty, and corrected it on the board without being prompted.
Interviewers weight this very heavily and candidates consistently underestimate it. A design that's right because the candidate checked is worth more than a design that's right because the candidate guessed — the second one tells you nothing about what happens when they're alone with a real system. The closing line, reframing block size as a three-way trade rather than a constant, is the part that reads as L6+.
Two users shorten the same long URL. Same code, or two codes?
Two codes, and this is a decision I'd defend to a product manager who wanted otherwise.
Dedup looks like a pure storage win, and the storage argument is real but tiny — we established this is 100 TB, which nobody cares about. What it costs is three things. It merges two users' analytics into one counter. It makes one user's takedown delete the other user's link. And it's a privacy oracle: if shortening a URL returns an existing code, I've just learned that somebody else already shared that URL. Point that at a URL with a user ID in the query string and you have a membership test against private activity.
So: no dedup on the public path. If storage ever genuinely hurt, I'd dedup the values underneath — many codes pointing at one interned URL row — while keeping the codes distinct. That gets the storage win without any of the three costs.
A security argument that the interviewer didn't ask for, arrived at from first principles, and then a fourth option that captures the benefit while dropping the cost.
"Dedup the values, not the keys" is the kind of answer that only shows up when someone has actually operated a system like this. It's also the first moment Dana wrote something in the L7 evidence column rather than the L6 one.
The cache stack
Being assessed: everyone can describe cache-aside. The discriminating question is whether the candidate has reasoned about what the cache makes impossible, and what they do when the interviewer finds the hole they didn't.
Walk me through one redirect where nothing is cached anywhere.
Request lands at the nearest PoP. The worker inverts the code with the Feistel key first — that's a few microseconds and it rejects malformed or scanned-for garbage before it costs us anything. Then: edge cache miss, so it calls the regional API tier. L1 miss. Redis miss. Read from the KV store by partition key hash(code), single-digit milliseconds. Populate Redis, populate L1, populate the edge, return a 302 with Cache-Control: private, no-store.
Two details that matter more than the happy path. First, the fill is guarded by a per-host 1,000 concurrent misses on one host → 1 KV readA lock keyed by the cache key. The first request to miss goes and fetches; every other request for that same key on that host attaches to the call already in flight and shares its result. It is the standard guard against a cache stampede — the moment a hot key expires and every concurrent reader heads for the origin at once. Note it is per host: with three thousand API hosts it bounds the herd to three thousand reads, not one., so a thousand concurrent requests for the same missing code produce one KV read, not a thousand. Second — and this is the one people skip — nothing is written to any cache on create. Most links are never clicked. Pre-warming on create would fill the cache with a hundred million entries a day that nobody will ever ask for and evict the ones that matter. The cache is populated by demand, because demand is the only signal that's actually correlated with future demand.
Let's go back to your numbers. You've got sixty seconds at the edge, ten in the API process, three hundred in Redis. Trust and Safety deletes a link one second after a fill. How long does that link keep resolving?
(pause) ...longer than my budget. Let me work it properly rather than guess.
They don't just take the max, they chain. Redis fills at t = 0 and is valid to t = 300. At t = 299 an L1 fills from Redis and is valid to t = 309. At t = 308 an edge worker fills from that L1 and is valid to t = 368. So the worst case is 300 + 10 + 60 = 370 seconds, and I told you five minutes. I'm over by more than a minute.
That's a real hole and I'm glad you pushed. Two things come out of it.
The mechanical fix: shrink the TTLs so the chain fits. Redis 240, L1 10, edge 30 — that's 280 seconds, inside 300. But that leaves twenty seconds of headroom for a p100 target, which is not a margin, it's a rounding error. Any clock skew or a slow delete and I'm out of budget again.
The real fix is that TTL should never have been my takedown mechanism in the first place. It's the backstop. The mechanism is the red band I drew at 15:00 — the blocklist. A delete writes a tombstone to the KV store and publishes the code onto a control-plane channel that every edge worker, every API host and every Redis proxy subscribes to. Codes land in a compact set at each layer within a couple of seconds, and the layers consult it in front of their own cache. TTLs then only have to cover the case where the push itself failed — and under p100 I don't get to call that rare. What I do get is that it's detectable: a worker that loses its subscription knows it has, and can fall back to short TTLs or straight to origin until it recovers. The budget only degrades where I can see it degrading, which is the version of this I can actually defend.
The miss is real. Sam specified three TTLs across three layers without checking whether they composed, against a requirement Sam had personally identified as the most constraining in the room twenty minutes earlier. That's not a trivia slip; it's the exact class of error that ships.
The recovery is worth more than the miss cost. Sam did not argue, did not hand-wave, and did not just patch the numbers — although the patch was computed correctly and out loud. Sam then named the deeper error: a TTL is a backstop, not an invalidation mechanism, and pointed at a component already on the board that solves it properly.
Dana's note reads: "found the hole, fixed the number, then fixed the reason the hole existed. Net positive."
Good. Different problem. A huge account posts a link and it goes to two million requests a second against one code. What breaks?
Not the KV store — it never sees the request. Not the API tier either, that's just horizontal capacity. What breaks is the single Redis shard that owns that key. Partitioning is by key hash, so two million requests a second land on one primary that tops out somewhere around 2,000,000/s ÷ ~175,000/s ≈ 11× over the shard’s ceilingA Redis primary runs commands on a single thread, so one shard’s ceiling is set by one core rather than by the box. Adding replicas or bigger hardware does not move it for a single hot key. operations a second. That shard browns out, and because it's also serving a slice of everything else, the blast radius is much wider than the one viral link.
(writing) Three mitigations, and I want to order them by how quickly they engage rather than by how clever they are.
- L1 already solved it. With three thousand API hosts each holding that code in-process for ten seconds, Redis sees three thousand requests per ten-second window — three hundred a second, not two million. The hot key is the case the in-process cache exists for. It's not a special mechanism, it's the one I'd already drawn.
- Key splitting, if L1 weren't available: write the value under
code:0throughcode:Nand have readers pick a random suffix, so the load spreads across N shards. Real cost is N copies of the same value in the tier I'm already sizing by memory, and every write has to land on all N. I'd take this only for a key that's hot and long-lived. - Promote to the edge. If a code crosses a threshold at a PoP, extend its local lifetime and let it resolve entirely within the PoP. The blocklist check still runs, so it doesn't cost me takedown.
Honestly, two million a second on a warm key is the easy version. The version that scares me is the same key cold in every location at once — a scheduled post going live, so every PoP misses simultaneously. That's a thundering herd against a cold KV partition. Singleflight bounds it to one read per host, but per host is the problem — three thousand hosts means three thousand simultaneous reads onto a single partition, and that is roughly where a DynamoDB partition tops out. I'd be betting on adaptive capacity reacting faster than the spike arrives, which is not a bet I want to make on a guess. That's the failure I'd actually write a load test for.
Three things happened in that answer. Sam located the failure precisely (one Redis primary, plus the collateral damage to its other keys — not "the cache falls over"). Sam noticed that the mitigation was already in the design instead of inventing a new component. And Sam priced the alternative in the currency established earlier — key splitting costs memory in a tier already sized.
Then, unprompted, Sam named a harder variant of the interviewer's own question and said which one would get a load test. That's the move that reads as operational experience rather than interview preparation.
Your third mitigation — doesn't the LRU already do that for you? A key that hot is never going to be evicted.
It won't be evicted, no. But eviction isn't what's hurting me — expiry is, and those are two different mechanisms sitting on the same entry.
LRU decides what to drop when I run out of memory, and you're right that a key doing two million a second is the last thing it would ever choose. The entry doesn't leave the PoP because of memory pressure. It leaves because it turned thirty seconds old. LRU has no opinion about age, so that key gets refilled every thirty seconds, at every PoP, for as long as it stays hot — and the refills are the traffic I'm trying to keep off the origin.
So promotion is a TTL change, not a residency change. LRU is already doing its half correctly; it just can't do this half.
And the reason I'll do it for one key and not globally: thirty seconds at the edge is a line item in a five-minute budget I have already had to cut my TTLs to fit. If TTL were still my takedown mechanism I couldn't extend it for anything, ever. It's affordable now only because the blocklist reaches the PoP on its own channel — a promoted key is still killable in under five seconds. I'm spending something the blocklist bought me.
Separating eviction from expiry is a small distinction that sorts candidates quickly: both remove an entry, and only one of them is under your control per key. The follow-through matters more, though — Sam noticed that extending a TTL is only safe because a decision made earlier in the interview stopped TTLs from carrying the takedown guarantee. That is a candidate tracking what their own past choices bought them, unprompted.
Storage, and splitting a namespace by its consistency requirement
Being assessed: most candidates pick one consistency model and defend it for the whole system. The signal here is noticing that this system contains two populations of keys with genuinely different requirements, and refusing to make them share a model.
Data model. What's actually stored?
(writing) One primary table, partitioned on hash(code). I'd rather write the fields out than describe them, because about half of them are only on this list because of something you asked me earlier.
Four of them are the obvious ones — the code, the URL, who made it, when. The other two are load-bearing and I want to say why before you ask. expires_at is a native store TTL rather than a sweeper job, because a sweeper at this row count is a scan I'd have to schedule and then apologise for. And status exists because the read path has to branch on it — a blocked code must stop resolving without the row being deleted, otherwise takedown and garbage collection become the same operation and I lose the audit trail.
-
codechar(7–8)PK
The only key the read path ever uses.
-
long_urltext
~200 bytes average.
-
owner_idbigint
Takedown, rate limiting, attribution. Also the awkward one — see the secondary index.
-
created_attimestamp
Also the input to the abuse scan window.
-
expires_attimestamp, null
Native store TTL. No sweeper job, no scan.
-
statusenum
unverified → active → blocked. The read path branches on it.
(going back to the board and writing in the empty box) Now I can name it. Partitioned KV — DynamoDB if we want it managed, Cassandra if we want to run it ourselves. I left it blank earlier because the choice doesn't reach any other box on that board. Now the access pattern is written down, so it's arguable.
Why a partitioned KV rather than Postgres: the read path is a single-key point lookup at 500K/s with no joins, and 100 TB is past what I want on one primary with replicas. If I ever expect the access pattern to become relational — "show me every link in this campaign, filtered and sorted" — I'd revisit, because that's a genuinely bad fit for a KV store. Today it isn't in scope, and I don't want to pay for a query engine I've explicitly said I won't use.
The interesting wrinkle is the second access pattern. Takedown sometimes arrives as "every link this account ever created," which is a query by owner_id against a table partitioned by code. That's a secondary index, written asynchronously, and I want to be careful about what I let it be responsible for. It's fine for enumerating what to take down. It must not be in the path that guarantees a link stopped resolving — because it's eventually consistent, and my five-minute promise is p100. The authoritative stop is still the tombstone on the primary row plus the blocklist push, both keyed by code.
"Eventually consistent index is fine for finding things, not for guaranteeing things" is a distinction that only gets made by someone who has been burned by it. Most candidates add a GSI and move on without ever asking what correctness property they just made dependent on replication lag.
We're in three regions now — Europe, US, Asia. Where do writes go?
Okay — this is the part the ID scheme was for.
Each region leases from a disjoint counter range. Two regions can therefore never generate the same code, no matter what — it's not "unlikely," it's structurally impossible. So every region accepts writes locally with zero cross-region coordination, and rows replicate asynchronously afterwards. No global consensus on the write path, no leader region, no cross-region latency in create.
There is exactly one thing that breaks that, and it's custom aliases. The moment a user picks the string, two people in two regions can pick launch2026 at the same instant, and now I genuinely need consensus. Uniqueness on a user-chosen name is a CP problem and no amount of cleverness makes it an AP one.
So I'd split the namespace by its consistency requirement rather than picking one model for both. Generated codes — over 99.9% of creates — stay AP and local. Custom aliases go through a single strongly-consistent namespace: either a globally consistent store like Spanner, or the boring version, which is routing all alias creation to one home region and accepting a couple of hundred milliseconds on a path that handles maybe one write a second. Aliases are also lexically distinguishable from generated codes — different length, or a reserved prefix — so the read path knows which population a code belongs to without a lookup.
The common failure is to notice that custom aliases need uniqueness and then make the entire ID system strongly consistent to accommodate 0.1% of traffic. Sam went the other way: contain the expensive requirement inside the smallest possible piece of the system.
Let me push on the premise. Twelve hundred writes a second isn't a lot. One primary handles that without noticing, and you'd get read-after-write for free. What is multi-master actually buying you?
Less than I'd like, honestly.
Not throughput — you're right, twelve hundred a second is one cluster with headroom to spare. And not availability, which is the answer I'd reach for if I weren't checking. I wrote create availability down at three nines myself, and justified it by telling you a failed create is a red toast in front of a human who can press the button again. Three nines is 0.1% × 30 d × 24 h × 60 min = 43.2 minAgainst Sam's own scratchpad target of 99.9% on create. A single primary with a synchronous standby in another AZ and a rehearsed cross-region failover clears this comfortably — which is exactly why he refuses to use availability as the justification.. A primary with a synchronous standby and a rehearsed failover clears that. I don't get to use my own number when it helps and ignore it when it doesn't.
What it actually buys me is Asia. Singapore to Virginia is something like SIN → us-east-1 ≈ 230 ms RTT
FRA → us-east-1 ≈ 85 ms RTTTypical public-internet round trips, and the floor is physics rather than engineering — the fibre path is longer than the great-circle distance. Numbers to sanity-check against real measurements, not to quote as measured. milliseconds round trip, and my create budget is two hundred, total. Under one primary in Virginia the ocean spends the entire budget before we've done a single useful thing, and nothing I tune inside the request fixes a number that's already over at the network layer. Europe I could just about defend — ninety milliseconds leaves me something to work with. Asia I can't. So the honest framing isn't that multi-master is better; it's that the third region is what breaks the single-primary version, and the third region is yours, not mine. You put it on the table.
And I want to be precise about what I'm committing to, because that's two decisions and they don't cost the same. The write topology is reversible. If we ship one primary with regional replicas and it turns out Asia doesn't care, or the third region never materialises, going active-active later is a deployment change. The ID scheme is not reversible. Allocate from one global sequence and every code we've ever issued is identified by a number only that sequence could have produced — splitting it across regions later means re-identifying every row in the table, and those codes are already printed and posted and out of our reach.
So the sequencing I'd actually defend: take the irreversible half now — disjoint leased ranges, from day one, even in a single region where it buys you nothing you can see — and defer the reversible half until a region forces it. Which is roughly what I'd do if you cut my time in half: one region, replicas elsewhere for reads, same ID scheme underneath. The scheme is the part I'd never defer.
Sam was handed a free win and declined it. The easy answer to “why multi-master” is availability, and most candidates take it without checking. Sam checked it against a target he set himself at 05:10, found it didn't hold, and said so out loud before offering the one justification that survives his own numbers.
The second half is the level split. Two decisions arrived coupled; Sam separated them by cost of being wrong rather than by topic, then sequenced them accordingly — ship the half you can't unwind, defer the half you can. A candidate doing that is managing option value, not drawing boxes.
Someone creates a link in Frankfurt and pastes it into a DM immediately. A user in Oregon clicks it a hundred milliseconds later, before your async replication lands. What do they get?
With a naive design, a 404. And a 404 on a link that genuinely exists is the worst outcome this system can produce — worse than a slow redirect, worse than an error page, because it's wrong and the user has no way to know it's transient. They assume the link is broken and they never click it again.
So I encode the home region in the code itself. Reserve the first character — or just two bits of the pre-permutation counter, which is cleaner because the Feistel key still hides it from anyone without the key. Now a miss carries information: Oregon misses on a code whose hint says Frankfurt, so instead of returning 404 it does a synchronous read-through to Frankfurt, pays maybe eighty to a hundred milliseconds, and returns the right answer.
What I've done is convert a wrong answer into a slow answer, on a path that's rare by construction — it only happens inside the replication window for a link created elsewhere. And I still return 404 if the home region also doesn't have it, which is the honest answer for a code that was never issued. That path is also where the Feistel inverse earns its keep: a code that doesn't decode to a plausible counter value is junk, and I reject it at the edge without a cross-region hop at all.
Failure, abuse, and the promise that has to survive an outage
Being assessed: whether "degraded" means something specific to this candidate — which guarantees are load-bearing and must hold through an outage, and which ones are allowed to bend.
Your entire Redis cluster is gone. Not one shard — all of it. It's 9pm on a Friday.
Let me get the number first, because it decides whether this is an incident or an outage.
(counting on the board) 500K/s arriving. The L1 caches are unaffected — they're process memory on the API hosts. Because of the skew we sketched earlier, an L1 holding fifty thousand keys per host should be absorbing 500K/s × 0.80 = 400K/s from L1
remainder = 100K/s → KV, vs ~5K/s steady = 20×This falls straight out of the Zipf assumption at 10:26: if the top 0.1% of codes carry most reads, fifty thousand keys per host covers the head of the distribution and the hit rate is a consequence, not a target. on its own. So the L1s keep answering roughly 400K/s, and about 100K/s falls through to the KV store, against a normal steady state of maybe 5K/s. Twenty times normal load on the KV tier.
DynamoDB with on-demand capacity would ride that out and hand me a genuinely unpleasant bill. A self-managed Cassandra ring would not, not without warning. So I want two things that don't depend on which I picked.
Stale-if-error. When the fill path is failing, L1 entries stop expiring and keep serving past their TTL, capped at an hour. That takes the KV tier from twenty times normal down to about fourteen, and it keeps redirects working.
Shed the tail, not the head. If I still have to drop load, I drop cold codes first. A code nobody has asked for recently is disproportionately likely to be a scanner walking the space rather than a human clicking a link in a feed. Shedding by popularity protects real users; shedding randomly protects nobody.
You just said entries stop expiring for up to an hour. You promised me five-minute takedown. Which promise are you breaking?
Neither.
Stale-if-error relaxes the freshness of a mapping — a link whose destination was edited might serve an old target for an hour, and I'm comfortable with that because I already declared link editing out of scope. It does not touch the blocklist. The blocklist arrives on its own channel, lands in a set in each process, and is consulted before the cache lookup, not after. So a blocked code stops resolving in seconds whether the entry is fresh, stale, or an hour past its TTL.
That's the thing I'd want on a design review slide, actually: the safety guarantee and the freshness guarantee ride on different channels, so degrading one can't degrade the other. If takedown had been implemented as "short TTLs," everything I just described would have been a lie, and it would only have become a lie during an outage — which is exactly when nobody is checking.
Dana set a trap out of Sam's own words, and it didn't close, because the separation that made it survivable was a deliberate choice made twenty-six minutes earlier and not a lucky one.
The sentence about safety and freshness riding on different channels is a principle, not a fact about this system. Candidates who can compress a design decision into a transferable rule are the ones who end up writing the design docs everyone else copies.
Quickly — abuse, and analytics. Two minutes each.
Abuse. Every new link is created in unverified and queued for an async scan against a reputation service plus our own signals. Scanning inline would put a third party inside my create latency and inside my availability number, so it's out of the request path. While a link is unverified, the redirect serves a short interstitial — "you're leaving, here's where you're going" — instead of a direct 302. That window is seconds to a minute, and almost nothing resolves inside it — the poster still has to paste the link somewhere before anyone can click it, and that takes longer than the scan. So the UX cost lands almost entirely on scanners and on the poster's own first click.
Analytics. No per-click write anywhere. Each API host aggregates in memory and flushes a batch every ten seconds, which turns 500K events a second into 3,000 hosts ÷ 10 s window = 300 msg/s ≈ 1,700:1Message volume tracks hosts × ten-second windows, not clicks — each message is one host’s rolled-up counts for a window. Doubling traffic barely adds messages, which is the whole point of aggregating at the edge of the pipeline. a second into Kafka. Flink rolls it up, ClickHouse serves it.
If a host dies mid-window we lose up to ten seconds of its counts. I'll take that trade explicitly: click counts are a product metric with a ±1% tolerance I stated at the start, not a ledger. If this were billing — if we charged per click — I'd need per-event durability with idempotency keys, and I'd be building a meaningfully more expensive system to get it. The right question isn't "how accurate can I be," it's "what is this number used for."
Concretely I'd run Kafka for the bus, Flink for the roll-ups, and ClickHouse for the served counts — but those are the least load-bearing names on the board. Any log bus, any stream processor and any columnar store does this, and I'd pick on what the org already runs rather than on merit.
Last thing on this. Walk the failure modes — what breaks, how far it spreads, and what still holds while it's broken.
(writing a four-column table) Last column is the one I care about. If I can't name what still works, I haven't designed degradation — I've just listed outages.
| What fails | Blast radius | Response | What still holds |
|---|---|---|---|
| One Redis shard | ~1/6 of keys lose L2 | L1 + KV absorb; client-side shard eviction | Everything. Barely visible. |
| Entire Redis tier | All L2 gone | stale-if-error, shed cold tail, 14× on KV | Redirects + takedown |
| ID allocator down | No new leases | Hosts keep serving from their held block (~9 min) | All reads; writes for ~9 min |
| Kafka down | Click events drop | Bounded in-memory buffer, then discard | Redirects. Analytics goes dark, silently by design. |
| Blocklist channel down | Takedown falls back to TTL | Page immediately — this is a P1 even with zero user impact | Redirects; takedown degrades to 280 s |
| Whole region lost | Local writes in flight | Edge steering drains to peers; region-hinted reads absorbed by replicas | Reads globally; writes elsewhere |
"Page immediately — this is a P1 even with zero user impact" is a small line that says a lot. A silent failure of a safety control, during which nothing looks wrong, is the most dangerous state this system has. Candidates who have carried a pager write that row. Candidates who haven't, don't.
The last four minutes, where a level is sometimes decided
Being assessed: judgment under a forced cut, and whether the candidate knows which decisions are reversible. This is also where the interviewer checks a hypothesis they've been holding since minute ten.
You ship this and something's wrong. What is it?
The takedown path, and not because of the bug you caught — because of the number. Five minutes is what Trust and Safety asked for today. The first time something bad moves fast through DMs, that number becomes thirty seconds, and it'll arrive as an escalation, not a roadmap item.
At thirty seconds, TTLs are dead as a backstop and the blocklist push has to be provably fast, with its own monitoring and its own SLO. Everything else in this design degrades gracefully under a tightened requirement. That one doesn't — it changes the mechanism. So if I were building this for real, I'd build the blocklist propagation path first and the cache second, because caches are easy to retrofit and control planes are not.
One quarter. Four engineers. What do you cut?
Cut: multi-region — one region, replicas elsewhere for reads only. Cut the edge worker tier — and that one I'd have to renegotiate for, because thirty milliseconds p99 off-continent was only ever reachable by answering inside the PoP. L1 plus Redis is a fraction of the operational surface and it does not replace an ocean crossing. Cut custom aliases entirely, which deletes the only strongly-consistent component in the design. Cut the analytics pipeline down to a counter in ClickHouse with no Flink. And make the interstitial unconditional rather than status-dependent, so I don't need the scan pipeline to be good on day one.
What I would not cut, at any headcount: the ID scheme, and the blocklist as a separate channel.
The ID scheme because short codes are permanent public artifacts. If I ship sequential codes in Q1, I can't unship them — they're in people's messages forever, the enumeration exposure is already real, and there's no migration that fixes it. Every other component on that board is a stateless rewrite. That one is a one-way door.
The blocklist because retrofitting a control plane into a system that has been quietly relying on TTLs means touching every caching layer at once, under pressure, during whatever incident made it urgent.
The cut list is fine — most strong candidates produce one. The scoring line is the second half: sorting by reversibility rather than by importance, and giving a concrete reason why each survivor is a one-way door.
"Short codes are permanent public artifacts" is the sentence that justifies the four minutes spent on ID generation in Phase 03. The time allocation was an argument, and here it gets closed.
We're at time. Anything you want to ask me?
Two. What does on-call actually look like for the team that owns this — how many pages a week, and what are they usually about? And when a design like this gets written down, who has to agree before it gets built?
Good questions, I'll answer both. Before that — one thing for your own benefit, since we're done scoring.
There is an existing system. About two billion links in a legacy Postgres cluster, sequential IDs, seven years old, and the team spends a third of its time on it. You designed the greenfield version and you never asked whether you were building one.
(pause) …I should have asked that at minute two. And it would have changed real things — two billion sequential codes already in the wild means the enumeration exposure exists today, the new scheme has to coexist with the old namespace rather than replace it, and "cut multi-region" stops being a simple cut if the legacy cluster is the thing that can't move.
That's the question I'll be annoyed about on the drive home.
Dana had been holding this since minute ten. Sam asked excellent questions about the product ("is this a platform or a consumer app?") and none at all about the context ("does this already exist, who owns it, what does it cost them today?").
That distinction is close to the whole L6/L7 boundary in a design loop. L6 is asked to design the right system. L7 is asked to work out whether the system should be built, what it replaces, who has to be convinced, and how it gets from here to there without a flag day. Sam's recovery in the last twelve seconds is the correct instinct arriving about forty-seven minutes late.
Dana's debrief
Written up forty minutes after the loop, before reading anyone else's feedback.
Scorecard
Moments that moved the needle
What L7 would have looked like
Nothing on the whiteboard needed to change. The gap isn't technical, and I want to be careful saying that, because candidates hear "not L7" and go read another distributed systems book, which is the wrong response.
At L6 you are asked to design the right system. Sam did that, with better judgment about what to spend time on than most candidates two levels up.
At L7 the system is only half the deliverable. The other half is: what exists today and what does it cost the team? Who calls this, and what breaks for them when it changes? What is the sequence of shippable steps from here to there, with no flag day? Who has to be convinced, and what will they object to? And — the one I listen hardest for — should we build this at all, or is the real problem somewhere adjacent?
Sam reached for exactly that at 01:04, arguing the interesting system was the abuse-interception surface rather than the shortener, then dropped it and never came back. That instinct is already there. It needs to survive contact with an interesting technical problem, which is the hard part — because the technical problem is more fun, and forty-eight minutes is exactly long enough to forget.
Six things to steal from this transcript
Portable across every system design question, not just this one.
Ask the disambiguating question, not the checklist one
"What's the QPS?" gets you a number. "Is this a product or a platform component?" gets you a different interview. Spend your first 90 seconds on the question whose answer changes the design, not on the one whose answer fills in a blank.
Convert every policy number into a constraint, out loud
When someone hands you "five minutes," say what it forbids. Most candidates write the number in the corner and design as if they'd never heard it. The requirement is only real once it has eliminated an option.
Estimation exists to load a decision
Sam produced five numbers and two of them shaped the architecture. The other three were correct and irrelevant. Say which ones you're going to use — and when the mean is the wrong statistic, say that instead of computing it anyway.
Leave the rejected options on the board
The board is the only record of what you considered. Cross an option out, write the one sentence that killed it, and leave it there — the alternatives you never wrote down are, to your interviewer, alternatives you never saw.
Being caught is not the failure. Arguing is
Sam's TTLs didn't compose, and the recovery scored net positive: correct the number, then name the deeper error that made it possible. Being right because you checked beats being right because you guessed.
Sort by reversibility when you're forced to cut
Not by importance. Short codes are permanent public artifacts; a cache tier is a weekend. Knowing which of your boxes is a one-way door is most of what separates senior scope-cutting from a feature list.
— 50 minutes, one board, seven erasures.
- Rate Limiter System Design, atomic Redis operations, distributed race conditions, and multi-tier quota enforcement
- URL Shortener System Design, hash encoding tradeoffs, database sharding strategies, and viral key mitigation
- Web Crawler System Design, Bloom filter deduplication, politeness throttling, and distributed frontier design
- Twitter/X Feed System Design, fan-out write amplification, hybrid push/pull strategy, and celebrity threshold design
- Notification Service System Design, multi-channel delivery, idempotency keys, and priority queues at scale
- Search Autocomplete System Design, Trie data structures, prefix caching, and read-heavy scale strategies
- Key-Value Store System Design — quorum consensus, LSM trees and SSTables, and anti-entropy repair
- Distributed Cache System Design — consistent hashing, eviction and TTL policy, hot keys, and cache-aside vs write-through
- Chat System (WhatsApp) System Design, WebSocket management, transient vs persistent storage, and read receipts
- Video Streaming (YouTube) System Design, ABR streaming, CDN distribution, and metadata management
- Distributed Message Queue System Design, Kafka partition tuning, exactly-once delivery, and geo-replication
- File Storage (Dropbox / Google Drive) System Design, chunking, delta sync, conflict resolution, and global deduplication
- Ride-Sharing System Design (Uber / Lyft) — geohashing, WebSocket-driven location tracking, and ETA prediction
- Payment Processing System Design — idempotency keys, exactly-once semantics, and append-only ledger models
- Top-K Leaderboard System Design — Redis sorted sets, approximate counting, and stream aggregation
- Airbnb Booking & Reservation System — inventory locks, double-booking prevention, and async elasticsearch sync
- Photo-Sharing Feed System Design — image pipelines, CDN delivery, and social graph scaling
- Proximity Search System Design (Yelp / Google Places) — geohash indexing, quadtree partitioning, and Bayesian review ranking
- Online Judge System Design — secure sandboxing, execution queues, and worker scaling
- Collaborative Document Editing (Google Docs) System Design — operational transformation vs CRDTs, ownership leases, and offline merge
- Ticket Booking & Flash-Sale (Ticketmaster) System Design — seat-hold contention, optimistic vs pessimistic locking, and virtual waiting rooms
- Google Maps System Design — road-graph routing, contraction hierarchies vs customisable preprocessing, vector tiles, and live-traffic ETAs
- Object Storage (Amazon S3) System Design — erasure coding across zones, durability math, and a range-partitioned metadata index
- Social Post Search System Design — per-viewer visibility filtering, over-fetch math, and fan-out tail latency