URL Shortener Design
URL Shortener Design
System that maps short alphanumeric codes to long URLs, enabling redirect-based access and click analytics.
Clarify First
Before designing, lock these assumptions with the interviewer:
- Read:write ratio — URL shortener is read-heavy (~100:1); drives CDN and cache tier decisions. Every URL is written once, read hundreds of times.
- Do short URLs expire? — TTL on records adds an
expires_atcolumn and requires background deletion jobs; no expiry means simpler storage but unbounded growth. - Custom aliases? — User-provided short codes (e.g.,
bit.ly/my-brand) require conflict detection; changes the ID generation path. - Analytics depth? — Click count only vs geo/device/referrer breakdowns; click count fits in the URL record, detailed analytics requires a separate event store.
- Scale target? — Startup (1M DAU) vs Bitly-scale (300M DAU); different tiers, different sharding decisions.
Capacity Estimation
Derivation chain for a mid-tier URL shortener (2026):
Assumption: 100M DAU (mid-tier URL shortener, 2026 estimate)
Write path: 1 URL creation per 100 users per day = 1M new URLs/day
write_QPS = 1M / 86_400 ≈ 12 write QPS
Read path: 100:1 read/write ratio
read_QPS = 12 × 100 = 1,200 read QPS
peak_read_QPS = 1,200 × 2 = 2,400 QPS (2x peak multiplier)
Storage (5-year horizon):
record_size ≈ 500 bytes (short_url + long_url + metadata)
daily_records = 1M
5_year_records = 1M × 365 × 5 = 1.825B records
storage = 1.825B × 500 bytes ≈ 900 GB ≈ 1 TB over 5 years
→ single-shard relational DB is sufficient at this scale
→ sharding required only if write_QPS exceeds ~10,000 (100× scale)
Cross-reference: Capacity-Estimation for the shared DAU-to-QPS-to-storage methodology.
Conclusion: At 12 write QPS and 2,400 read QPS, a single-shard relational database handles writes. The read path is the target for caching and CDN optimisation, not the write path.
Central Technical Problem
Collision-free short code generation at write time without a sequential ID source.
Two approaches address this:
Approach 1: Hash-then-truncate
- Compute MD5 or SHA-256 of the long URL.
- Truncate to 6-7 base62 characters.
- Collision risk is on truncation, not on the full hash. Two different URLs can truncate to the same 6-character prefix.
- Mitigation: retry loop (generate next 6 chars from the hash) or bloom filter pre-check before writing.
Approach 2: Auto-increment ID + base62 encode
- Database auto-increment provides a globally unique integer.
- Encode the integer in base62 to get a 6-character short code.
- No collision possible — uniqueness is guaranteed by the auto-increment.
- Risk: sequential IDs are guessable (enumeration attack). Mitigation: apply a random salt or use Snowflake-style distributed counter to remove sequential predictability.
Base62 encoding math
- Alphabet:
[0-9A-Za-z]— 62 characters - 6 characters: 62^6 = ~56.8 billion combinations — sufficient for 1.825B records over 5 years
- 7 characters: 62^7 = ~3.5 trillion — sufficient for Bitly-scale (300M DAU)
- At 1M writes/day for 5 years: 1.825B IDs needed — 6-char base62 has a 31× safety margin
301 vs 302 redirect tradeoff
| Response Code | Browser Caches? | Analytics Impact | Use When |
|---|---|---|---|
| 301 Moved Permanently | Yes — browser skips server on repeat visits | Breaks click count after first visit (clicks never reach server) | Analytics not required; CDN/cache offload is priority |
| 302 Found (Temporary) | No — every click hits the server | Every click is intercepted and countable | Analytics required; accept the per-click server load |
Decision rule: Use 302 if click analytics are required. Use 301 if analytics are not needed and minimising server load is the priority.
Component Design
[Client] --> [Load Balancer] --> [URL Shortener API] --> [Cache (Distributed-Cache)]
| |
v v
[SQL Database] [CDN (for redirect)]
(short_url, long_url,
created_at, expires_at,
click_count)
Component responsibilities:
- Load Balancer — distributes incoming read traffic across API server replicas; stateless API servers scale horizontally
- URL Shortener API — handles URL creation (write path) and redirect lookup (read path); stateless; scales horizontally
- Cache (Distributed-Cache) — cache-aside on the read path; caches (short_url → long_url) mappings; TTL aligned with URL expiry; reduces DB load on the 1,200 read QPS path
- SQL Database — single source of truth for all URL records; relational handles TTL expiry queries and analytics aggregations efficiently at 12 write QPS
- CDN — caches the 302 redirect response at edge nodes globally; reduces latency for international users; effective for popular short URLs with high repeat access
System Diagram
URL-Shortener-Design-diagram.excalidraw
Alternatives Considered
| Decision | Alternative A | Alternative B | Why Chosen Approach Wins |
|---|---|---|---|
| ID generation: auto-increment + base62 | Hash-then-truncate | Snowflake distributed ID | Auto-increment has no collision; simpler implementation; guessability mitigated with salt; preferred at sub-Bitly scale |
| Redirect: 302 Found | 301 Moved Permanently | Client-side JS redirect | 302 enables server-side click interception; 301 breaks analytics after first visit; JS redirect adds latency |
| Storage: relational DB | NoSQL key-value (DynamoDB) | NoSQL document store | Relational handles TTL expiry queries and analytics aggregations at 12 write QPS; NoSQL wins only at 10,000+ write QPS |
| Read scaling: CDN | No CDN (all reads to cache) | Cache only | CDN reduces latency for global users; popular URLs benefit from edge caching; cache tier handles long-tail reads |
Likely Follow-Up Questions
- How do you handle expired URLs? — Background TTL job deletes records past
expires_at; alternatively, a lazy deletion strategy marks records as expired on first miss and deletes in a batch cleanup. - How do you prevent enumeration attacks on sequential IDs? — Apply a random salt before base62 encoding, or use a Snowflake-style ID that embeds a timestamp and sequence rather than a simple counter.
- What changes at Bitly-scale (300M DAU)? — write_QPS grows to 36 (still manageable), but read_QPS grows to 360,000 (3x peak = 720K). A 7-char base62 code is required. Sharding the DB becomes necessary. CDN becomes critical to absorb the read load.
- How do you implement real-time click analytics? — Write click events to a stream (Kafka topic); a downstream analytics worker aggregates per-short-URL counts and stores projections in a read-optimised store. This separates the write-heavy click stream from the URL record itself.
- How would you add custom alias support? — Add an
aliascolumn with a unique constraint; on creation, check for conflict and return 409 if the alias is already taken; custom aliases share the same redirect path as generated codes. - What if the same long URL is shortened multiple times? — Either generate a new short code each time (simpler, wastes space) or de-duplicate by hashing the long URL and checking for an existing record (requires a unique index on the long URL hash).
Existing Pattern Connections
| Design Decision | Existing Pattern | Relationship |
|---|---|---|
| Short code generation via distributed counter | Consistent-Hashing | If using distributed ID generation across nodes, consistent hashing assigns ID ranges to nodes without coordination bottleneck |
| Cache tier for long URL lookup | Distributed-Cache | Cache-aside strategy: check cache first, fall back to DB, populate cache on miss; eviction on URL expiry |
| Read scaling via CDN edge | Content-Delivery-Network | 302 redirects served from CDN edge; CDN caches (short_url → long_url) mapping for the read-heavy path |
| Click analytics as separate projection | CQRS-Pattern | Click events are writes to an append-only event log; analytics queries read from a separate aggregated projection — command and query separation |
| URL expiry triggering cleanup | Domain-Events | URL expiry publishes a URLExpired domain event; background cleanup worker subscribes and deletes the record |