Case Studies
URL Shortener
A small, fully-worked design covering code generation, the cache-aside redirect path, and multi-region trade-offs.
Often the very first system design prompt a candidate ever gets — small enough to finish in one sitting, but with a genuine trade-off (code generation, cache-aside redirects) at every step.
Understanding the Problem
Think of it like a coat-check ticket. Instead of handing someone a long, unwieldy URL, you hand them a short code. The service holds onto the real URL and hands it back the moment that code is presented.
That gives the system two very different jobs. Creating a short URL happens once, when a link is made. Redirecting happens every single time someone clicks it — which, for a popular link, could be millions of times. Those two jobs have opposite traffic shapes, and that asymmetry is the one idea this whole design is built around. Keep it in mind — it's the thread that ties every decision below together, starting with what we're actually on the hook to build.
Functional Requirements
Here's what we need to support:
- Users can submit a long URL and get back a short one — optionally supplying their own custom alias (3-20 characters) instead of a generated code.
- Users can visit a short URL and get redirected to the original long URL, honoring an optional expiration date set at creation, defaulting to one year out if none is given.
- The same long URL can map to more than one short code across different owners: two different users shortening the identical link independently get independent codes, since each may later want their own expiration date, alias, or click stats. The same user resubmitting a link they've already shortened gets their existing code back, not a duplicate.
- Users can list the short URLs they own, and see click count, referrer, and country breakdown for each one.
- Users can delete a short URL they own.
Out of scope
- Authentication mechanics — every requirement above assumes the caller is already identified (e.g. via a bearer token), just not how they got one.
- Billing, a link-in-bio page builder, or a browser extension/app.
- A full analytics dashboard UI — the API returns the numbers; visualizing them is a separate project.
- Bulk creation (shortening many URLs in one call).
Non-Functional Requirements
Now the constraints that actually shape the design — how fast, how often, and what we can't get wrong:
- Redirects are fast. p50 under 10ms, p99 under 50ms — this is the wait a real person feels every time they click a shared link.
- Creation can be slower, but not by much. Up to 300ms at p99 is fine — it happens once, not on every click.
- The read:write ratio is lopsided — grounded in real numbers, not
invented ones. Assume 500 million daily active users, each clicking
roughly 50 short URLs a day and creating roughly 5. That works out to:
- 500,000,000 × 50 / 86,400 sec ≈ 289,000 reads/sec
- 500,000,000 × 5 / 86,400 sec ≈ 29,000 writes/sec
- a 10:1 read-to-write ratio — the single number the rest of this design leans on. This is genuinely production-scale traffic, not a scaled-down interview version of it — worth stating plainly, since it's what justifies sharding and a coordinated ID allocator further down, not just a bigger cache. A 10:1 ratio is also less skewed than a typical anonymous shortener's, deliberately: ownership pulls weight toward the read side too, since list/stats calls only happen because someone already created a link, not because a stranger clicked one — a service with no accounts at all would likely see a much heavier skew toward reads.
- These numbers are daily averages, not a flat rate. Traffic is diurnal, and a single link going viral can push a slice of it several multiples above this baseline for minutes at a time — the real reason the cache-stampede mitigation and the CDN/edge-compute ceiling further down exist, not just headroom for its own sake.
- The system never loses an acknowledged write, and never issues the same code twice — both have to hold even through a crash mid-write.
- 99.99% availability.
- Codes can't be guessed in sequence. This isn't just hygiene — a short
code is a public identifier by design, unlike an internal database ID,
so anyone can sample a handful of them. A predictable sequence turns that
sampling into real information: an outside observer who sees codes
bit.ly/000481andbit.ly/000513a week apart can estimate the service's total creation volume just from the gap between them — the same inference problem that let analysts estimate wartime tank production from the serial numbers on a handful of captured tanks. Non-enumerable codes close that off entirely.
Capacity Estimation
Let's put real weight behind that read:write ratio before deciding anything, since it's the number the rest of this design leans on. Each row is small: a 7-character code (~7B) plus the long URL (~100B average) plus an owner reference (8B) plus timestamps and a few flags (~20B) comes to roughly 135 bytes per record. At 100 million new URLs a day, that's about 13.5GB/day — averaging ~5TB/year, or up to ~18TB/year if URLs consistently run near the 2048-character max (the same estimation approach Numbers to Know covers generically).
That's not a storage problem by itself — even 18TB/year is well within what a properly-provisioned relational store handles. What actually forces sharding here is write throughput (29,000/sec), not disk size — worth saying explicitly, since "big numbers" tempts people to assume storage is the reason.
At 289,000 reads/sec, a small slice of codes get disproportionately clicked — the classic access skew. The Pareto rule is a reasonable starting assumption: if the top 20% of URLs draw about 80% of traffic, that hot set is roughly 20 million records × 135 bytes ≈ 2.7GB — small enough to fit comfortably on a single Redis node, from a pure capacity standpoint. Read bandwidth at that volume is about 289,000 × 135B ≈ 39MB/s, far under what a single NIC handles; write bandwidth is a further order of magnitude smaller. None of this is a bandwidth problem — it's entirely a "how many requests per second can one node's CPU push through" problem, which caching and sharding each solve in a different place (reads vs. writes).
Writes are the harder scaling problem here, not the easier one: 29,000/sec
is well past what a single relational primary comfortably absorbs, which is
the real argument for sharding the write path (below) rather than just
adding read replicas. Postgres is still the right choice of database — not
because of a specific feature it has, but because the write path needs a
real UNIQUE constraint on code, and an eventually-consistent store would
let two nodes briefly both believe they'd claimed the same code during a
network partition. If you haven't worked with Postgres specifically, that's
fine — name whichever relational database you know best; the reasoning
(strong consistency on a uniqueness constraint) is what matters, not the
vendor.
Core Entities
With the requirements pinned down, here's the one thing we actually need to persist:
type ShortUrl = {
code: string; // the short code, e.g. "aZ3kQ1w" — or the custom alias, if one was given
longUrl: string;
userId: string; // who owns this code — earns its place now that listing/stats/delete are in scope
createdAt: Date;
expiresAt: Date | null; // null when the code has no expiry
clickCount: number; // denormalized, updated async — see "Recording clicks" below
};
That's the entire persisted shape — this system does not need a full User
entity beyond that owner reference, given the stated scope.
In storage, code is the primary key — the same UNIQUE constraint the
Write Service leans on above to catch alias collisions. A secondary index
on expiresAt is worth naming too: it's what keeps the cleanup job's "find
rows past their expiry" query cheap as the table grows into the billions of
rows, instead of scanning the whole thing. A composite index on
(userId, createdAt DESC) supports the "list my URLs" endpoint below.
API Interface
Four endpoints now cover the full picture — one per job, plus the two ownership adds:
POST /urls { longUrl: string, customAlias?: string, expiresAt?: string }
-> 201 { code, shortUrl, isExisting }
-> 400 Bad Request, if longUrl isn't a valid URL
-> 409 Conflict, if customAlias is already taken
-> 429, 100/min per user
GET /:code -> 302 redirect to longUrl
-> 404 Not Found, if the code doesn't exist
-> 410 Gone, if past expiresAt
-> 429, 1000/sec per IP
GET /urls?cursor=<opaque> -> 200 { urls: ShortUrl[], nextCursor }
-> 429, 60/min per user
GET /urls/:code/stats -> 200 { clickCount, byReferrer, byCountry }
-> 429, 60/min per user
DELETE /urls/:code -> 204, soft-delete — code stays inactive,
never recycled
-> 429, 100/min per user
The create response's isExisting flag is what makes creation idempotent
per owner: if the same user resubmits a long URL they've already shortened,
the response carries their existing code instead of minting a duplicate —
cheap to check with a Bloom filter before ever touching the database (more
on that in Trade-offs), and it's what keeps one owner's keyspace from
filling up with redundant codes for the same link.
Listing a user's own URLs uses cursor-based pagination — an opaque token
built from (createdAt, code) — instead of OFFSET. OFFSET has to scan
and discard every row before the requested page, so it gets slower the
deeper a user pages in; a cursor stays a constant-cost lookup regardless of
position. The one thing you give up is jumping straight to page 40 —
acceptable here, since scrolling through recent links in order is the only
access pattern that matters.
Deleting a URL is a soft delete: the code flips to inactive and is never recycled for a new destination. Hard-deleting and reusing a code would mean an old link — still shared somewhere, still bookmarked by someone — could suddenly start redirecting to whatever a stranger claims that code for next.
Every rate limit above is enforced the same way: see rate limiting for the sliding-window mechanism behind these numbers, rather than a naive fixed window that lets a caller burst right at the window boundary.
High-Level Design
Reads outnumber writes 10:1, and only reads are latency-sensitive — the asymmetry called out at the very start. That's reason enough for us to split the API into two independently-scaled paths from the start, instead of building one service that does both jobs:
- A Write Service handles creation.
- A Read Service handles redirects.
Each can then be scaled to match its own load, rather than one path forcing the other to be over- or under-provisioned.
Creating a short URL
Here's what happens end to end when someone shortens a link:
Write path
Client
POST /urls
Write Service
validate URL, check alias
Bloom filter
already shortened by this owner?
Generate a code
counter-based — see Trade-offs
Database
INSERT the row
Client
201 { code, shortUrl }
Step 3 is a cheap pre-check, not the real guarantee: a Bloom filter can only answer "possibly already shortened by this owner" or "definitely not" — a possible match still needs a real database lookup to confirm and fetch the existing code, but a definite miss skips that lookup entirely. The actual sizing and error-rate trade-off it buys is worth its own discussion, in Trade-offs below.
If the caller supplies a custom alias instead of asking for a generated
code, step 4 is skipped. The Write Service inserts the alias directly, and
lets the database's own UNIQUE constraint on code decide whether it was
actually available — returning a clear "alias unavailable" error on
conflict, rather than silently overwriting someone else's link.
One more thing worth naming: a custom alias and a counter-generated code
share the same code column, so nothing stops a future generated code from
landing on an alias someone already claimed. The UNIQUE constraint alone
catches that collision, which is exactly why both paths write through the
same table instead of two separate ones.
Sharding the database
At 29,000 writes/sec, a single Postgres primary is the real bottleneck —
not disk, not bandwidth, just how many INSERTs one node's CPU and I/O can
push through per second. The fix is sharding the urls table across many
nodes, and the shard key matters more than it might seem.
Shard by a hash of code, not by userId. The redirect hot path — by far
the highest-volume operation in this whole system — looks up a row by
code alone, so hashing on code means every redirect hits exactly one
shard. Hashing on userId instead would optimize for a much rarer
operation (listing one owner's URLs) at the direct expense of the one that
actually dominates traffic.
Routing a write to its shard
code
e.g. aZ3kQ1
hash(code) % N
Shard 7 of 16
INSERT here
The trade-off this creates is real, not free: listing one user's URLs now
means fanning a query out across every shard and merging the results, since
that user's links are scattered by code, not grouped together. That's
acceptable specifically because listing is a low-frequency operation — well
under 1% of total traffic — compared to the redirect volume the shard key
is actually optimized for.
code staying the primary key (established above) is what makes this work
cleanly: the redirect path's lookup is already "find the row by code," so
routing by hash(code) and looking up by code inside that shard is the
same key doing double duty, not two separate indexing schemes to keep in
sync.
Redirecting to the original URL
Now the path that actually matters for latency — the one a real user waits on every time they click a link:
Hit — the common case
Client
GET /:code
Read Service
Cache
hit → return immediately
Miss
Client
GET /:code
Read Service
Cache
miss
Database
SELECT — source of truth
Cache
populate, 5 min TTL
This is the cache-aside pattern: the Read Service checks the cache first,
and only falls back to the database on a miss — populating the cache
afterward so the next request for that code is a hit. A ShortUrl row
never changes once created, so there's no invalidation problem here — only
a capacity one: how much of the hot set fits in the cache.
A popular code's cache entry expiring is its own small crisis: if tens of thousands of clicks land in the same second right as a hot key's TTL lapses, every one of them misses the cache simultaneously and piles onto the database at once — a cache stampede. Two cheap fixes handle it: request coalescing (only the first miss actually queries the database; every other concurrent miss for that same code waits on that one result instead of firing its own query), and TTL jitter (randomizing each entry's TTL by roughly ±10%, so a batch of keys cached together don't all expire in the same instant).
Even though the hot set alone fits comfortably on one Redis node, the cache
is still deployed as a cluster — for availability, not capacity. Losing the
one node holding your hot set would otherwise send the entire redirect
path to the database at once. The cluster uses consistent hashing rather
than a plain hash(code) % N — the same modulo scheme the database shards
use above would remap almost every key the moment a single node joins or
leaves. Consistent hashing bounds that disruption to roughly 1/N of the
keyspace (about 6% of keys with 16 nodes), so losing one node doesn't mean
the rest all serve stale routing decisions at once.
Given the read skew this design assumes, the hit path is the normal case, not the miss path — worth saying out loud, since it's tempting to only ever diagram the (rarer) miss.
For a link going viral, a single Redis tier eventually has a ceiling. The next rung up is pushing the redirect itself to the edge — a CDN with edge-compute (Cloudflare Workers, Lambda@Edge) caching the code → URL mapping at a point of presence near the requester, so the hottest links resolve without the request ever reaching the origin Read Service. That's a further step on the same ladder as the cache, not a replacement for it — worth naming as this design's ceiling even if it's out of scope to build here.
Recording clicks without slowing redirects
Every redirect is also a data point — referrer, country, timestamp — that the owner's stats endpoint needs later. Writing that row inline, on the same request that's already racing a 50ms budget, is the wrong place to do it: an analytics insert costs an estimated 5-50ms depending on load, a real bite out of that budget for a mechanism the redirect itself doesn't depend on.
Click logging
Read Service
redirect already sent
Kafka
fire-and-forget
Analytics consumer
click_analytics table
append-only, partitioned by month
The Read Service publishes a click event to Kafka and moves on without
waiting for anything downstream — the redirect response has already gone
out before this even starts. A separate consumer reads that stream and
writes to an append-only click_analytics table, and the clickCount
field on the ShortUrl row itself gets bumped by that same consumer
roughly every 60 seconds rather than on every single click, so the stats
endpoint never has to run a COUNT(*) over a billions-of-rows table just
to answer "how many clicks does this code have." Partitioning that table by
month is a small extra design choice worth naming: dropping a whole
partition once it ages out is one fast operation, instead of a row-by-row
delete crawling through a table that's grown into the billions of rows.
The trade-off is honest: analytics become eventually consistent, typically
lagging by a few seconds, in exchange for the redirect path never waiting
on any of this.
Expiration and cleanup
One more wrinkle the happy path above skips: what happens once a code's
expiresAt passes? A code with expiresAt set needs two things, not one:
- The Read Service checks
expiresAton every lookup and returns410 Goneonce it's passed. - The cache's TTL for that entry is capped at whichever is shorter — the
default TTL, or the time remaining until
expiresAt— so a cached redirect can't outlive the code's own expiration.
Actually deleting expired rows from the database is a separate, lower-
urgency job: a weekly batch cleanup removes rows well past expiresAt.
Nothing on the hot path depends on the row being gone immediately — only on
it no longer redirecting, which the two rules above already guarantee.
Trade-offs
Several decisions here are worth defending explicitly if your interviewer pushes — starting with the most tempting shortcut in the whole design:
Hash-based (Good)
Long URL
SHA-256 hash
Base62-encode, truncate
e.g. 8 characters
Check for a collision
retry on conflict
Counter-based (Great)
Claim a range
coordinator — 10,000 IDs at a time
Increment locally
no network call per ID
Base62-encode the value
Base62 counter vs. hash-based codes. A short code is really just a
number wearing a disguise. Base62 is the compact way to write that number —
26 lowercase letters, 26 uppercase, 10 digits, 62 symbols total. Base64
would pack a code into even fewer characters, but its extra two symbols
(+ and /) aren't safe inside a URL: / is a path separator, and + can
get silently read as a space in a query string. Base62 sidesteps both
problems and needs no escaping.
For the hash approach, here's the collision math worth having ready: with
n codes already issued out of a code space of size |S| (62⁸ ≈ 218
trillion for 8 characters), the chance the next code collides is roughly
n / |S|. Even at a billion existing codes, that's about 1 in 218,000 —
small, but not zero, which is why the hash approach still needs a bounded
retry (3-5 attempts is standard), backed by the database's UNIQUE
constraint as the real guarantee.
The counter sidesteps that math entirely — but a single shared counter has
its own ceiling. A lone Redis INCR is atomic and single-threaded, which
guarantees two callers never get the same number back, but it also means
every write serializes through one command, capping out around 10,000/sec
under contention — well short of the 29,000 writes/sec this design needs to
sustain.
The fix is range pre-allocation: each Write Service instance claims a block of 10,000 IDs from a coordination service like ZooKeeper up front, then increments a local counter with zero further coordination until that block runs out. One instance might claim IDs 1 through 10,000, another 10,001 through 20,000 — and from then on, minting an ID is a local increment, not a network call. Base62-encode whatever comes out and there's no collision left to check for, ever.
Worth sizing the keyspace this actually buys: at 7 characters, base62 gives 62⁷ ≈ 3.5 trillion possible codes — at 100 million new URLs a day, that's roughly 96 years of runway before the counter itself would need a longer code. Compare that to the hash approach's collision math above — the counter's own capacity story is a separate, much larger number from the "how likely is the next collision" question the hash approach has to keep answering on every write.
The one real risk this introduces is a crash mid-range: if an instance dies after claiming a block but before using all of it, that block's unused tail is gone for good — a small, permanent gap in the keyspace, harmless at this scale. A write-ahead log closes the more dangerous version of that risk: each range claim is persisted before it's handed to an instance, so a crash in the coordination service itself can't accidentally reissue a range that's already out — which would actually cause a collision, unlike a merely-wasted range. And if the coordinator itself ever needs to be highly available, the pattern has a real precedent: an early large-scale ID generator ran two independent allocators in parallel, one issuing only odd IDs and the other only even, so either one going down never stops issuance entirely.
That's the actual trade: the hash needs zero coordination but a bounded retry on every write; the counter needs one shared, coordinated source of truth but never has to retry. Chosen: counter with pre-allocated ID ranges — the write path can absorb the small added coordination in exchange for never needing that retry logic at all.
One more alternative is worth naming, even though it loses to the counter too: pre-generating a large pool of random codes ahead of time, and just popping one off the pool on every write.
Pre-computed pool (also rejected)
Generate codes offline
in advance, in bulk
Pool
single point of failure
Pop one on write
It's collision-free by construction, same as the counter — but it fails in two different ways. The pool itself becomes a single point of failure: if it's exhausted or unavailable, writes stop entirely, with no fallback the way a counter's local increment has one. And every code sitting unused in the pool is pure waste — storage spent on a code nobody has claimed yet, where the counter's approach never allocates a code until the moment it's actually needed. Range pre-allocation gets the same collision-free guarantee without either cost, which is why it beats this option too, not just the hash-based one.
Bloom filter for idempotent creates. Checking "has this owner already shortened this exact URL?" the naive way means a database lookup on every single create, most of which return "no" and did nothing but cost a round-trip. A Bloom filter answers that question from memory instead, in front of the database: at roughly 10 bits per element for a 1% false-positive rate, tracking 10 billion URLs costs about 12GB — versus roughly 80GB for an equivalent exact hash set, since a Bloom filter trades a small, tunable false-positive rate for that much smaller footprint.
The filter can only ever be wrong in one direction: a "definitely not present" answer is always correct and skips the database lookup entirely, while a "possibly present" answer still needs a real lookup to confirm, since it's occasionally a false positive. In practice that eliminates an estimated 85% of redundant lookups on writes, in exchange for accepting that roughly 1% of genuinely-new URLs pay for a database check that turns out to have been unnecessary — a false positive never causes incorrect behavior, only a wasted lookup.
301 vs. 302 redirect. A 301 (permanent) lets browsers cache the redirect client-side — for a high-traffic link, that can cut server load by an estimated 60%, since a browser that's cached the redirect stops asking the server on repeat visits. But that same caching costs two things at once: the code becomes effectively immutable forever, and click counts go unreliable, since a repeat visit never touches the server to be counted.
Chosen: 302 — nothing in the requirements calls for giving up click visibility, and the cache layer already absorbs most of the read load a 301 would have saved anyway.
One more precision worth having ready: a 302 isn't cached by a browser at
all unless the response explicitly opts in — no Cache-Control header
needed to keep it out of the cache, unlike a 301, which is cacheable by
default. That's exactly why the reads a 301 would have saved land on the
service instead — for the Redis cache above to absorb, not the browser.
Sharding by code vs. by userId. Already covered in detail above —
worth restating here in the same shape as everything else on this list,
since it's exactly the kind of trade-off an interviewer expects called out
explicitly. Sharding by userId would optimize the rare operation (listing
one owner's links) at the direct expense of the dominant one (redirects).
Chosen: shard by code — every redirect hits exactly one shard, and
the fan-out cost lands on an operation that's well under 1% of total
traffic.
Sharded Postgres vs. a horizontally-scaled store (Cassandra/DynamoDB).
A relational database gives a UNIQUE constraint on code for free within
a shard — the exact guarantee the write path leans on above. A wide-column
store scales writes further than sharded Postgres ever could on its own,
but that same uniqueness check stops being free: with no native unique
index across partitions, every insert needs a conditional write or
lightweight transaction to get the same guarantee back — and a store built
for eventual consistency risks two different partitions briefly both
believing they'd claimed the same code during a network partition, which a
relational store's synchronous constraint check simply can't do.
Chosen: sharded Postgres — at 29,000 writes/sec spread across many shards, each individual shard's write load lands back in the range a single Postgres primary handles comfortably, so sharding solves the actual throughput problem without giving up the free uniqueness guarantee anywhere.
Final Design
Put every piece from above together and this is the shape it settles into:
Final design
Client
Load Balancer
Read Service
redirects
Write Service
creates codes
Cache
code → longUrl, consistent-hashed
Counter
range pre-allocation
Bloom filter
idempotent-create check
Database
sharded by hash(code), 16 shards
Every piece here scales on its own axis:
- Read and Write services scale against very different load profiles — Read horizontally for volume, Write needs less raw horizontal scale but real coordination underneath it.
- Cache, Counter, and Bloom filter each sit in front of the database for a different reason — redirect latency, ID uniqueness, and dedup respectively — and can be sized independently even though they're drawn in the same tier.
- The database is sharded, not singular — 16 shards by hash of
code— but still the one thing every other piece can be rebuilt from if it's lost. - Click analytics (above, under "Recording clicks without slowing redirects") is a separate, async branch off the Read Service — left out of this picture deliberately, since it's off the critical path everything else here is drawn for.
Operations & Observability
A design that only handles happy-path traffic isn't finished — you also need to know it's breaking before a user files a ticket. Raising this without being asked is one of the clearest signals that separates a senior or staff-level answer from one that stops at "and then it works."
On the redirect path, watch cache hit ratio first — target 95% or higher given the Pareto-shaped hot set above, and alert once it drops below 90%, since a drop that size usually means either eviction pressure or a cold cache right after a deploy, the same cold-cache scenario Level Expectations calls out below.
Pair it with p99 latency split by hit vs. miss — a miss should cost roughly one DB round-trip more, and if it costs much more than that, something upstream is wrong — and 5xx rate on redirects specifically, the highest-severity alert here, worth paging on above roughly 0.1%, since a broken redirect is the one failure a user actually notices.
On the write path, watch per-instance range-remaining for the counter allocator — a buffer that isn't refilling fast enough is an early warning that ID issuance is outpacing range claims, well before an instance actually blocks waiting on the coordinator. Pair it with write error rate per shard — above roughly 1%, check replication lag, disk space, or connection-pool exhaustion on that specific shard, since a healthy-looking aggregate error rate can still hide one struggling shard underneath it.
One more metric candidates tend to forget: cache key distribution skew. A cluster can report a perfectly healthy aggregate hit ratio while one node quietly holds every hot key and bottlenecks under load the rest of the cluster never sees — only a per-node breakdown surfaces that. In a multi-region deployment, add per-region counter issuance rate, so one region silently approaching the edge of its allocated ID range gets caught before it collides with another.
Level Expectations
Wherever you land on this ladder is fine — here's roughly how the bar moves as you go up it.
Mid-level: proposes the cache-aside redirect path and a working code generation scheme, and can state the read:write ratio's effect on the design even if the exact numbers are rough.
Senior: proactively raises the base62-counter-vs-hash trade-off (with the collision math behind it) and the 301-vs-302 trade-off without being asked, can reason about what happens when the cache is cold (e.g. after a deploy) under the stated read load, and names cache hit ratio and redirect error rate as what they'd actually watch in production, not just what they'd build.
Staff: additionally reasons about multi-region deployment — where the counter-based ID allocator needs to avoid two regions issuing the same code — and can propose a concrete fix (e.g. region-prefixed ID ranges, the same composite-ID pattern covered in Unique Distributed ID Generator) on the spot, along with naming CDN/edge delivery as the ceiling of the caching story for exceptionally hot links.
At real production scale, a Staff candidate also proactively raises
sharding by code over userId (naming the listing fan-out it trades
away), the write-ahead-log guarantee behind the counter allocator's crash
safety, a Bloom filter as the cheap first line of defense for idempotent
creates, and consistent hashing as what keeps a single cache-node failure
from remapping the entire keyspace at once.
Follow-Up Questions
Interviewer:Two API instances both exhaust their pre-allocated ID range at the same moment. What stops them from being handed overlapping ranges?
The allocator does one thing: atomically advance a single "next free range start" counter and return the range before the advance. That's the only place contention can happen, and it's cheap and rare — once per exhausted block, not per request. The real risk isn't overlap, it's the allocator being briefly unavailable right when an instance runs out. Each instance holds a second, pre-fetched buffer range, so it never has to block on the allocator mid-request.
Interviewer:Someone reports a shortened link is being used for phishing. How does your design take it down?
Reuse the mechanism already built for expiration: set expiresAt to
now (or add a dedicated active flag, if takedown needs to look
different from normal expiry in logs). The Read Service starts
returning 410 Gone on the very next lookup. The one extra step: the
cached entry needs to be actively evicted or short-TTL'd rather than
waiting out its normal TTL — the same TTL-vs-purge choice
CDNs names generically,
now forced by a real requirement instead of a default.
Interviewer:The database's write primary for one shard goes down during a traffic spike. What actually happens?
Writes to that one shard fail or queue until an orchestrator promotes a
replica — semi-synchronous replication means at least one replica
always holds the latest committed writes, so promotion doesn't lose
data, and it typically completes in around 30 seconds. The other 15
shards are entirely unaffected, since sharding by code scopes this
failure to whichever shard happened to own the affected range, not a
platform-wide outage. Replication only protects against a single node
failing, though — periodic snapshots to durable storage are the
separate backstop for what replication can't cover, like a bad write
or corruption that propagates to every replica before anyone notices.
Interviewer:One Redis node in the cache cluster goes down. What happens to the redirects that were relying on it?
Because the cluster uses consistent hashing rather than plain
hash-mod-N, losing one node out of many only remaps roughly 1/N of
the keyspace to its neighbors — about 6% of keys with 16 nodes, not the
whole cache. Every remapped key is a guaranteed miss on its next lookup,
so there's a brief, bounded spike in database reads — roughly 18,000
reads/sec (289,000 ÷ 16) landing on the database at once, not the full
289,000 a whole-cluster failure would cause. A circuit breaker on the
cache path, with a bounded connection pool, keeps that spike from
cascading further.
Interviewer:A user creates a short URL, then clicks it immediately. What stops them from seeing a 404 for a link they just made?
This is a real risk if the redirect's read is served by a read replica that hasn't caught up yet — replication lag is normally small, but not zero. The fix is a write-through cache: the moment a create succeeds, its mapping is written into the cache immediately, so the very next lookup for that code is a hit and never touches the lagging replica at all. As a second layer, the creating user's own reads can be pinned to the primary for a short window after their own write, so even a cache miss wouldn't land on a stale replica.
Try It Yourself
Try it yourself
The design above caps a code's cache TTL at whichever is shorter — the
default TTL, or time-until-expiresAt. Sketch what breaks if that cap
were removed and every code just used the flat default TTL regardless of
its own expiration. Concretely: construct a sequence of events (a code's
expiresAt, a cache population time, and a request time) where a client
gets redirected to a URL that should have already returned 410 Gone.