Design a system that takes a long URL and returns a short URL, and redirects short URLs to their originals.
1️⃣ Clarify Requirements
Functional Requirements
- Given a long URL, generate a short URL
- Given a short URL, redirect to the original long URL
- (Optional) Custom aliases:
short.ly/my-brand - (Optional) URL expiration
- (Optional) Analytics: click count, referrer, location
Non-Functional Requirements
- High availability — downtime = broken links everywhere
- Low latency redirects — < 10ms (cached)
- Durability — shortened URLs should work for years
- Reads >> Writes (100:1 read-to-write ratio typical)
2️⃣ Estimate Scale
Write QPS:
10M URLs shortened per day
= 10M / 86,400 ≈ 115 writes/sec
Read QPS (100:1 ratio):
100 × 115 = 11,500 reads/sec
Peak: ~50,000 reads/sec
Storage:
Each record: shortURL(7B) + longURL(200B) + metadata(100B) ≈ 307 bytes
10M URLs/day × 10 years = 36.5B URLs
36.5B × 307 bytes ≈ 11 TB (very manageable)
Cache:
80% reads on 20% of URLs (Pareto principle)
Cache 20% of daily URLs: 10M × 0.2 × 307B ≈ 600 MB/day
3️⃣ API Design
# Create short URL
POST /api/shorten
Body: { "longUrl": "https://...", "customAlias": "optional", "expiresAt": "optional" }
Response: { "shortUrl": "https://short.ly/abc1234" }
# Redirect
GET /:shortCode
Response: 301/302 Redirect to longUrl
# Analytics (optional)
GET /api/stats/:shortCode
Response: { clicks: 1000, topCountries: [...] }
301 vs 302:
-
301 Permanent→ browser caches redirect → fewer server hits → can't update -
302 Temporary→ browser always checks server → can update/expire → use this for analytics
4️⃣ Short Code Generation
Option A: Hash + Truncate
MD5(longURL) = "1a79a4d60de6718e8e5b326e338ae533"
Take first 7 chars: "1a79a4d"
shortURL: short.ly/1a79a4d
Problem: Collisions — different URLs can produce same 7-char prefix.
Option B: Base62 Encoding (Recommended)
Character set: [0-9a-zA-Z] = 62 characters
7 characters × 62^7 = 3.5 trillion unique codes ✅
ID: 123456789
Base62(123456789) = "8M0kX"
shortURL: short.ly/8M0kX
Where does the ID come from?
- Auto-increment DB ID — simple, but reveals volume/sequence
- UUID — random, no collision, but long → truncate carefully
- Snowflake ID — distributed, time-sortable, unique (Twitter's approach)
Option C: Counter Service
A dedicated service generates unique IDs:
Counter Service: ID = 1, 2, 3, ... (globally unique)
URL Service: Base62(ID) → short code
5️⃣ High-Level Design
[Cache (Redis)]
│
Client ──► [API Gateway + LB] ──► [URL Service] ──► [DB (PostgreSQL)]
│
[Analytics Queue]
│
[Analytics DB]
Redirect flow:
Client ──GET /abc1234──► [URL Service]
│
┌─── Redis HIT → 302 Redirect ──► Client
│
└─── Redis MISS → DB query → cache → 302 Redirect
6️⃣ Database Schema
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(10) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
click_count BIGINT DEFAULT 0
);
CREATE INDEX idx_short_code ON urls(short_code);
-- Analytics table
CREATE TABLE clicks (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(10),
clicked_at TIMESTAMP DEFAULT NOW(),
country VARCHAR(2),
referrer TEXT,
user_agent TEXT
);
7️⃣ Caching Strategy
Key: short_code ("abc1234")
Value: long_url
TTL: 24 hours (refresh on hit if needed)
Cache-aside:
1. Check Redis for short_code
2. MISS → query PostgreSQL → store in Redis with TTL
3. HIT → return long_url immediately
Cache hit rate target: >95% (80/20 rule — most traffic on top URLs)
8️⃣ Scalability Deep Dive
Read Scaling
- Redis cache handles ~95% of redirect traffic
- Read replicas for the remaining DB reads
- CDN for the redirect response itself (if 301)
Write Scaling
- Writes (new URLs) are much rarer — primary DB + async replication is fine
- If needed: hash short_code → route to shard
High Availability
- Redis Sentinel or Cluster for cache HA
- DB: Primary + read replicas + automated failover
- Multiple URL service instances behind LB
9️⃣ Custom Aliases & Expiry
def shorten(long_url, custom_alias=None, expires_at=None):
if custom_alias:
if db.exists(custom_alias):
raise ConflictError("Alias taken")
short_code = custom_alias
else:
id = db.insert(long_url)
short_code = base62_encode(id)
db.store(short_code, long_url, expires_at)
return f"https://short.ly/{short_code}"
def redirect(short_code):
url_data = cache.get(short_code) or db.get(short_code)
if not url_data:
raise NotFoundError()
if url_data.expires_at and url_data.expires_at < now():
raise GoneError() # 410 Gone
return url_data.long_url
🎨 Diagram
The diagram shows:
- Full system: Client → LB → URL Service → Redis → PostgreSQL
- Shorten flow vs redirect flow (separate paths)
- Cache hit vs miss paths
- Analytics async flow via queue
- DB schema with indexes highlighted
✅ Trade-offs Summary
| Decision | Choice | Trade-off |
|---|---|---|
| 301 vs 302 | 302 | More server hits, but analytics & expiry work |
| ID generation | DB auto-increment + Base62 | Simple but sequential — use Snowflake for privacy |
| Cache TTL | 24hr | Stale entries for expired/deleted URLs |
| SQL vs NoSQL | PostgreSQL | Simple queries, ACID for uniqueness |
Top comments (1)
A 24-hour Redis TTL paired with expiring links needs more care: if the cache stores only
long_url, the redirect path can't enforceexpires_aton a hit, and a disabled alias may stay live until eviction. I'd cache the URL plus status and expiry, invalidate on mutations, and keep click events on the analytics queue rather than updatingclick_countsynchronously. At 10M new URLs per day, the missing production concern is abuse-rate limits, domain reputation checks, and a fast takedown path matter as much as Base62 uniqueness.