Knowledge Guide
HomeHands-On BuildsSD Design Labs

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:

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)

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

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.

TinyURL mechanism: write path is App Server to ID-Gen (leased range or Snowflake, no per-write lock) to base62 encode to the sharded KV store; read path is CDN edge cache and Redis cache serving most requests directly back to the client, with only a cache miss falling through to read the KV store and repopulate the cache.
TinyURL mechanism: write path is App Server to ID-Gen (leased range or Snowflake, no per-write lock) to base62 encode to the sharded KV store; read path is CDN edge cache and Redis cache serving most requests directly back to the client, with only a cache miss falling through to read the KV store and repopulate the cache.

5. Rubric — what a strong answer hits

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:

DecisionOption AOption BPick when
Code generation — uniquenessCounter + base62 (leased range / Snowflake)Hash (MD5/SHA) + collision-check-and-retryCounter+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 — unpredictabilityRaw sequential counter (simple, but enumerable)Counter run through a reversible permutation (Feistel cipher / multiplicative hash mod a prime) before base62Plain 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 code301 Permanent302 Temporary301 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 engineRelational (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 populationCache-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


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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes