URL Shortener (TinyURL)
Map billions of long URLs to short codes and redirect in <50ms.
Open the interactive version → diagrams, practice & moreRequirements
Functional
- Create short code for a URL
- Redirect code → URL
- Optional custom alias & expiry
- Click analytics
Non-functional
- p99 redirect <50ms
- High availability
- Read:write ~100:1
Scale
~100M new links/day, billions of redirects/day
The approach
Tiny write path (generate unique code, store mapping) + a heavily-cached read path. Reads go App → Cache → DB; the cache absorbs the read skew. Analytics fire-and-forget to a queue.
Key components
Client → App → Cache → KV/SQL store · Queue + workers for analytics · optional CDN
Numbers that matter
- Read:write ≈ 100:1 — billions of redirects vs ~100M new links/day. The whole design bends around that skew: cache everything, make writes cheap.
- 7 base62 chars = 62^7 ≈ 3.5 trillion codes — plenty; 6 chars (~57B) gets tight at 100M/day, so size code length to your horizon.
- Redirect p99 < 50ms: a cache hit is ~1ms, a DB read ~5–10ms — cache hit-rate is the lever, and the tail of unpopular links is what hurts.
- 301 vs 302 is a real trade: 301 lets browsers/CDNs cache the redirect (huge load cut) but loses per-click analytics; 302 keeps every click but every redirect hits you.
Senior deep-dive
Code generation is the crux — everything else is a cached key-value lookup.
A global counter → base62 is dense but needs coordination (hand out ID ranges per host, or use Snowflake-style IDs); hashing risks collisions (retry on conflict).
Keep analytics off the redirect path — it is the hot path and must stay <50ms.
ID generation: the only hard part
Auto-increment is the trap — a single sequence is a write bottleneck and leaks your volume. Hand out ID ranges to each app host (grab a block, allocate locally) or use Snowflake-style IDs (time + machine + counter), then base62-encode. Hashing (MD5 → truncate) works but collides, so you must check-and-retry — an extra read on every write.
The read path is a cache problem
A redirect is a single-key lookup, so cache it hard — App → Cache → DB, the cache absorbing the 100:1 skew. Hot links live in memory; the tail of rarely-clicked links is what misses and hits the DB. Size the cache to your hot set, and push popular mappings to the CDN edge so most redirects never reach the origin.
Custom aliases and the uniqueness check
User-chosen aliases need a real uniqueness guarantee — a conditional insert or unique constraint, never read-then-write (it races). Keep a separate keyspace for custom vs generated codes so they can't collide, and validate length, charset, and profanity up front.
Analytics without slowing redirects
Never log clicks synchronously on the redirect — emit an event to a queue and aggregate asynchronously. Losing a click is fine; adding latency to every redirect is not. At scale, approximate (HyperLogLog) or pre-aggregate by time bucket instead of counting rows.
Expiry, deletion, and storage
Expiry is a TTL checked on read plus a lazy background sweep — don't eagerly scan billions of rows. The store is a simple KV (DynamoDB/Cassandra) or sharded SQL keyed by code: no joins, single-row reads, so it shards cleanly by code hash.
What breaks at scale
At billions of redirects/day the bottlenecks are ID-allocation coordination, cache hit-rate, and DB read fan-out — not storage (the data is tiny). Geo-replicate reads (a mapping is immutable once created), and watch the unpopular-link tail that defeats caching. Writes are easy; the read scale and global ID uniqueness earn the senior signal.
In production
Bit.ly, TinyURL, and t.co all run this shape: a tiny coordinated-ID write path and a massively-cached read path, often with the mapping served from the CDN edge. The interesting engineering is never the shortening — it is ID allocation without a global bottleneck, and keeping the redirect hot path fast at billions/day.
Common mistakes
- Putting click logging synchronously in the redirect
- Using auto-increment IDs that leak volume & need a single sequence
- Forgetting the 100:1 read skew → no cache