Design URL Shortener
A production-grade URL shortening service capable of handling billions of redirects. This walkthrough covers high-throughput read paths, distributed Snowflake ID generation, caching strategies, robust rate limiting, and includes an interactive quiz.
Functional Requirements
Core Features
Out of Scope (V1)
- โ Real-time click stream dashboards
- โ Link-in-bio landing pages
- โ QR code generation (V2)
- โ Team / organization management
- โ Browser extension
- โ Bulk URL upload
User Journeys
โ๏ธ Alternatives & Tradeoffs
Non-Functional Requirements
- โบRead Latency: < 10ms p50 / < 50ms p99
- โบWrite Latency: < 200ms p99
- โบThroughput: 100K reads/sec, 1K writes/sec at peak
- โบSLA: 99.99% uptime (52 min downtime/year)
- โบFailover: Auto-failover < 30 seconds
- โบRedundancy: Multi-AZ deployment; no SPOF
- โบURLs stored: 100M+ unique short URLs
- โบHorizontal: Stateless services, scale-out pattern
- โบDB sharding: Shard on short_code hash
- โบPersistence: URLs must not be lost
- โบBackup: Daily snapshots + point-in-time recovery
- โบReplication: 3ร replicas across 2+ AZs
- โบAuth: JWT tokens + API key
- โบRate Limiting: 100 URLs/hr anon, 10K/hr premium
- โบMalicious URLs: Google Safe Browsing API check
- โบModel: Eventual consistency for analytics OK
- โบURL reads: Strong consistency (read-your-writes)
- โบUniqueness: Strict guarantee for short_code
CAP Theorem Positioning
The URL shortener prioritizes Availability (A) + Partition Tolerance (P) โ i.e. an AP system.
If a node goes down, we prefer serving stale cache over returning errors. For the write path (URL creation), we briefly sacrifice availability for consistency to guarantee unique short codes.
โ๏ธ Alternatives & Tradeoffs
Back-of-the-Envelope Estimation
๐ Traffic Assumptions
| DAU | 100 million |
| Read:Write Ratio | 100:1 |
| New URLs/day | 1 million |
| Redirects/day | 100 million |
| Write RPS | ~12 req/s |
| Read RPS | ~1,200 req/s |
| Peak Read RPS (10ร) | ~12,000 req/s |
๐๏ธ Storage & Cache
| URL record size | ~500 bytes |
| New records/year | 365M |
| Storage/year | ~182 GB |
| 5-year storage | ~1 TB |
| With replication (3ร) | ~3 TB |
| Click events/day | 100M @ 200B = 20 GB |
| Click data (1 yr) | ~7.3 TB |
๐ข Short Code Math
Base62 charset: a-z A-Z 0-9 = 62 chars
At 1M new URLs/day, a 7-char code space lasts ~9,589 years.
โ๏ธ Alternatives & Tradeoffs
High-Level Design
๐๏ธ Architecture Overview
This High-Level Design outlines how the URL Shortener efficiently handles both read-heavy redirect traffic and write-heavy URL creation spikes. Client requests hit the CDN and Load Balancer, which route traffic to stateless Read or Write microservices. A caching layer provides sub-millisecond lookups, while an asynchronous message queue buffers database writes and analytics to ensure the core system remains highly responsive under load. Click on any component below to learn more about its specific role.
๐ Click any component
โ๏ธ Write Path
โ๏ธ Read Path
Redirect Status Codes
Cache Strategy
โ๏ธ Alternatives & Tradeoffs
Data Model
๐ Schema
{
"PK": "abc123def", // Partition Key (short_code)
"long_url": "https://example.com/very/long/path",
"user_id": "987654321", // Used for Global Secondary Index (GSI)
"created_at": 1705312800,
"expires_at": 1736848800,
"is_active": true
}CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
plan_tier VARCHAR(50) DEFAULT 'free',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE custom_domains (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
domain VARCHAR(255) NOT NULL UNIQUE
);CREATE TABLE clicks (
id BIGINT PRIMARY KEY,
short_code VARCHAR(8) NOT NULL,
clicked_at TIMESTAMP NOT NULL,
country_code CHAR(2),
device_type VARCHAR(50)
) ENGINE=MergeTree()
PARTITION BY toYYYYMM(clicked_at)
ORDER BY (short_code, clicked_at);๐ ER Diagram
user_id in the URLs table are enforced at the application layer, not the database layer, because they live in entirely different database systems (PostgreSQL vs DynamoDB).๐๏ธ Storage Choice
โ๏ธ Alternatives & Tradeoffs
API Design
Authorization: Bearer <jwt>/api/v1/shorten
Create a shortened URL
{
"long_url": "https://www.example.com/very/long/path?q=1",
"custom_alias": "my-promo", // optional
"expires_at": "2025-12-31T23:59:59Z", // optional ISO-8601
"title": "My Campaign Link", // optional
"tags": ["marketing", "q4"] // optional
}{
"data": {
"id": "abc123def",
"short_url": "https://bit.ly/my-promo",
"short_code": "my-promo",
"long_url": "https://www.example.com/very/long/path?q=1",
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2025-12-31T23:59:59Z",
"qr_code_url": "https://bit.ly/my-promo/qr"
}
}/{short_code}
Redirect to original URL (public endpoint)
No request body. Path param: short_code (e.g. my-promo)HTTP/1.1 302 Found
Location: https://www.example.com/very/long/path?q=1
X-RateLimit-Remaining: 999
Cache-Control: no-store/api/v1/urls/{short_code}
Get metadata for a short URL (authenticated)
Headers: Authorization: Bearer <jwt_token>{
"data": {
"id": "abc123def",
"short_url": "https://bit.ly/my-promo",
"long_url": "https://www.example.com/very/long/path?q=1",
"click_count": 42891,
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2025-12-31T23:59:59Z",
"is_active": true
}
}/api/v1/urls/{short_code}
Deactivate / delete a short URL
Headers: Authorization: Bearer <jwt_token>HTTP/1.1 204 No Content/api/v1/urls/{short_code}/analytics
Get analytics for a URL
Query params:
from=2024-01-01&to=2024-01-31
granularity=day // hour | day | week | month
group_by=country // country | device | referrer{
"data": {
"total_clicks": 42891,
"unique_visitors": 18234,
"time_series": [
{ "date": "2024-01-01", "clicks": 1423 },
{ "date": "2024-01-02", "clicks": 2105 }
],
"breakdown": {
"by_country": [{ "country": "US", "clicks": 18000 }],
"by_device": [{ "device": "mobile", "clicks": 25000 }],
"by_referrer": [{ "referrer": "twitter.com", "clicks": 9000 }]
}
}
}/api/v1/urls/{short_code}
Update URL metadata (title, expiry, destination)
{
"long_url": "https://new-destination.com", // optional
"title": "Updated title", // optional
"expires_at": "2026-06-30T00:00:00Z", // optional
"is_active": true // optional
}{
"data": { /* full url object */ }
}Rate Limiting Headers (all responses)
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 987
X-RateLimit-Reset: 1705312800 (Unix timestamp)
Retry-After: 60 (seconds, only on 429)โ๏ธ Alternatives & Tradeoffs
Deep Dive
๐ ID Generation
A 64-bit Snowflake ID is composed of: 41-bit timestamp (milliseconds since epoch), 10-bit machine ID (datacenter + worker), and a 12-bit sequence number(4096 IDs per millisecond per node). This gives us uniqueness without coordination between nodes. The numeric ID is then Base62-encoded to produce the short code.
// Base62 charset: 0-9a-zA-Z (62 characters)
const CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
function toBase62(num: bigint): string {
if (num === 0n) return CHARSET[0];
let result = '';
while (num > 0n) {
result = CHARSET[Number(num % 62n)] + result;
num = num / 62n;
}
return result;
}
// ID 12345678901 โ "dnh75K"If system clock moves backward (NTP correction), Snowflake may generate duplicate IDs.
- Wait until clock catches up (if skew < 10ms)
- Use logical clock + atomic monotonic sequence
- Use Boundary for strict ordering
โฐ URL Expiry
- User requests short URL
- Read service fetches URL
- Check expires_at field
- If expired โ return 410 Gone
- Background job deletes old records
- Set Redis key with TTL = expires_at - now()
- Cache miss on TTL expiry โ DB lookup
- DB returns 410 if expires_at passed
- Daily cron: DELETE FROM urls WHERE expires_at < NOW()
- Or: Mark is_active = FALSE for soft delete
๐ฆ Rate Limiting
-- Lua script (atomic in Redis)
local key = "rate:" .. user_id
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2]) -- tokens/sec
local burst = tonumber(ARGV[3]) -- max bucket size
local bucket = redis.call("HMGET", key, "tokens", "last")
local tokens = tonumber(bucket[1]) or burst
local last = tonumber(bucket[2]) or now
-- Refill tokens
local delta = math.max(0, now - last)
tokens = math.min(burst, tokens + delta * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call("HMSET", key, "tokens", tokens, "last", now)
redis.call("EXPIRE", key, 3600)
return 1 -- allowed
end
return 0 -- denied-- Sliding window log in Redis sorted set
local key = "swl:" .. user_id .. ":" .. minute
local now = tonumber(ARGV[1])
local window = 60 -- 60 second window
local limit = 100 -- max requests
-- Remove old entries outside window
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
-- Count current window
local count = redis.call("ZCARD", key)
if count < limit then
redis.call("ZADD", key, now, now .. math.random())
redis.call("EXPIRE", key, window)
return 1 -- allowed
end
return 0 -- denied๐ Custom Domains
- 1User adds domain in dashboard (e.g. go.acme.com)
- 2System provides CNAME record: go.acme.com โ us.bitly.com
- 3User adds CNAME at their DNS provider
- 4System polls DNS; verifies CNAME resolution
- 5Auto-provisions TLS certificate (Let's Encrypt / ACM)
- 6Domain is activated; URLs with prefix work
# Wildcard cert + domain routing
server {
listen 443 ssl;
server_name *.bitly.com go.acme.com;
ssl_certificate /etc/certs/wildcard.crt;
location / {
# Pass Host header; app resolves domain โ user_id
proxy_pass http://read-service;
proxy_set_header Host $host;
}
}Host header โ lookup custom_domains table โ get user_id โ namespace short code to that user.Bottlenecks & Mitigation
โ๏ธ Alternatives & Tradeoffs
๐ Quiz: Test Your Understanding
Check how well you learned the URL shortener system design. 20 questions.