Design TinyURL (URL Shortener)
Design TinyURL (URL Shortener)
Say this in an interview and watch the room relax: "easy — hash the long URL, base62-encode it, store the mapping, done." That relief is the trap. Truncate an MD5/SHA hash to a handful of characters and three things break the moment this ships to real traffic: (1) collisions — two different long URLs can hash to the same short code, and at billions of rows this isn't a theoretical edge case (we'll compute the actual expected count below); (2) no idempotency — shorten the same URL twice and a naive hash-of-content scheme either returns the same code (fine) or, once you add a timestamp/salt to dodge collisions, returns a different code each time (now you've minted two rows for one URL, silently wasting keyspace and confusing analytics); (3) custom aliases don't fit the model at all — the moment a user wants short.ly/my-brand instead of a hash output, "just hash it" has nothing to say. TinyURL is the "hello world" of system design interviews precisely because the naive answer sounds complete for thirty seconds and falls apart the instant someone asks "what happens when two people shorten the same URL a second apart?" This lab derives the design that survives that question.
Play with it first — sharding the code→URL store is consistent hashing wearing a trench coat
Once you're serving billions of codes, the mapping code → long_url can't live on one machine, so you shard it — and the question "which node owns this code" is the exact mechanism the simulator below teaches: add or remove a node and watch how few keys have to move. That's the property you want when you add a fourth cache/DB node to handle growth: most codes should stay pinned to their existing shard, not reshuffle the whole table.
Scope it like a senior (ask before you design)
Don't reach for the whiteboard yet. Pin these down first — each answer changes the design:
- Scale: how many new URLs per day/month, and over how many years does the store need to hold everything? (Drives the keyspace math and the storage BOTE.)
- Read:write ratio: is this dominated by redirects, or is creation a bigger share than usual? (URL shorteners are almost always extremely read-heavy — confirm the ratio rather than assume it.)
- Custom aliases: can a user request
short.ly/my-brand, or is every code auto-generated? (Custom aliases can't come from a counter/hash — they need their own uniqueness check, see the worksheet.) - Expiry: do links expire, or live forever? (Affects storage growth and whether you need a TTL-sweep/reaper job.)
- Analytics: does the product need exact click counts, or is "roughly how many clicks" acceptable? (This single answer decides the 301-vs-302 trade-off — see Reason to the design.)
- Guessability: must codes be non-enumerable (a competitor shouldn't be able to iterate
/1,/2,/3and scrape every link)? (Affects whether a raw incrementing counter is acceptable as a code, or needs to be permuted first.)
Assume for this lab: 500M new short URLs/month; 100:1 read:write ratio; custom aliases ARE supported alongside auto-generated codes; links can optionally expire (TTL); click analytics is wanted but an exact real-time count is NOT a hard requirement (a slight undercount is acceptable); codes should not be trivially enumerable. Single company, not multi-tenant; a 5-year storage horizon.
Reason to the design (derive it, don't recite it)
Attempt 1 — hash the long URL, truncate, store it. code = base62(md5(long_url))[:7]. Deterministic, no coordination needed, looks done. Two problems surface immediately: first, two different URLs can produce the same truncated hash — a collision — and you either silently overwrite someone's mapping or need a collision-check-and-retry loop (extra read + conditional write per collision, and worse, you now need to decide what "retry" even means: rehash with a salt? that makes the scheme non-deterministic, which was the one thing hashing bought you). Second, if you salt/timestamp to make retries work, shortening the identical URL twice now yields two different codes — the scheme isn't idempotent, and you've quietly doubled your storage for duplicate submissions of popular URLs. Neither problem is rare at scale — we compute exactly how not-rare in Break It.
Attempt 2 — one shared, monotonically increasing counter, base62-encode it. A single auto_increment column (or one Redis INCR) handed to every app server: whichever integer you get is yours, guaranteed, forever — zero collisions by construction, no retry loop, no salt. This is strictly better than hashing for uniqueness. But now every single write, from every app server, in every region, has to round-trip to the one place that owns "what's the next integer" — that's a single point of coordination, and if it's also a single point of failure, a bad deploy or a hardware fault on that one node stops all URL creation company-wide, even though redirects (reads) keep working fine from cache. We've traded a correctness bug for an availability bug.
Attempt 3 — keep the counter's guarantee, remove the single node from the hot path. Two production-proven ways to do this: leased ID ranges (each app server asks a lightweight coordinator like ZooKeeper/etcd for a block of, say, 1M sequential integers at startup, then hands out IDs locally with zero further coordinator calls until the block runs low — refilled asynchronously before it hits zero), or Snowflake-style IDs (encode timestamp + machine-id + a local sequence counter directly, generated entirely in-process with no network call per ID at all). Either way, the "one shared counter" becomes a rare, amortized, out-of-band operation instead of something every write depends on synchronously — see the SPOF trace in Break It and the full comparison in Defend Under Pushback (Q5).
Read-heavy means the redirect path, not the write path, is where the system lives or dies. At a 100:1 ratio, redirects dwarf creations, and a code → long_url lookup is about as cacheable as data gets — it almost never changes after creation. So: cache the mapping in Redis, and for the truly hot links, let a CDN cache the redirect response itself at the edge, so a repeat click from the same region never even reaches your origin. This single decision (§ Build It, §4) is what makes ~20K reads/s a non-event instead of a scaling crisis.
301 vs 302 is not a formality — it's a trade-off you must state out loud. A 301 (Permanent Redirect) gets cached by the browser and by CDNs; the second time the same user clicks the same link, the request never reaches your servers at all — maximal scalability, minimal latency. A 302 (Found/Temporary) is never cached client-side, so every single click, every time, hits your origin — perfect click analytics, but you're now serving the full 100:1 read load from your own infrastructure with no client-side caching assist. You cannot have both maximal caching and exact click counts from the same status code; which one you pick depends directly on the "analytics" scoping question above.
Build it — the design worksheet
This is the artifact you'd actually produce on a whiteboard or doc in a 45-minute round. Work it in order; the numbers below are traced, not asserted — recompute them yourself before an interview.
1. Back-of-envelope estimation (BOTE)
- Writes: 500M new URLs/month ÷ 30 days ÷ 86,400s ≈ 193 writes/s avg. With a modest 3× diurnal peak (creation traffic is fairly evenly spread across a day, unlike, say, a lunch-rush food-delivery spike) → ~580 writes/s peak.
- Reads: at 100:1, reads ≈ 100 × 193 ≈ 19,300/s avg (~20K/s). Same 3× peak factor → ~58K/s peak. This confirms the earlier claim: the redirect path is the one that must never touch a slow path on the common case.
- Storage over 5 years: 500M/month × 60 months = 30 billion URLs total. At ~500 bytes/record (7-char code + average long URL + timestamps + owner id + index overhead): 30B × 500B ≈ ~15 TB — comfortably shardable across commodity nodes, no exotic storage engine required.
- Keyspace sizing (why 7 base62 characters, not 6): we need to comfortably outlive 30B rows.
62^6 ≈ 56.8 billion— only ~1.9× headroom over our 5-year total, meaning continued growth blows past it within a couple more years.62^7 ≈ 3.52 trillion— ~117× headroom, so even at 10× the assumed growth rate we're using well under 1% of the space. Pick L = 7. - Cache sizing: reads skew heavily toward recently created and viral links, not a uniform sample of all 30B rows. Take the last ~2 days of new URLs (2 × 16.7M/day ≈ 33M codes) plus a margin for recurring viral links, round to ~50M hot entries. At ~150 bytes/entry (code + long_url only, no metadata) that's ~7.5 GB of working set — fits comfortably on a small Redis cluster (with 3× replication, budget ~22.5 GB total cluster memory), not a distributed cache farm.
2. API sketch
POST /shorten
body: { long_url, custom_alias?, expires_at?, user_id? }
-> 201 { code, short_url, expires_at? }
-> 409 ALIAS_TAKEN (only for custom_alias)
GET /{code}
-> 301 Location: long_url // cacheable, common case
-> 404 NOT_FOUND | 410 GONE (expired)
GET /{code}/stats
-> { clicks, unique_visitors, created_at } // eventually-consistent count
DELETE /{code}
-> 204 (owner only)
3. Data model & partitioning
- Shard key =
hash(code). Every lookup and write is a single-key operation on one row — there is no query that needs to join across codes, so hash-based sharding (no hot range, unlike sharding by creation time) spreads both the 30B rows and the write/read load evenly. See the consistent-hashing sim above for how nodes get added without a full reshuffle. urlstable/collection, one row per code:code (PK), long_url, owner_id, created_at, expires_at, click_count(updated asynchronously by a queue consumer, never on the hot redirect path).- Custom aliases share the same table and the same primary key namespace as generated codes — a user can't claim an alias that collides with an existing generated code or another alias. That means aliases go through a synchronous uniqueness check (conditional insert / unique constraint) that generated codes skip entirely, because generated codes are collision-free by construction (see Reason to the design, Attempt 2/3).
- Cache layer: Redis, keyed identically to the primary store (
code → long_url), populated cache-aside (lazily, on read miss) — see the trade-off table for why write-through is the wrong choice here.
4. High-level diagram — write path vs read path
The write path (solid blue) always produces a code from a collision-free ID source, encodes it, and does exactly one write to the code's home shard. The read path (green/amber) resolves almost every request from CDN edge or Redis without ever touching the KV store — only a cache miss falls through, reads the store, and populates the cache for next time.
5. Rubric — what a strong answer hits
- States the scale assumptions and the BOTE arithmetic out loud (writes/s, reads/s, storage, keyspace) before drawing a single box.
- Names a collision-free ID source (counter/leased-range/Snowflake) as the default for generated codes — not a hash-and-hope scheme — and explains why (see the collision trace in Break It).
- Explicitly separates the write path's consistency needs from the read path's, and justifies caching + CDN for reads by the 100:1 ratio, not by habit.
- Picks 301 or 302 deliberately and states the trade-off being made (cacheability/scale vs exact analytics) rather than picking one by default.
- Handles custom aliases as a distinct code path with a synchronous uniqueness check, not bolted onto the generated-code logic.
- Can defend every row of the trade-off table (§ Optimise) with "buys X, costs Y, use when Z."
- Model-answer reading: check your worksheet against the guide's existing RESHADED treatment, Designing a URL Shortening Service like TinyURL, and cross-check your BOTE arithmetic against Drill: full sizing of a URL shortener.
Break it — the naive design's three concrete failures
1. Hash-and-hope collisions, traced with real numbers. The naive generator:
// NAIVE
code = base62(md5(long_url))[:7]
db.insert(code, long_url) // no check — assumes collisions "won't happen"
Use the birthday-paradox bound: for n items drawn into m slots, the expected number of colliding pairs ≈ n² / (2m). At our scale, n = 30 billion codes, m = 62⁷ ≈ 3.52 trillion:
expected collisions ≈ (30,000,000,000)² / (2 × 3,520,000,000,000)
≈ 9 × 10²⁰ / 7.04 × 10¹²
≈ 127,800,000 (≈ 128 million colliding pairs)
That's roughly 1 in every 235 new URLs silently colliding with an existing one over the system's lifetime (128M ÷ 30B ≈ 0.43%) — even with a keyspace that looks enormous (3.52 trillion!). "The keyspace is huge, collisions are basically impossible" is the exact intuition that fails here: a huge keyspace makes any one collision unlikely, but summed over billions of insertions the expected total is not small. This is precisely why Attempt 2/3 (a collision-free ID source) isn't a nice-to-have — hashing without a uniqueness check is a real, quantifiable bug at this scale, not a paranoid edge case.
2. The single counter is a write-side SPOF, traced. Suppose you "fixed" the collision problem with one shared auto_increment counter in one database instance, no leasing. At our peak of ~580 writes/s, throughput isn't the issue (a single sequence or Redis INCR handles far more than that) — availability is: that one instance goes down (bad deploy, disk failure, a botched migration) and every POST /shorten across the entire company fails, instantly, everywhere, while redirects keep working fine because they never touch it. That's a total, sudden write-side outage from a single node — not a graceful degradation. Leased ranges or Snowflake IDs (Reason to the design, Attempt 3) remove this node from the synchronous path entirely, turning "every write depends on this one machine" into "this machine is consulted roughly once per million writes."
3. 301 caching silently erases your own click analytics. Ship 301 without thinking through the consequence: a user clicks a short link once, their browser caches the redirect per standard HTTP semantics; every subsequent click by that same user, on that same device, is served straight from local disk cache — your servers never see request #2, #3, or #50. Your click_count column will report "1" forever for a link that user actually opened fifty times. This isn't a rare bug, it's the designed behavior of a 301 — and it's why "analytics requirements" (Scope It) has to be answered before you pick the status code, not after.
Optimise — with trade-offs
Bottlenecks worth naming out loud:
- A single viral link (hot key): one code can pull thousands of requests/second on its own, far above the ~20K/s system-wide average spread across billions of codes. The fix is the cache layer itself (Redis + CDN edge) absorbing that concentration — unlike the classic "hot counter" problem (e.g. a parking garage's shared decrement counter), a redirect lookup is a pure read with no contention to shard around; one popular row being read a million times from cache is just... cache doing its job. No sub-bucketing needed here, because there's no write contention on that key.
- Write-path ID generation: distinguish two different failure modes that get conflated. Throughput is not actually at risk at 580 writes/s peak — even a single naive
INCRnode handles well over 100K ops/s. The real problem is availability (one node, one failure, total write outage) and, at multi-region scale, latency (every write round-tripping across an ocean to one counter). Leased ranges and Snowflake IDs fix both; a simple primary-replica failover on the counter fixes availability alone but does nothing for cross-region latency. Know which problem you're solving before reaching for a fix.
| Decision | Option A | Option B | Pick when |
|---|---|---|---|
| Code generation — uniqueness | Counter + base62 (leased range / Snowflake) | Hash (MD5/SHA) + collision-check-and-retry | Counter+base62 almost always — it's collision-free by construction with no retry loop. Reach for hashing only if you specifically need the code to be a deterministic, reproducible function of the URL content (e.g. content-addressed dedup) and you're willing to pay the collision-check cost. |
| Code generation — unpredictability | Raw sequential counter (simple, but enumerable) | Counter run through a reversible permutation (Feistel cipher / multiplicative hash mod a prime) before base62 | Plain sequential when enumerability doesn't matter (internal tool). Permuted-counter when codes must not be guessable/scrapable in bulk — you keep the collision-free guarantee of a counter (it's still a bijection) while the output looks random. |
| Redirect status code | 301 Permanent | 302 Temporary | 301 when redirect throughput/latency at scale matters more than exact click counts (most consumer link-shortener traffic). 302 when the product's core value IS the analytics (e.g. a marketing-attribution tool) and you're willing to serve every click from origin. |
| Storage engine | Relational (Postgres/MySQL, sharded) | KV store (DynamoDB/Cassandra-style) | KV store for this system — every access pattern is a single-key lookup/write, no joins, and you want horizontal write scale without manual resharding. Reach for relational only if you need strong cross-table joins (e.g. billing tied to usage) or ACID transactions spanning multiple entities, which this domain doesn't. |
| Cache population | Cache-aside (lazy, populate on read miss) | Write-through (populate cache at write time) | Cache-aside here — most created codes are read rarely or never (a long tail), so pre-populating every write into cache wastes memory on links nobody clicks. Write-through only makes sense when you know almost every write will be read again soon after — not true for URL shortening. |
Defend under pushback
Q1 — "How do you avoid collisions?"
We don't rely on probabilistic uniqueness at all for auto-generated codes. Each code comes from a globally-unique, monotonically-issued ID (a leased counter range or a Snowflake-style ID) that is, by construction, never handed to two requesters — there's no "check and retry," because there's nothing to collide with. The one place a real uniqueness check IS needed is custom aliases (Q4), because those are user-chosen strings that don't come from the ID generator and share the same namespace.
Q2 — "301 vs 302 and analytics — what's the actual trade-off?"
301 is cached by browsers/CDNs, so repeat clicks from the same client never reach us again — huge scalability win, but our click counter under-counts real usage because those repeat clicks are invisible to our servers. 302 is never client-cached, so every click round-trips to origin — exact click accounting, but we're back to serving the full 100:1 read load ourselves with no client-side caching assist. A practical middle ground real shorteners use: serve 301 for scale, and fire an async client-side beacon/pixel just before the redirect to recover approximate click counts without paying the full 302 cost — but that's still an approximation, not exact.
Q3 — "Size the keyspace for 100 billion URLs."62⁷ ≈ 3.52 trillion gives ~35× headroom over 100B (using well under 3% of the space), so length-7 codes still hold. But the more important answer is that keyspace size doesn't rescue a hash-and-hope scheme: recomputing the collision estimate at n=100B, n²/(2m) = (1×10¹¹)² / (2×3.52×10¹²) ≈ 1.42 billion expected collisions if you relied on raw hashing without a check — worse than at 30B. The fix isn't a bigger keyspace, it's the same fix as Q1: a collision-free ID source, which is unaffected by n because it never generates a duplicate to begin with.
Q4 — "How do custom aliases work?"
They bypass the ID generator entirely — a user submits a literal string, and we do a synchronous uniqueness check against the same urls table generated codes live in (a conditional insert / unique constraint, since it's one namespace: an alias can't collide with a generated code any more than with another alias). We also run it against a reserved/blocklist (offensive terms, trademarked names) and rate-limit alias creation per user to prevent squatting. This is strictly more work per request than a generated code, which is exactly why aliases are opt-in, not the default path.
Q5 — "How does the distributed ID generator avoid becoming the same SPOF as the single counter?"
Two concrete mechanisms, either works: leased ranges — each app server asks a coordinator (ZooKeeper/etcd) for a block of, say, 1M sequential IDs at startup and whenever it's running low, then serves IDs from that local block with zero further coordinator round-trips until exhausted (refilled asynchronously before hitting zero) — the coordinator is touched roughly once per million writes, so a brief coordinator blip only delays the next refill, not current traffic. Snowflake-style IDs — encode timestamp + machine-id + a local sequence number, generated entirely in-process with zero network calls per ID; the only coordination is assigning each machine a unique machine-id once at boot and keeping clocks roughly synced (NTP) to avoid ordering issues across a clock rollback. Both turn "every write depends on one shared node" into "a rare, amortized, out-of-band operation" — note that simply adding read replicas to the counter does NOT fix this, because you still need exactly one writer to avoid handing out duplicate IDs; replication solves read scale, not write coordination.
What you've mastered when this is done
- You can compute, not assert, why "just hash it" fails at scale — deriving an expected collision count from the birthday bound instead of hand-waving "collisions are rare."
- You can separate a write path's correctness requirement (collision-free IDs) from a read path's scale requirement (cache + CDN), and justify each independently instead of applying one consistency model to both.
- You can pick 301 vs 302 as a deliberate, defensible trade-off tied to a stated product requirement (analytics vs scale), not a default.
- You can distinguish an availability bottleneck from a throughput bottleneck in a write path, and name the fix that addresses the one you actually have (leased ranges/Snowflake vs simple failover).
Re-authored/Deepened for this guide. Model-answer reading: Designing a URL Shortening Service like TinyURL (System Design Problems); Drill: full sizing of a URL shortener (Capacity Estimation). Escalation ideas cross-referenced with Consistent Hashing and the Design a Distributed Rate Limiter / Design a Distributed, Multi-Garage Parking System labs.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design TinyURL (URL Shortener)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design TinyURL (URL Shortener)** (Hands-On Builds) and want to truly understand it. Explain Design TinyURL (URL Shortener) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design TinyURL (URL Shortener)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design TinyURL (URL Shortener)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design TinyURL (URL Shortener)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.