Mock Interview · Full Transcript

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.

calibrated for L6 → L7 · Staff / Senior Staff
Target level · L6 (Staff) Interviewer · L7, panel chair Duration · 50 min Outcome · see debrief

Companion to the URL Shortener System Design guide — that post is the answer key, this one is someone answering under a clock.

Hero image for the URL shortener mock interview: a stick figure at a whiteboard explaining a design, one idea crossed out and redrawn, while an interviewer with a stopwatch and clipboard looks on
How the 50 minutes were spent
0 10 20 30 40 50 scope capacity design + IDs cache regions failure close this 24-minute stretch decides the level

How to read this transcript

DK
Dana K. — the interviewer. L7, chairs the design panel. Interrupts on purpose.
SR
Sam R. — the candidate, interviewing for L6. Eight years in, two of them on infra.
✓
Signal — the specific thing in that answer that scored, and why.
!
Miss / recovery — where the candidate was wrong, and what the recovery was worth.
✎
Whiteboard — the sketch as drawn, mid-conversation, arrows and crossings-out intact.
L
Level split — the same question answered at L5, L6 and L7.
?
Stated numbers — anything underlined with dots is a claim Sam made out loud. Click or tap it for the arithmetic underneath.
00:00 – 09:00Phase 01

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.

DK
00:00
Dana

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.

SR
00:35
Sam

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?

DK
00:52
Dana

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.

SR
01:04
Sam

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?

DK
02:20
Dana

(writing) That's the right framing. Keep going — what's in scope?

✓ Signal · 02:20

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.

SR
02:30
Sam

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.

DK
03:15
Dana

Why is takedown in scope when a dashboard isn't? Both look like product features to me.

SR
03:24
Sam

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.

DK
04:02
Dana

Trust and Safety wants five minutes, globally, p100.

SR
04:08
Sam

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.

✓ Signal · 04:08 — the single best moment of the interview

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.

SR
05:10
Sam

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.

NFRs — targets I’ll be held to candidate scratchpad · 05:10
  • 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.

L · The same nine minutes at three levels
L5
Asks good clarifying questions — read/write ratio, latency, scale — then produces a correct requirements list. Waits to be told what matters.
L6
Establishes requirements and their consequences: names which NFR drives which component, and states non-goals explicitly. Sam is comfortably here.
L7
Changes the problem. Argues that the interesting system isn't the shortener but the abuse-interception and link-identity surface around it, and gets the interviewer to agree before designing. Sam reached for this at 01:04 and then let it go.
09:00 – 15:00Phase 02

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.

SR
09:00
Sam

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?

DK
09:12
Dana

A hundred million a day.

SR
09:16
Sam

(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/s
The 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..

DK
10:20
Dana

Where does 100 to 1 come from? You just asserted it.

SR
10:26
Sam

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.

✓ Signal · 10:26

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.

✎ whiteboard · left panel10:20 – 13:40
WRITES 100 M links / day ÷ 86,400 s = 1,157 /s → ~1.2 K/s × 3 diurnal peak 3.5 K writes/s = nothing. move on. READS 100 : 1 (assumed, see →) = 10 B redirects / day = 115 K /s average × 4 peak 500 K reads/s STORAGE · 5 yr 100M × 365 × 5 = 182 B rows × ~500 B/row (url+meta) = 91 TB ~100 TB, +18 TB/yr KEYSPACE — 7 chars 62⁷ = 3,521,614,606,208 ≈ 3.5 × 10¹² rows / slots = 182 B / 3.5 T = 5.2 % occupancy so a truncated hash collides ~1 insert in 20 capacity is fine for ~90 yrs at this rate — the constraint is collisions + enumeration, not exhaustion …but the mean is a lie reads/s codes, ranked → top 0.1 % of codes ≈ most of the traffic 1 viral code alone → 10⁶ /s design for THIS, not 500 K
Figure 1 — the napkin. Three boxes of arithmetic, and only two numbers survived to influence the design: the 5.2% keyspace occupancy (which killed hash-based codes in Phase 03) and the shape of the curve on the right (which dictated the cache stack in Phase 04). The 91 TB came back exactly once, to rule out a single Postgres primary — it never sized anything.
SR
12:30
Sam

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/yr
500 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 usable
Capacity 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 ≈ 400M
A 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.

DK
13:40
Dana

Good. Draw me the system.

15:00 – 23:00Phase 03

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.

SR
15:00
Sam

(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.

✎ whiteboard · main panel15:00 – 17:10
TAKEDOWN BLOCKLIST control plane · gossiped to every layer in < 5 s · never on the request path drawn FIRST, not last client feed · DM · browser Edge PoP — a point of presence: a CDN rack in a metro near the user, so a redirect is answered 10–30 ms from the click instead of ~150 ms from an origin region.edge PoP worker, ~200 sites caches DATA, 60 s ✎ (never the response) API tier · stateless GET /:code + L1 LRU 50 K keys, 10 s POST /shorten 3.5 K/s · separate pool Redis cluster 6 primaries + replicas · ~110 GB TTL 300 s miss KV store · source of truth partition by hash(code) · ~100 TB engine: TBD — decide at the data model ID allocator leases 10 K blocks · see fig. 3 click events batched 10 s, fire & forget log bus rate: size it later stream job roll-ups columnar store counts · geo synchronous — inside the click asynchronous / control plane — can fail without anyone noticing
Figure 2 — the board at 17:10. Four read layers, one write path, and two things drawn in red that are not in the request path at all. The red band at the top is unusual: most candidates add invalidation after the interviewer asks about consistency. Sam drew it before drawing Redis. Note the store box carries no product name yet — Sam fills it in at 32:20, in Figure 5.
DK
17:15
Dana

Take me into the ID allocator. How do you generate a code?

SR
17:22
Sam

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 19
Occupancy 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.

DK
18:40
Dana

Spell out the permutation. What exactly are you applying?

SR
18:46
Sam

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.

◆ The mechanism, stated precisely

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.

DK
20:05
Dana

A host dies holding a leased block. What happens?

SR
20:10
Sam

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.

✓ Signal · 20:10 — self-caught

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+.

✎ whiteboard · right panel18:46 – 21:30
① lease a block [4210000 → 4220000) 10 K ids ≈ 9 min of writes held in memory n ② Feistel · 42-bit, key k x = E_k(n) bijection on [0, 2^42) ⇒ collisions impossible if x ≥ 62⁷ → re-encrypt (avg 1.25 rounds) x ③ base62(x) 0-9 a-z A-Z 7Kq2mZa 7 chars · non-sequential zero coordination no read-before-write, ever k is fixed — rotating it breaks the bijection what I'm NOT doing, and why hash(url)[:7] keyspace 5.2 % full → ~1 in 20 inserts collide → consistent read on every write → and it leaks: same url = same code global counter++ collision-free ✓ but coordination on every write and codes are sequential → anyone can enumerate the corpus random 7 chars non-sequential ✓ same 1-in-20 collision maths no dedup upside to pay for it strictly worse than ② block size 1M → 10K (see 20:10)
Figure 3 — ID generation. The rejected options are on the board on purpose. An interviewer can't give credit for a trade-off you made silently, and the three crossed-out boxes are doing as much work in the scorecard as the chosen one.
DK
21:40
Dana

Two users shorten the same long URL. Same code, or two codes?

SR
21:46
Sam

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.

✓ Signal · 21:46

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.

23:00 – 31:00Phase 04

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.

DK
23:00
Dana

Walk me through one redirect where nothing is cached anywhere.

SR
23:06
Sam

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.

DK
25:40
Dana

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?

SR
25:52
Sam

(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.

! Miss, then recovery · 25:52

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."

✎ whiteboard · erased and redrawn25:52 – 27:30
blocklist push · < 5 s T&S edge worker ~200 PoPs · caches the mapping 30 s API in-process L1 50 K keys LRU · absorbs the viral case 10 s Redis cluster ~110 GB shared working set 240 s KV store source of truth · tombstone written first 0 s miss miss miss how long does a deleted link keep resolving? TTLs chain — each layer can fill just before its source expires redis 300 + L1 10 + edge 60 = 370 s budget is 300 s. over by 70. redis 240 + L1 10 + edge 30 = 280 s fits. 20 s of headroom. 20 s is not a margin. it's a warning. → TTL is the BACKSTOP, for when the push fails → the blocklist is the MECHANISM — < 5 s, checked in front of every cache, independent of every TTL caught by the interviewer, not by me. fair.
Figure 4 — the takedown budget, second draft. The crossed-out arithmetic is left on the board deliberately: the correction is the interesting part, not the final number. The red spine on the left is the blocklist, and it is the only line on this diagram that does not obey a TTL.
DK
27:40
Dana

Good. Different problem. A huge account posts a link and it goes to two million requests a second against one code. What breaks?

SR
27:48
Sam

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:0 through code:N and 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.

✓ Signal · 27:48

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.

DK
28:40
Dana

Your third mitigation — doesn't the LRU already do that for you? A key that hot is never going to be evicted.

SR
28:46
Sam

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.

✓ Signal · 28:46

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.

L · "A link goes viral. What breaks?" at three levels
L5
"The cache handles it — Redis is fast, and we scale the API tier horizontally." Treats the cache as a single undifferentiated thing.
L6
Identifies the single hot shard as the bottleneck, proposes key splitting or a local cache, and quantifies the relief. This is the expected answer.
L7
All of the above, plus: prices each mitigation against a constraint established earlier, distinguishes the warm-viral case from the cold-viral case, and says which one is worth building a test for. Sam landed here.
31:00 – 39:00Phase 05

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.

DK
31:00
Dana

Data model. What's actually stored?

SR
31:05
Sam

(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.

links — the primary table candidate scratchpad · 31:05
  • 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.

SR
32:20
Sam

(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.

✓ Signal · 32:20

"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.

✎ whiteboard · store box, revisited32:20 – 33:10
the empty box, filled in — 17 min later, with the access pattern on the table primary table · source of truth pk = hash(code) ~100 TB · single-key point lookup · 500 K/s DynamoDB (managed)  or  Cassandra (self-run) Postgres no joins on the read path 100 TB > one primary + replicas revisit IF it ever goes relational why this could wait: no other box on the main board changes whichever one wins secondary index  owner_id → codes  · written asynchronously, eventually consistent ✓  fine for ENUMERATING every link an account made ✗  never the thing that GUARANTEES a link stopped resolving — that stays the tombstone + blocklist, keyed by code finds things ≠ guarantees things
Figure 5 — the store box, filled in. The engine was left blank on the main board at 15:00 and named here, at 32:20. That gap is the point: Sam deferred a decision that nothing else depended on, said so at the time, and came back to it once the access pattern made it arguable. The dashed band is the index he refuses to let carry a correctness guarantee.
DK
33:30
Dana

We're in three regions now — Europe, US, Asia. Where do writes go?

SR
33:38
Sam

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.

✓ Signal · 33:38 — the strongest architectural move of the interview

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.

DK
34:40
Dana

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?

SR
34:50
Sam

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 RTT
Typical 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.

✓ Signal · 34:50 — separating the reversible decision from the irreversible one

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.

DK
35:50
Dana

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?

SR
35:58
Sam

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.

✎ whiteboard · lower board33:38 – 38:20
disjoint ID ranges ⇒ writes are local, everywhere, with zero coordination EU · Frankfurt API tier · accepts writes KV replica · full copy range 0 · 1.17 T ids ~400 writes/s US · Oregon API tier · accepts writes KV replica · full copy range 1 · 1.17 T ids ~400 writes/s APAC · Singapore API tier · accepts writes KV replica · full copy range 2 · 1.17 T ids ~400 writes/s async ~150 ms async ~150 ms no consensus on the write path custom aliases ONLY · strongly consistent < 0.1 % of creates · ~5 writes/s · one global namespace Spanner, or just route them all to one home region contain the CP requirement, don't spread it region hint rides inside the counter, pre-permutation: [2 bits region][40 bits counter] → E_k the hint never shows up in the code — E_k hides it miss in US + hint says EU → read through to EU (+90 ms), not a 404 a wrong answer becomes a slow one
Figure 6 — three regions, two namespaces. The blue path is AP and never leaves a region. The purple box is the only strongly-consistent thing in the system and it handles about one write a second. Deciding which 0.1% of your traffic gets to be expensive is most of what multi-region design is.
39:00 – 46:00Phase 06

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.

DK
39:00
Dana

Your entire Redis cluster is gone. Not one shard — all of it. It's 9pm on a Friday.

SR
39:08
Sam

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.

DK
41:00
Dana

You just said entries stop expiring for up to an hour. You promised me five-minute takedown. Which promise are you breaking?

SR
41:08
Sam

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.

✓ Signal · 41:08 — the structure paying off

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.

✎ whiteboard · scratch corner39:08 – 40:40
redis: entirely gone. is this an incident or an outage? 500 K/s arriving, unchanged L1 absorbs ~80 % 50 K keys/host · skew does it (process memory — unaffected) stale-if-error + shed serve past TTL, 1 h cap drop COLD first, never hot ~70 K/s to KV · vs 5 K normal 14× normal on the KV tier — survivable on on-demand capacity, expensive, and I'd want the alert before the bill. …and the blocklist channel is untouched by any of this stale-if-error relaxes FRESHNESS (a mapping may be an hour old — fine, editing is out of scope) the blocklist is SAFETY, checked in front of every cache, on its own channel, still < 5 s degrade one without degrading the other ⇐ the whole point
Figure 7 — losing the entire cache tier. Drawn in ninety seconds in the corner of the board while answering. The point of the sketch isn't the arithmetic, it's the bottom band: which promise bends and which one doesn't.
DK
42:30
Dana

Quickly — abuse, and analytics. Two minutes each.

SR
42:36
Sam

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.

DK
44:30
Dana

Last thing on this. Walk the failure modes — what breaks, how far it spreads, and what still holds while it's broken.

SR
44:38
Sam

(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 failsBlast radiusResponseWhat still holds
One Redis shard~1/6 of keys lose L2L1 + KV absorb; client-side shard evictionEverything. Barely visible.
Entire Redis tierAll L2 gonestale-if-error, shed cold tail, 14× on KVRedirects + takedown
ID allocator downNo new leasesHosts keep serving from their held block (~9 min)All reads; writes for ~9 min
Kafka downClick events dropBounded in-memory buffer, then discardRedirects. Analytics goes dark, silently by design.
Blocklist channel downTakedown falls back to TTLPage immediately — this is a P1 even with zero user impactRedirects; takedown degrades to 280 s
Whole region lostLocal writes in flightEdge steering drains to peers; region-hinted reads absorbed by replicasReads globally; writes elsewhere
◆ Note the fifth row

"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.

46:00 – 50:00Phase 07

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.

DK
46:00
Dana

You ship this and something's wrong. What is it?

SR
46:06
Sam

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.

DK
47:10
Dana

One quarter. Four engineers. What do you cut?

SR
47:16
Sam

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.

✓ Signal · 47:16

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.

L · "What's wrong with what you shipped?" at three levels
L5
Names a component that might fail — "Redis could go down" — and describes the mitigation already in the design. Answers the question as a robustness question.
L6
Names the weakest part of their own design and says what they'd do about it. Sam's takedown answer is comfortably here.
L7
Names the requirement most likely to move, not the component most likely to break, and says which mechanism stops working when it does. Sam gets here on the takedown number — and then, at 49:20, misses the largest version of the same move: he never asked what already exists.
DK
48:30
Dana

We're at time. Anything you want to ask me?

SR
48:36
Sam

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?

DK
49:20
Dana

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.

SR
49:48
Sam

(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.

! The gap · 49:20

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.

Verdict at L6 · Staff
Strong hire
Clears the bar on depth, trade-off articulation and recovery. One dimension — operational thinking — sits below where I want it, and it is the one that costs least at this level.
Verdict at L7 · Senior Staff
Not this loop
One dimension short, and it happens to be the defining one: altitude above the design itself.
What actually decided it
The question never asked
Not the TTL bug. That was recovered inside two minutes and scored net positive.

Scorecard

Requirements & problem framing
Reframed a generic prompt into a specific system in 90 seconds. Turned a policy number into three architectural constraints.
Quantitative reasoning
Fast and fluent. Rebuilt a challenged assumption from product mechanics rather than defending it. One composition error on TTLs, self-corrected once surfaced.
Depth on the decision that mattered
Spent 4 of 50 minutes on ID generation and justified the allocation at 47:16. Feistel + cycle-walking was correct and correctly explained.
Trade-off articulation
Best dimension. Every choice priced in a currency already on the board — dedup against privacy, edge promotion against takedown budget, analytics accuracy against throughput.
Failure & operational thinking
Strong on degradation and on which guarantee must not bend. Thin on rollout, on what to alert on beyond the blocklist, and on what week one in production looks like.
Communication & drive
Drove the whole session. Took interruption without losing the thread, and handled being caught wrong without defensiveness or over-apology.
Organizational altitude · the L7 dimension
Fifty minutes of greenfield design without once asking whether a system already existed, who depends on it, or what the migration costs. No mention of callers, owners, or a path from here to there.

Moments that moved the needle

04:08
Turned five minutes into three constraints. TTL ceilings stack; 301 is eliminated; invalidation gets designed before caching. Best single moment of the loop.
10:26
Refused the average. Challenged on 100:1, rebuilt the estimate from product mechanics and then argued the mean was the wrong statistic entirely.
20:10
Self-caught, factor of twenty. Stress-tested their own answer on block size, found it wrong, fixed it on the board unprompted, and reframed the constant as a three-way knob.
21:46
Dedup as a privacy oracle. Unprompted security reasoning, then a fourth option — intern the values, keep the keys distinct — that takes the benefit and drops the cost.
25:52
Caught out, recovered up. The TTLs didn't compose. Fixed the arithmetic, then fixed the reason the error was possible: a TTL is a backstop, not a mechanism.
33:38
Split the namespace by consistency requirement. 99.9% stays AP and local; the 0.1% that genuinely needs consensus is contained rather than spread. Strongest architectural move of the interview.
41:08
Walked out of a trap they'd already disarmed. Safety and freshness on separate channels, decided at 15:00, paid off at 41:00.
49:20
Never asked what already exists. The one that capped the level.

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.

1

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.

2

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.

3

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.

4

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.

5

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.

6

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.

System Design Mock Interviews

AI-powered system design practice with real-time feedback on your architecture and tradeoff reasoning.

Coming Soon

Practice Coding Interviews Now

Get instant feedback on your approach, communication, and code — powered by AI.

Start a Coding Mock Interview →
Also in this series