Skip to content
System design course
Ch.4 · Designing real systems·concept ·8 min read

Designing a URL shortener (TinyURL)

The classic warm-up done properly — requirements, the read-heavy estimate that forces caching, and the real design question of how to generate short unique IDs.


The problem

Design a URL shortener (TinyURL, bit.ly): take a long URL, return a short code, and redirect anyone who visits the short link to the original. It’s the canonical opener — small enough to finish, rich enough to show estimation, ID generation, caching, and scale.

Step 1 — Requirements

Functional: create a short URL for a long one; redirect a short URL to its target; optionally custom aliases, expiry, and click analytics.

Non-functional: extremely read-heavy (redirects ≫ creations), low-latency redirects, high availability (a dead shortener breaks every link), unique & short codes, and effectively permanent storage.

Scope cut: core = create + redirect; treat analytics and custom aliases as optional.

Step 2 — Estimate (it drives everything)

  • Say 100M creates/day → ~1,150 writes/sec average, a few k peak.
  • ~100:1 read:write~100k redirects/sec. → redirects must come from cache, never the DB on the hot path.
  • ~500 B/record × 100M/day × 5 years ≈ tens of TBpartition the store.
  • Code length: base62 (a–zA–Z0–9); 62⁷ ≈ 3.5 trillion codes — 7 chars is plenty. (62⁶ ≈ 56B if you want shorter.)

Step 3 — API and data model

POST /shorten { long_url, [custom_alias], [ttl] } → { short_url }
GET  /{code}  → 301/302 redirect to long_url

Mapping: code (PK), long_url, created_at, owner, expires_at

301 (permanent) caches in browsers and saves a hop but loses click tracking; 302 (temporary) lets you count clicks. State the trade.

Step 4 — The real design question: generating the code

The interesting part. Options:

  • Hash the URL (e.g. base62 of MD5, take 7 chars) — simple, but collisions need detection/retry, and identical URLs collapse to one code (sometimes fine).
  • Counter + base62 encoding — a global auto-increment encoded to base62 guarantees unique, short, no-collision codes. But a single counter is a bottleneck/SPOF → hand out ranges of IDs to each app server (each gets a block of 1,000 and encodes locally), or use a dedicated ID generator.
  • Pre-generated keys (KGS) — a key-generation service pre-computes random unused codes into a DB; servers pull batches. No collisions, no hot counter.
  • Snowflake-style IDs — distributed unique IDs (timestamp + machine + seq), base62-encoded; longer codes.

Recommend counter-with-ranges or KGS and explain the bottleneck you’re avoiding.

Step 5 — High-level design

create:   client → LB → app → ID gen (range/KGS) → store mapping (sharded KV) → return code
redirect: client → LB → app → cache(code→long_url)
                                  │ miss → KV store → populate cache → 301/302

Shard the store by code (hash/range); a cache (Redis) absorbs the 100k/sec read load; a CDN/edge can cache hot redirects too.

Step 6 — Custom aliases, expiry, analytics

  • Custom alias → check uniqueness; reserve it (it’s just a chosen code).
  • Expiry → store expires_at; a background job purges, or check on read.
  • Analytics → emit a click event to a queue → async aggregation (don’t slow the redirect).

Trade-offs to raise

  • Hashing (simple, collisions) vs counter (unique, hot) vs KGS (clean, extra service).
  • 301 (cacheable, fast) vs 302 (trackable).
  • Read path is cache-first — the DB is sized for writes + misses, not 100k/sec.

The interview cue

“Read-heavy ~100:1, so cache-first redirects; counter-with-ranges or a KGS for unique short codes (avoiding a hot global counter); sharded KV store; 302 if we want analytics via an async event.” Generating IDs without a bottleneck and serving redirects from cache are the two things this problem is really testing.