Classic Designs
Design a service like bit.ly. Covers hashing strategies, base62 encoding, collision handling, redirect performance, and analytics tracking.
The URL shortener is one of the most commonly asked system design questions because it appears simple on the surface but reveals your understanding of hashing, database design, caching, and scalability under the hood. You are designing a service like bit.ly that takes a long URL and returns a short alias, then redirects anyone who visits that alias back to the original URL.
Assumptions:
- 100 million new URLs per month
- 100:1 read-to-write ratio → 10 billion redirects per month
Writes: 100M / (30 × 24 × 3600) ≈ 40 URLs/sec
Reads: 10B / (30 × 24 × 3600) ≈ 3,850 redirects/sec
Storage (5-year retention):
- 100M × 12 months × 5 years = 6 billion URLs
- Average URL length: 500 bytes (long URL) + 7 bytes (short code) + metadata ≈ 600 bytes
- Total: 6B × 600 bytes ≈ 3.6 TB
Cache (for hot URLs):
- 20% of URLs generate 80% of traffic (Pareto principle)
- Daily redirects: 10B / 30 ≈ 333M/day
- Cache 20% of daily URLs: 333M × 0.2 × 600 bytes ≈ 40 GB (fits in memory)The core challenge: how do you generate a short, unique code for each URL?
Use the characters `[a-zA-Z0-9]` (62 characters). A 7-character code gives you 62^7 ≈ 3.5 trillion unique combinations, far more than the 6 billion URLs we need to store.
Hash the long URL (e.g., MD5 or SHA-256) and take the first 7 characters of the base62 encoding.
import hashlib
import base64
def generate_short_code(long_url):
hash_bytes = hashlib.md5(long_url.encode()).digest()
base62 = base64.b64encode(hash_bytes).decode()
# Take first 7 alphanumeric characters
code = ''.join(c for c in base62 if c.isalnum())[:7]
return codeCollision handling: Different URLs may produce the same 7-character code. When a collision is detected (the code already exists in the database for a different URL), append a counter or timestamp to the input and rehash. Alternatively, probe: try characters 1-7, then 2-8, then 3-9, and so on.
Pros: Deterministic. The same long URL always produces the same short code (useful for deduplication). Cons: Collision resolution adds complexity and database round-trips.
Use a global auto-incrementing counter. Convert the counter value to base62.
BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def to_base62(num):
if num == 0:
return BASE62[0]
result = []
while num > 0:
result.append(BASE62[num % 62])
num //= 62
return ''.join(reversed(result))
# to_base62(1000000) → "4c92"
# to_base62(3500000000000) → "zzzzzz" (still only 6 chars)Pros: No collisions by construction. Simple and fast. Cons: Sequential IDs are predictable (security concern). Requires a centralized counter, which becomes a bottleneck.
To avoid the single-counter bottleneck, use a distributed ID generator:
The range-based approach is the best balance of simplicity and scalability for interviews.
This is a detail that separates strong candidates from average ones.
For a URL shortener, use 302 if analytics is a feature (most real-world shorteners). Use 301 if you want to minimize server load and do not need click tracking.
Table: urls
┌─────────────┬──────────────┬──────────────────────────────┬─────────────────────┬──────────┐
│ short_code │ long_url │ created_at │ expires_at │ user_id │
│ (PK, CHAR 7) │ (TEXT) │ (TIMESTAMP) │ (TIMESTAMP) │ (BIGINT) │
├─────────────┼──────────────┼──────────────────────────────┼─────────────────────┼──────────┤
│ "aB3x9Kp" │ "https://..."│ 2024-01-15T10:30:00Z │ 2029-01-15T10:30:00Z│ 42 │
└─────────────┴──────────────┴──────────────────────────────┴─────────────────────┴──────────┘
Table: analytics (optional)
┌─────────────┬─────────────────────┬────────────┬──────────────┬───────────┐
│ short_code │ clicked_at │ ip_address │ user_agent │ referrer │
└─────────────┴─────────────────────┴────────────┴──────────────┴───────────┘Database choice: A NoSQL key-value store (DynamoDB, Cassandra) is a natural fit since the primary access pattern is a point lookup by short code. However, a relational database (PostgreSQL with proper indexing) works perfectly fine at this scale.
Since redirects dominate the workload, caching is critical.
Client → Load Balancer → App Server → Cache (Redis) → Database
↑
Cache hit: return immediately
Cache miss: query DB, populate cacheFor a URL shortener, analytics (click counts, geographic distribution, referrer tracking) is a key feature. The naive approach of incrementing a counter on every click creates a write bottleneck.
Better approaches:
┌──────────────┐
Clients ────────>│ Load Balancer│
└──────┬───────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ App Svc 1│ │ App Svc 2│ │ App Svc 3│
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└─────────────┼─────────────┘
│
┌──────┴───────┐
│ Redis Cache │
└──────┬───────┘
│ (miss)
┌──────┴───────┐
│ Database │
└──────────────┘
│
┌──────┴───────┐
│ Kafka (clicks│
│ for analytics)
└──────────────┘