Design a URL shortener (TinyURL / bit.ly)
The setup
"Design a URL shortener. Given a long URL, return a short URL (e.g., bit.ly/abc123). Reverse: given a short URL, redirect to the long URL. 100 million URLs created per day, 10× reads vs writes. Short URLs should never collide. Service must be highly available."
This problem looks trivial but exposes core distributed-systems concepts: ID generation at scale, read-heavy caching, durability under load. The interview test is whether you can recognize the simplicity and design accordingly — not over-engineer it.
Clarifying questions
| Question | Story answer |
|---|---|
| Custom aliases (user picks the short code)? | Allow, but skip auth — optional feature. |
| Analytics on clicks? | Out of scope; just redirect. |
| Expiry on short URLs? | Optional. Default: never expire. |
| HTTPS only? | Yes; the redirect is HTTP 301 or 302. |
| What's "highly available" mean numerically? | 99.99% uptime — 1 hour downtime/year max. |
Capacity estimation
Writes: 100M/day = ~1,200/sec avg; 5K/sec peak Reads: 10× writes = ~12K/sec avg; 50K/sec peak Storage: 100M × ~500 bytes/record = 50 GB/day, ~18 TB/year, ~50 TB over 3 years
Design Decision 1: shortcode generation
What length? With 62 alphabet (a-z, A-Z, 0-9):
- 6 chars: 62^6 = 56 billion possible codes
- 7 chars: 62^7 = 3.5 trillion
- 8 chars: 62^8 = 218 trillion
At 100M URLs/day for 10 years: ~365 billion URLs total. 7 chars suffice; 8 chars gives huge headroom.
| Approach | How | Tradeoff |
|---|---|---|
| Random + collision check | Generate random 7-char string; INSERT; on conflict, retry | Simple; collision rate climbs as cardinality grows; eventually many retries; non-deterministic |
| Hash of URL | SHA256(long_url), take first 7 chars (base62 encoded) | Same URL → same shortcode (idempotent for repeat shortens); but doesn't allow different shortcodes for the same URL by different users; collisions inevitable |
| Sequential ID + Base62 | Distributed ID generator (Snowflake), encode to base62 | Guaranteed unique; no collision check needed; but reveals creation order; needs distributed ID infrastructure |
Production answer: sequential ID with Base62 encoding. Why:
- No collision check on insert → simpler, faster, more reliable
- Snowflake-style IDs are 64-bit; one ID generator per shard; trillions of IDs available
- Predictability of IDs: a non-issue for public URL shorteners (where the existence of the URL is OK to be inferred). If predictability matters (private link sharing), apply a bijection: XOR with a secret + base62 encode → opaque but unique.
Design Decision 2: storage choice
| Option | Fit |
|---|---|
| Postgres (sharded by shortcode prefix) | Workable. ~50TB across 16 shards. Operational overhead. |
| DynamoDB | Excellent. KV access pattern; trivial to operate; auto-scales. |
| Cassandra | Workable. KV is its strong suit. Operational complexity (3 nodes minimum, replication, repair). |
For an interview, DynamoDB is the simplest answer for the read pattern. For self-managed, Cassandra. Both scale linearly.
Design Decision 3: read path caching
50K reads/sec at peak — significant for the DB. But the access pattern is heavily skewed: popular URLs (viral tweets, news links) get most reads.
| Tier | Cache size | Hit rate |
|---|---|---|
| In-process (Caffeine) per app server | Top 10K hot URLs per server | ~80% |
| Redis cluster (shared) | Top 1M URLs | ~95% combined with L1 |
| DB fallback | Everything | ~5% of total requests |
With 95%+ L1+L2 hit rate, DB sees 50K × 0.05 = 2.5K rps. Trivial for DynamoDB at any tier.
The architecture
WRITE PATH:
Client ──POST /shorten {long_url}──► App tier ──► ID gen ──► DB INSERT
│
└──► Return short_url
READ PATH (the hot one):
Client ──GET /abc123──► App tier ──► L1 cache (in-process)
│ │
│ └─ miss → L2 cache (Redis)
│ │
│ └─ miss → DB lookup
│
└──► 301 redirect to long_url
Cross-examination round 1: same URL, different shortener
Cross-examination round 2: malicious URLs
Cross-examination round 3: high availability
Takeaway
URL shorteners look trivial but expose real distributed-systems patterns: unique ID generation, cache-driven read scaling, and high availability under load. The non-obvious part is that this is fundamentally a read-heavy KV system, not a data-rich app. Treat it as such: optimize the read path with multi-tier caching; storage choice is the simpler problem.
The deeper lesson for staff-level thinking: recognize the simplicity. Don't over-engineer. A URL shortener doesn't need 5 microservices, a queue, an event bus, or eventual consistency tricks. It needs a fast KV store and aggressive caching. Add complexity only when justified by requirements. Many interview candidates fail this not by missing complexity, but by adding it where it doesn't belong.