Design a URL shortener
Problem Statement
Design a URL shortener like bit.ly
Functional Requirements
- Given a long URL → generate a unique short URL
- Given a short URL → redirect to original long URL
- Link Analytics — click count per short URL
- Optional: custom aliases, expiry dates
Non-Functional Requirements
- Low redirect latency — < 10ms p99 (read-heavy, must be fast)
- Scale — 100M DAU, 1B reads/day, 100M writes/day
- Storage — 1–5B total lifetime URLs
- Highly Available — 99.99% uptime
- High Durability — no URL should ever be lost
- No collisions — two long URLs must never map to same short URL
Estimations
| Metric | Value |
|---|---|
| Writes/sec | ~1,200 RPS |
| Reads/sec | ~12,000 RPS |
| Read:Write ratio | 10:1 |
| Avg URL size | ~500 bytes |
| Storage (5B URLs) | ~2.5 TB |
| Short URL length | 7 chars (base62 → 62^7 = 3.5 trillion unique URLs) |
API Design
POST /api/urls/shorten
Request: { "longUrl": "https://...", "alias": "optional", "expiresAt": "optional" }
Response: { "shortUrl": "https://short.ly/aB3xY9z" }
GET /{shortCode}
Response: 302 Redirect → Location: <originalUrl>
(use 301 for caching, 302 for accurate analytics — prefer 302)
GET /api/urls/{shortCode}/stats
Response: { "clicks": 1234, "createdAt": "...", "lastAccessed": "..." }
301 vs 302:
301 Permanent— browser caches redirect, no future requests hit server → less load, but no analytics302 Temporary— every redirect hits server → accurate click tracking ✅ (bit.ly uses this)
URL Generation Strategies
Option 1: Hash + Base62 Encode (Recommended)
longUrl → MD5/SHA256 hash → take first 7 chars → Base62 encode → shortCode
- Base62 = [a-z, A-Z, 0-9] = 62 chars
- 7 chars = 62^7 = ~3.5 trillion unique codes
- Problem: hash collisions — two different URLs could produce same 7-char prefix
- Fix: check DB before storing; if collision, append counter and re-hash
Option 2: Counter-based (Auto-increment ID → Base62)
DB auto-increment ID → Base62 encode → shortCode
1 → "1"
100 → "1C"
1000000 → "4c92"
- Pros: guaranteed unique, no collision
- Cons: predictable/sequential — security concern, easy to enumerate
- Fix: use distributed ID generator (Twitter Snowflake)
Option 3: Pre-generated Keys (Key Generation Service)
KGS generates random 7-char keys upfront → stores in "available keys" table
URL shortener picks one key → marks as used
- Pros: no collision risk, fast (just pick a key)
- Cons: KGS is a single point of failure → need replication
- KGS keeps a small in-memory buffer of keys to avoid DB hit per request
Recommendation for interview: Base62 + counter (Snowflake ID) or KGS
High Level Architecture
graph TD
Client([Client]) -->|POST /shorten| LB
Client -->|GET /shortCode| LB
LB[Load Balancer] --> AG[API Gateway]
AG -->|write| WS
AG -->|read| RS
subgraph write["Write Path"]
WS[URL Shortener Service]
KGS[Key Generation Service]
DB[(Primary DB <br/> DynamoDB)]
MQ[[Kafka]]
WS --> KGS
WS --> DB
WS -.->|async| MQ
end
subgraph read["Read Path"]
RS[URL Redirect Service]
Cache[(Redis Cache)]
Replica[(Read Replicas)]
RS --> Cache
RS --> Replica
Cache -.->|cache miss| DB
DB --> Replica
end
subgraph analytics["Analytics"]
AS[Analytics Service]
ADB[(Cassandra)]
AS --> ADB
end
MQ --> AS
style write fill:#fff7ed,stroke:#f97316,stroke-width:1.5px
style read fill:#f0fdf4,stroke:#22c55e,stroke-width:1.5px
style analytics fill:#faf5ff,stroke:#a855f7,stroke-width:1.5px
style Client fill:#dbeafe,stroke:#3b82f6
style LB fill:#e9d5ff,stroke:#7c3aed
style AG fill:#e9d5ff,stroke:#7c3aed
style WS fill:#fed7aa,stroke:#ea580c
style KGS fill:#fef9c3,stroke:#ca8a04
style DB fill:#ccfbf1,stroke:#0d9488
style MQ fill:#fed7aa,stroke:#ea580c
style RS fill:#bbf7d0,stroke:#16a34a
style Cache fill:#ccfbf1,stroke:#0d9488
style Replica fill:#ccfbf1,stroke:#0d9488
style AS fill:#f3e8ff,stroke:#9333ea
style ADB fill:#f3e8ff,stroke:#9333ea
Deep Dive
Write Flow (Shorten URL)
1. Client → POST /shorten with longUrl
2. API Gateway → URL Shortener Service
3. Check if longUrl already exists in DB (dedup) → return existing shortCode
4. KGS provides unique shortCode (or generate via Base62)
5. Store { shortCode → longUrl, createdAt, userId, expiresAt } in DB
6. Return shortUrl to client
Read Flow (Redirect)
1. Client → GET /aB3xY9z
2. URL Redirect Service checks Redis cache first
3. Cache HIT → return 302 with longUrl (< 1ms)
4. Cache MISS → query DB → store in cache → return 302
5. Async: publish click event to Kafka → Analytics Service updates click count
Caching Strategy
- Cache: Redis with LRU eviction
- Cache what: shortCode → longUrl mappings (read-heavy, values rarely change)
- TTL: match URL expiry or 24hrs default
- Hit rate: ~80% of traffic hits top 20% URLs (Pareto) → cache very effective
- Cache size: 12,000 RPS × 500 bytes × 20% hot URLs ≈ manageable
Database Schema
URLs Table (DynamoDB / Cassandra)
shortCode (PK) | String | "aB3xY9z"
longUrl | String | "https://..."
userId | String | owner
createdAt | Long | epoch ms
expiresAt | Long | epoch ms (nullable)
clickCount | Int | updated async
Why NoSQL:
- Key-value access pattern (shortCode → longUrl) — perfect fit
- High write throughput
- Horizontal scaling built-in
- No complex joins needed
Scalability
| Layer | Strategy |
|---|---|
| API | Stateless services → horizontal scale behind LB |
| DB writes | Single primary + write sharding by shortCode hash |
| DB reads | Read replicas + Redis cache |
| URL generation | Distributed KGS with in-memory key buffer |
| Analytics | Async via Kafka → don't block redirect latency |
Tradeoffs
| Decision | Option A | Option B | Chose | Why |
|---|---|---|---|---|
| Redirect code | 301 | 302 | 302 | Accurate analytics |
| URL generation | Hash-based | Counter+Base62 | Counter+Base62 | No collisions |
| DB | SQL | NoSQL | NoSQL | High throughput, KV access |
| Analytics | Sync | Async (Kafka) | Async | Don't add latency to redirect |
| Cache eviction | LRU | LFU | LRU | Simpler, good enough for hot URLs |
Edge Cases
- Duplicate longUrl → do NOT dedup across users or by default. Same longUrl must generate a new shortCode each time — different users own different shortCodes, and same user may want separate links per campaign (e.g. two influencers tracking same product URL independently). Cross-user dedup breaks ownership — User B's link could be deleted/modified by User A. bit.ly never dedups by default.
- Expired URLs → lazy delete on access + background TTL cleanup job
- Custom aliases → validate uniqueness before storing
- Malicious URLs → integrate URL safety API (Google Safe Browsing) on write
- KGS failure → fallback to hash-based generation
What Interviewers Look For
- Clarify functional vs non-functional requirements first ✅ 2026-03-29
- Back-of-envelope estimation (storage, RPS)
- Explain 301 vs 302 tradeoff ✅ 2026-03-29
- Justify NoSQL over SQL ✅ 2026-03-29
- Cache layer for read performance ✅ 2026-03-29
- Async analytics to not block redirect ✅ 2026-03-29
- Handle collisions in URL generation ✅ 2026-03-29
- Mention expiry + cleanup strategy