Case Studies

URL Shortener

A small, fully-worked design covering code generation, the cache-aside redirect path, and multi-region trade-offs.

20 minHardcase-studycachingdistributed-systems
very common

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/000481 and bit.ly/000513 a 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:

  1. The Read Service checks expiresAt on every lookup and returns 410 Gone once it's passed.
  2. 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

ClientServiceCache (Redis)Database

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.