Requirements & Scope
Requirements & Scope
Functional Requirements
- Shorten URL: Given a long URL, generate a unique short URL (e.g.,
tinyurl.com/abc123). - Redirect: Given a short URL, redirect to the original long URL.
- Custom Aliases: Users can optionally specify a custom short key (e.g.,
tinyurl.com/my-brand). - URL Expiry: URLs can have an optional TTL (time-to-live) after which they expire.
- Analytics: Track click counts, geographic data, referrer, and device type for each short URL.
- Delete URL: Users can delete a short URL before its expiry.
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Latency | Redirect must complete in < 10ms at the 99th percentile |
| Availability | 99.99% uptime (the redirect path must always be up) |
| Throughput | 100M URLs created per day, 10B redirects per day |
| Durability | URLs must never be lost once created |
| Read:Write Ratio | ~100:1 (reads vastly outnumber writes) |
Capacity Estimation
Writes: 100M URLs/day ≈ 1,200 URLs/sec
Reads: 10B redirects/day ≈ 115,000 redirects/sec
Storage (5 years): 100M × 365 × 5 = 182.5B URLs
Per URL record ≈ 500 bytes → ~91 TB total storage
Cache: 20% of reads = 2B/day → store hot 20% in Redis (~18 GB)
Key Clarifying Questions
- What is the expected length of the short URL? → Typically 6-7 characters.
- Can users create custom aliases? → Yes, with uniqueness check.
- Do we need analytics? → Yes, at least click count and timestamp.
- What happens when a short URL expires? → Return HTTP 404.
- Is the system public or authenticated? → Both: anonymous shortening + logged-in dashboard.
API Design & Data Model
API Design & Data Model
REST API Design
Create Short URL
POST /api/v1/urls
Request Body:
{
"longUrl": "https://www.example.com/very/long/path?query=param",
"customAlias": "my-brand", // optional
"expiry": "2026-12-31T23:59:59Z" // optional, null = never
}
Response (201 Created):
{
"shortUrl": "https://tinyurl.com/abc123",
"longUrl": "https://www.example.com/very/long/path?query=param",
"createdAt": "2026-08-16T10:00:00Z",
"expiresAt": "2026-12-31T23:59:59Z"
}
Redirect
GET /{shortKey}
Response (301 Moved Permanently):
Location: https://www.example.com/very/long/path?query=param
# 301 = browser caches permanently
# 302 = browser always hits our server (better for analytics)
301 vs 302: Use 302 if analytics matter (every redirect hits your server). Use 301 if you want to reduce server load (browser caches and redirects directly).
Delete Short URL
DELETE /api/v1/urls/{shortKey}
Response (204 No Content)
Get URL Analytics
GET /api/v1/urls/{shortKey}/analytics
Response (200 OK):
{
"shortKey": "abc123",
"totalClicks": 15234,
"clicksByDate": { "2026-08-16": 342, ... },
"topReferrers": ["twitter.com", "reddit.com"],
"topCountries": ["US", "IN", "UK"]
}
Data Model
Option A: SQL (MySQL / PostgreSQL)
CREATE TABLE urls (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_key VARCHAR(10) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
user_id BIGINT,
custom_alias VARCHAR(20) UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NULL,
click_count BIGINT DEFAULT 0
);
CREATE INDEX idx_short_key ON urls(short_key);
CREATE INDEX idx_custom_alias ON urls(custom_alias);
CREATE INDEX idx_expires ON urls(expires_at);
Option B: NoSQL (DynamoDB)
Table: urls
Partition Key: short_key (String)
Attributes:
long_url: String
user_id: String (optional)
created_at: String (ISO 8601)
expires_at: String (ISO 8601, null = no expiry)
click_count: Number
Analytics Table (Separate for High Write Volume)
CREATE TABLE url_clicks (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_key VARCHAR(10) NOT NULL,
clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
referrer VARCHAR(500),
ip_address VARCHAR(45),
country VARCHAR(2),
device VARCHAR(20)
);
CREATE INDEX idx_short_key_time ON url_clicks(short_key, clicked_at);
Why separate analytics table? Click events are high-volume writes. Separating them from the main URLs table prevents write contention on the read-heavy URL lookups.
Architecture & Hash Generation
Architecture & Hash Generation
High-Level Architecture
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐
│ Client │────▶│ API Gateway │────▶│ URL Service │────▶│ Database │
│ (Web/ │ │ (Rate Limit,│ │ (Shorten, │ │ (MySQL/ │
│ Mobile) │ │ Auth) │ │ Redirect, │ │ DynamoDB) │
└──────────┘ └──────────────┘ │ Analytics) │ └────────────┘
└──────┬───────┘ │
│ │
▼ │
┌──────────────┐ │
│ Redis │◀──────────┘
│ Cache │
└──────────────┘
│
┌──────┴───────┐
│ Kafka / │
│ SQS │
│ (Analytics) │
└──────────────┘
│
┌──────▼───────┐
│ Analytics │
│ Service │
│ (Click Data) │
└──────────────┘
Component Responsibilities
| Component | Responsibility |
|---|---|
| API Gateway | Rate limiting, authentication, SSL termination, request routing |
| URL Service | Business logic for shortening, redirect lookup, expiry checks |
| Redis Cache | Cache hot short keys → long URL mappings (TTL 24h) |
| Database | Persistent storage for URL mappings |
| Kafka/SQS | Async event bus for analytics click events |
| Analytics Service | Processes click events, updates aggregate stats |
Hash Generation Strategies
Strategy 1: MD5/SHA256 + Base62 Encoding
import hashlib
import string
ALPHABET = string.ascii_letters + string.digits # 62 chars
BASE = len(ALPHABET)
def generate_short_key(long_url: str) -> str:
# Hash the URL
hash_hex = hashlib.md5(long_url.encode()).hexdigest()
# Convert first 8 hex chars to integer
num = int(hash_hex[:8], 16)
# Encode to base62
short_key = []
while num > 0:
short_key.append(ALPHABET[num % BASE])
num //= BASE
return ''.join(short_key[:7]) # 7 chars = 62^7 ≈ 3.5 trillion combinations
Pros: Deterministic (same URL → same key), simple.
Cons: Collisions possible (two URLs hash to same key → retry with salt).
Strategy 2: Counter-Based (Global or Per-Shard)
# Global auto-increment counter
counter = 0
def generate_short_key() -> str:
global counter
counter += 1
return base62_encode(counter)
# Per-shard counter (avoids single point of contention)
# Shard 1: counter 1-100M, Shard 2: 100M-200M, etc.
Pros: No collisions, sequential = predictable length.
Cons: Requires coordination (Kafka auto-increment, or DB sequence).
Strategy 3: Pre-Generated Key Service
┌─────────────────┐
│ Key Generator │ (offline service)
│ Pre-generates │
│ millions of │
│ unique keys │
│ → stored in DB │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Key Pool Table │ (available_keys, partitioned)
│ key_1 | available│
│ key_2 | available│
│ key_3 | claimed │
└────────┬────────┘
│
URL Service claims
a key on each request
Pros: No runtime computation, no collisions, keys are random.
Cons: Need to monitor key pool levels, refill pipeline.
Redirect Flow (Read Path)
Client → GET /abc123
│
▼
┌─────────┐ HIT ┌─────────┐
│ Redis │────────▶│ Return │
│ Cache │ │ 301/302 │
└────┬────┘ └─────────┘
│ MISS
▼
┌─────────┐
│Database │──▶ Lookup short_key → long_url
└────┬────┘
│
▼
┌─────────┐ Populate cache (TTL 24h)
│ Redis │◀─── Write key: short_key → long_url
│ Cache │
└─────────┘
│
▼
Return 301/302 to client
(Async: publish click event to Kafka)
URL Existence Check with Bloom Filter
When creating a new URL:
1. Check Bloom Filter (fast, in-memory)
- If DEFINITELY NOT EXISTS → create URL directly
- If PROBABLY EXISTS → check database for actual collision
2. Bloom filter specs for 1B URLs, 1% false positive rate:
- ~1.2 GB memory (10 bits per element)
- 7 hash functions
- 99% accurate for existence checks
# Pseudocode for creation with bloom filter
if not bloom_filter.check(short_key):
# Definitely not taken — safe to insert
db.insert(short_key, long_url)
bloom_filter.add(short_key)
else:
# Might be taken — check DB to confirm
existing = db.get(short_key)
if existing:
# Collision! Try another key
short_key = generate_short_key(long_url + salt)
# Recursive retry
else:
# False positive — safe to insert
db.insert(short_key, long_url)
bloom_filter.add(short_key)
Scaling & Caching
Scaling & Caching
Scaling Strategy
┌─────────────────────────┐
│ Load Balancer │
│ (Round Robin / L7) │
└────────────┬────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ URL Service │ │ URL Service │ │ URL Service │
│ Instance 1 │ │ Instance 2 │ │ Instance N │
└─────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└──────────────────┼──────────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Redis │ │ Redis │ │ Redis │
│ Shard 1 │ │ Shard 2 │ │ Shard N │
└─────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└──────────────────┼──────────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ MySQL │ │ MySQL │ │ MySQL │
│ Primary │ │ Replica 1 │ │ Replica 2 │
└───────────┘ └─────────────┘ └─────────────┘
Database Sharding
Approach: Hash-Based Sharding on short_key
Shard Key = hash(short_key) % number_of_shards
Shard 0: short_keys starting with a-f
Shard 1: short_keys starting with g-l
Shard 2: short_keys starting with m-r
Shard 3: short_keys starting with s-z
# Or use consistent hashing to minimize reshuffling on shard addition
Caching Strategy
| Aspect | Decision |
|---|---|
| What to cache | Short key → long URL mapping (read path) |
| Cache policy | Cache-aside (lazy loading) |
| Eviction | LRU with TTL of 24 hours |
| Cache size | ~20 GB (stores hot 20% of URLs) |
| Hot keys | Top 1% of URLs handle 20%+ traffic → pin to Redis primary shard |
# Cache-aside pattern
async def resolve_url(short_key: str) -> str:
# 1. Check cache
cached = await redis.get(f"url:{short_key}")
if cached:
# Async: publish click event
kafka.publish("click-events", {"key": short_key, "ts": now()})
return cached
# 2. Check database
long_url = await db.query(
"SELECT long_url FROM urls WHERE short_key = %s AND (expires_at IS NULL OR expires_at > NOW())",
[short_key]
)
if long_url:
# 3. Populate cache
await redis.setex(f"url:{short_key}", 86400, long_url) # TTL 24h
# Async: publish click event
kafka.publish("click-events", {"key": short_key, "ts": now()})
return long_url
return None # URL not found or expired
Analytics Pipeline
┌──────────┐ ┌─────────┐ ┌──────────┐ ┌────────────┐
│ URL Svc │───▶│ Kafka │───▶│ Consumer │───▶│ Analytics │
│ (publish │ │ Topic │ │ Group │ │ Database │
│ click) │ │ │ │ │ │ (Clickhouse│
└──────────┘ └─────────┘ └──────────┘ │ / Redshift)│
└────────────┘
- Click events are published to Kafka asynchronously (fire-and-forget from the redirect path).
- Analytics consumer aggregates data into time-bucketed tables.
- Dashboard service reads from analytics DB (not the main URLs DB).
URL Expiration
Two approaches:
1. Lazy Expiration (TTL on read):
- On redirect, check expires_at > NOW()
- Return 404 if expired, delete asynchronously
2. Active Expiration (Background Job):
- Cron job runs every hour
- DELETE FROM urls WHERE expires_at < NOW() LIMIT 10000
- Batch deletes to avoid DB overload
Rate Limiting
Limits:
- Anonymous: 10 URLs/hour per IP
- Authenticated: 100 URLs/hour per user
- Premium: 1000 URLs/hour per user
Implementation:
- Sliding window counter in Redis
- Key: rate_limit:{user_id}:{window}
- INCR + EXPIRE in pipeline
Deep Dives
Deep Dives
1. Custom Aliases — Uniqueness Check
Flow for custom alias:
1. User requests alias "my-brand"
2. Check Bloom Filter → probably exists?
3. If bloom says maybe: SELECT 1 FROM urls WHERE custom_alias = 'my-brand'
4. If exists: return 409 Conflict
5. If not: insert with custom_alias column
DB Index:
CREATE UNIQUE INDEX idx_custom ON urls(custom_alias) WHERE custom_alias IS NOT NULL;
2. Handling Hot Keys / Thundering Herd
Problem: A viral URL (e.g., during Super Bowl) gets 100K+ requests/sec
Solutions:
1. Cache Replication:
- Replicate hot key across multiple Redis shards
- Client reads from random shard → load distributed
2. Request Coalescing:
- First request populates cache
n - Subsequent requests within 100ms wait on same result
- Use Redis SETNX with short TTL as lock
3. Local Cache (Application-Level):
- Each app server has in-memory LRU cache (e.g., Caffeine)
- TTL 60s for hot keys
- Reduces Redis load by 10-100x
3. Database Choice Tradeoffs
| Factor | SQL (MySQL) | NoSQL (DynamoDB) |
|---|---|---|
| Schema | Fixed schema, ACID | Flexible, eventually consistent |
| Scaling | Vertical + read replicas | Horizontal by default |
| Query | JOINs, complex queries | Simple key-value lookups |
| Cost | Lower at small scale | Predictable at any scale |
| Best For | When you need analytics joins | When simple key-value is enough |
Recommendation: Start with DynamoDB for simplicity. Use SQL if you need complex analytics queries joining URLs with user data.
4. Data Partitioning Strategy
For DynamoDB:
- Partition Key: short_key (uniform distribution via base62)
- No hot partitions because hash of short_key is uniform
For MySQL:
- Shard by hash(short_key) % N
- Use consistent hashing for resharding
- Each shard: primary + 2 read replicas
For Redis:
- Use Redis Cluster (16384 hash slots)
- Slot = CRC16(short_key) % 16384
- Automatic sharding + failover
5. Security Considerations
1. Rate Limiting:
- Per IP: 100 req/min
- Per API key: 1000 req/min
2. Malicious URL Detection:
- Check against Google Safe Browsing API
- Flag URLs with known phishing/malware patterns
3. Abuse Prevention:
- Block shortening of URLs that redirect to blocked domains
- CAPTCHA for anonymous users after 5 URLs/hour
4. Input Validation:
- Validate long URL format (RFC 3986)
- Sanitize custom aliases (alphanumeric + hyphens only)
- Max custom alias length: 20 chars
6. Monitoring & Observability
Key Metrics:
- Redirect latency (p50, p95, p99)
- Cache hit rate (target > 80%)
- URLs created per second
- Database connection pool utilization
- Kafka consumer lag
Alerts:
- Cache hit rate drops below 70% → possible hot key issue
- p99 latency > 50ms → investigate DB slow queries
- Kafka lag > 10K → scale consumer group
7. Cost Estimation
Per 100M URLs/day:
- Storage: ~91 TB over 5 years → ~$2,300/month (S3/DynamoDB)
- Redis: ~20 GB cache → ~$500/month (ElastiCache)
- Compute: 10 API servers → ~$1,000/month (EC2)
- Kafka: 3 brokers → ~$600/month
- Total: ~$4,400/month
Interview Tips
- Start with requirements — Always clarify read:write ratio first. For URL shortener, it's 100:1.
- Explain 301 vs 302 — Shows you understand HTTP semantics and analytics tradeoffs.
- Discuss hash generation tradeoffs — Show you can evaluate approaches (deterministic vs counter vs pre-gen).
- Bloom filter — Mentioning this shows depth for URL existence checks.
- Analytics pipeline — Show you can handle async event processing separately from the critical path.
- Hot keys — Always address the viral URL scenario.
Practice Problems
Design a scalable URL Shortener (Design TinyURL) system. Cover high-level architecture, data model, and API design.
Solution
// Complete system design:
// - Functional + Non-functional requirements
// - Capacity estimation
// - Data model (SQL/NoSQL choice)
// - API endpoints
// - Component architecture
// - Scaling strategy
// - Monitoring & reliabilityHow would you scale URL Shortener (Design TinyURL) to handle 10x the current load? Identify bottlenecks and solutions.
Solution
// Scaling approach:
// 1. Load balancing
// 2. Database sharding/replication
// 3. Cache layer (Redis)
// 4. CDN for static assets
// 5. Async processing (queues)
// 6. Microservices decompositionAnalyze potential failure modes for URL Shortener (Design TinyURL) and design mitigation strategies.
Solution
// Failure mitigation:
// 1. Redundancy (multi-AZ)
// 2. Circuit breakers
// 3. Retry with backoff
// 4. Dead letter queues
// 5. Health checks
// 6. Graceful degradationQuiz
1. Why would you choose HTTP 302 over 301 for URL redirects?
2. What is the primary advantage of using a Bloom filter in the URL shortener?
3. In a URL shortener with 100:1 read:write ratio, which caching strategy is most appropriate?
4. How many unique short keys can a 7-character base62 key generate?
5. What is the recommended approach for handling click analytics in a URL shortener?
Flashcards
Question
What is the read:write ratio for a URL shortener and how does it affect design?
Click to reveal answer
Answer
100:1 (reads vastly outnumber writes). This means we optimize for read performance: cache hot URLs in Redis, use read replicas for the database, and keep the redirect path as fast as possible.
Question
What are the tradeoffs between 301 and 302 HTTP redirects?
Click to reveal answer
Answer
301 (Moved Permanently): Browser caches the redirect, reducing server load but losing analytics. 302 (Found): Browser always hits your server, enabling click tracking but increasing load. Choose 302 if analytics matter.
Question
Explain three strategies for generating unique short keys.
Click to reveal answer
Answer
1) MD5/SHA256 + Base62: Hash the long URL, encode to base62. Deterministic but collision-prone. 2) Counter-based: Auto-incrementing counter encoded to base62. No collisions but requires coordination. 3) Pre-generated key pool: Offline service generates millions of unique keys stored in a pool. No runtime computation.
Question
Why use a Bloom filter for URL shortener creation?
Click to reveal answer
Answer
A Bloom filter quickly checks if a short key definitely does NOT exist (fast path: create directly) or MIGHT exist (slow path: check DB). This avoids hitting the database for every new URL, reducing load. For 1B URLs at 1% false positive rate, it needs ~1.2 GB RAM.
Question
How do you handle a viral URL that gets millions of redirects?
Click to reveal answer
Answer
Three strategies: 1) Replicate the hot key across multiple Redis shards. 2) Use application-level local cache (Caffeine/Guava) with short TTL. 3) Request coalescing — only one request hits DB, others wait for the cached result. Monitor cache hit rate and alert if it drops below 70%.
Question
SQL vs NoSQL for URL shortener storage?
Click to reveal answer
Answer
DynamoDB (NoSQL): Simple key-value lookups, automatic horizontal scaling, predictable performance. Best if you only need short_key → long_url lookups. MySQL (SQL): Better if you need complex analytics queries, JOINs with user tables, or ACID transactions. Start with DynamoDB, migrate to SQL if analytics complexity grows.
Revision Notes
Key Takeaways
- 1.Read-heavy (100:1) → optimize for reads: cache hot URLs, read replicas, fast redirect path
- 2.Use 302 redirects if analytics matter, 301 if reducing server load is priority
- 3.Base62 encoding of 7 characters gives 3.5 trillion unique keys — more than enough
- 4.Bloom filter reduces DB lookups for URL creation — check existence without hitting DB
- 5.Analytics should be async (Kafka) — never block the redirect critical path
- 6.Handle viral URLs with local cache + Redis shard replication + request coalescing
Interview Tips
- •Start by clarifying the read:write ratio — this drives all caching and scaling decisions
- •Explain 301 vs 302 tradeoff — shows HTTP protocol knowledge
- •Discuss hash generation approaches with pros/cons — demonstrates tradeoff thinking
- •Mention Bloom filter for URL existence — shows depth beyond basic design
- •Always address the viral URL / hot key scenario — interviewers expect this
- •Separate analytics from the critical path — shows production engineering mindset
- •End with cost estimation — shows business awareness
Cheat Sheet
URL Shortener — Cheat Sheet
Requirements
- Shorten URL, redirect, custom aliases, expiry, analytics
- 100:1 read:write ratio, <10ms redirect latency, 99.99% availability
- 100M URLs/day writes, 10B redirects/day reads
API
POST /api/v1/urls— create short URL (body: longUrl, customAlias?, expiry?)GET /{shortKey}— redirect (302 for analytics, 301 for performance)DELETE /api/v1/urls/{shortKey}— delete short URL
Hash Generation
- MD5/SHA256 + Base62 (deterministic, collision possible)
- Counter-based (sequential, no collisions, needs coordination)
- Pre-generated key pool (offline, random, no runtime cost)
- 7-char base62 = 3.5 trillion combinations
Architecture
Client → API Gateway (rate limit, auth) → URL Service → Redis Cache → DB
↓
Kafka → Analytics Service
Data Model
- URLs table: short_key (PK), long_url, user_id, custom_alias, created_at, expires_at
- Clicks table: short_key, clicked_at, referrer, country, device (separate for write volume)
Caching
- Cache-aside (lazy loading) with 24h TTL
- Hot 20% of URLs → ~20 GB Redis cache
- Local app cache for viral keys
Scaling
- Database: shard by hash(short_key) % N
- Redis: Redis Cluster (CRC16 slot assignment)
- API: horizontal scaling behind load balancer
- Analytics: async via Kafka + separate analytics DB
Key Deep Dives
- Bloom filter for URL existence check (~1.2 GB for 1B URLs)
- Hot key handling: shard replication, local cache, request coalescing
- Rate limiting: sliding window in Redis (10/hr anonymous, 100/hr auth)
- Expiration: lazy check on read + background cron job