Designing a URL shortener is one of the most common system design interview questions, especially at the intern and new grad level. It’s a focused problem with clear scope — small enough to cover end-to-end in 45 minutes, deep enough to test your instincts on hashing, databases, caching, and scale.
Step 1: Clarify Requirements
Before drawing anything, ask these questions. They change the design significantly:
- Scale: How many URLs shortened per day? How many redirects per day? (Write-to-read ratio matters.)
- Custom aliases: Can users choose their own short code (e.g.,
tinyurl.com/my-blog)? - Expiration: Do URLs expire? Can users set a TTL?
- Analytics: Do we need click counts, geographic data, referrer tracking?
- Availability vs. consistency: Is it okay to occasionally serve a stale redirect?
Assume for this design: 100M new URLs/day, 10B redirects/day (100:1 read/write ratio), custom aliases supported, URLs expire after 1 year by default, basic click analytics required.
Step 2: Back-of-Envelope Estimates
Writes: 100M URLs/day = ~1,200 writes/sec
Reads: 10B redirects/day = ~115,000 reads/sec
URL storage:
- Short code: 7 chars = 7 bytes
- Long URL: avg 100 bytes
- Metadata (created_at, expires_at, user_id, clicks): ~50 bytes
- Per URL: ~160 bytes
- 100M URLs/day × 365 days × 5 years = 180B URLs
- 180B × 160 bytes = ~29 TB total storage over 5 years
Short code length:
- 7 chars from [a-zA-Z0-9] = 62^7 ≈ 3.5 trillion combinations
- More than enough for 180B URLs
Step 3: Core API
POST /urls
Request: { "long_url": "https://...", "alias": "optional", "ttl_days": 365 }
Response: { "short_url": "https://short.ly/aB3kR9x" }
GET /{short_code}
Response: HTTP 301/302 redirect to long_url
GET /urls/{short_code}/stats
Response: { "clicks": 1234, "created_at": "...", "top_countries": [...] }
301 vs 302 redirect: 301 (Permanent) means browsers cache it — the redirect happens client-side on subsequent visits, reducing server load. But you can’t track repeat clicks or update the destination. 302 (Temporary) hits your server every time — more load but accurate analytics. For a URL shortener with analytics, use 302.
Step 4: Short Code Generation
This is the core algorithmic question. Three approaches:
Option A: Hash the Long URL
import hashlib, base64
def shorten(long_url):
hash_bytes = hashlib.md5(long_url.encode()).digest()
# Take first 7 chars of base62-encoded hash
short_code = base62_encode(hash_bytes)[:7]
return short_code
Problem: Hash collisions. Two different long URLs may produce the same short code. Must check the DB and append a suffix if collision detected. Also, the same URL always produces the same code — if two users shorten the same URL, they get the same short link (may or may not be desired).
Option B: Auto-Increment ID → Base62
Use the database’s auto-increment primary key and convert it to base62.
def id_to_short_code(id):
chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
result = []
while id > 0:
result.append(chars[id % 62])
id //= 62
return ''.join(reversed(result))
# ID 1 → "1"
# ID 1000000 → "4c92"
# ID 3.5T → 7 chars
Pros: No collisions. Simple, deterministic.
Cons: Sequential IDs are guessable — competitors can enumerate your URLs. Single DB node generates IDs, which can become a bottleneck. Fix the bottleneck with a distributed ID generator like Twitter’s Snowflake or a pre-generated ID pool.
Option C: Pre-generated Random Codes (Best for scale)
A background service pre-generates random 7-character codes, stores them in a “code pool” table, and marks them used when assigned.
-- Code pool table
CREATE TABLE code_pool (
code CHAR(7) PRIMARY KEY,
used BOOLEAN DEFAULT FALSE
);
-- Claim a code (atomic)
UPDATE code_pool SET used = TRUE
WHERE code = (SELECT code FROM code_pool WHERE used = FALSE LIMIT 1)
RETURNING code;
Pros: No collision risk, no sequential guessing, no ID generation bottleneck. Pool can be pre-seeded.
Cons: More moving parts; need to keep the pool stocked. Use a background job to refill when the pool drops below a threshold.
Step 5: Data Model
-- Primary URLs table
CREATE TABLE urls (
short_code CHAR(7) PRIMARY KEY,
long_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
click_count BIGINT DEFAULT 0
);
-- Click analytics (separate — don't slow down redirects)
CREATE TABLE clicks (
id BIGINT PRIMARY KEY,
short_code CHAR(7) REFERENCES urls(short_code),
clicked_at TIMESTAMP DEFAULT NOW(),
country CHAR(2),
referrer TEXT,
user_agent TEXT
);
Database choice: For the URLs table — any relational DB works at moderate scale. At 100M writes/day, a sharded MySQL (like PlanetScale/Vitess) or DynamoDB with short_code as partition key handles this easily. The clicks table is a time-series write-heavy workload → Cassandra or ClickHouse for analytics queries.
Step 6: Redirect Flow with Caching
Redirects are 100:1 reads. Most short codes will be looked up repeatedly. Cache aggressively.
def redirect(short_code):
# 1. Check cache first
long_url = redis.get(f"url:{short_code}")
if long_url:
async_record_click(short_code) # fire-and-forget to Kafka
return redirect_302(long_url)
# 2. Cache miss → DB lookup
row = db.get("SELECT long_url, expires_at FROM urls WHERE short_code = ?", short_code)
if not row or row.expires_at < now():
return 404
# 3. Populate cache (TTL = min(24h, time_until_expiry))
redis.set(f"url:{short_code}", row.long_url, ex=86400)
async_record_click(short_code)
return redirect_302(row.long_url)
Hot URLs (viral links) should be cached at the CDN edge too — if the redirect response itself is cacheable (use 301 + CDN), you eliminate origin server load entirely.
Step 7: Analytics at Scale
Recording a click on every redirect would crush the DB. Use a message queue:
Redirect service → Kafka topic "clicks" → Analytics consumer → ClickHouse / Cassandra
The redirect service publishes a click event asynchronously (non-blocking). Analytics consumers batch-insert into a time-series DB. Users query aggregated stats from a read-optimized analytics store, not the transactional DB.
High-Level Architecture
Client
↓
CDN (cache 301 redirects for viral URLs)
↓
Load Balancer (L7, route /api vs /)
↓ ↓
URL Shortener API Redirect Service
(POST /urls) (GET /{code})
↓ ↓ ↓
Primary DB Redis Cache Kafka
(write URLs) (hot URLs) (click events)
↓
Analytics Consumer
↓
ClickHouse
Follow-up Questions
Q: How do you handle custom aliases?
Same table. Check if the alias already exists before inserting. Return an error if it’s taken. Rate-limit custom alias creation to prevent squatting.
Q: How do you handle URL expiration?
Store expires_at. Check it on redirect — if expired, return 404. Run a background job nightly to delete expired rows and return codes to the pool.
Q: How do you prevent abuse (malware URLs, spam)?
Integrate a URL reputation API (Google Safe Browsing) on creation. Block known-bad domains. Rate-limit creation by IP/user. Review flagged URLs manually.
Q: How do you scale to 1M writes/sec?
Shard the URLs table by short_code using consistent hashing. Use pre-generated code pools per shard. Deploy multiple redirect service instances behind a load balancer. Push more redirect traffic to the CDN edge.
Summary
A URL shortener is a read-heavy system (100:1) so caching is the primary scaling lever. The short code generation strategy matters — pre-generated random codes avoid collisions and enumeration attacks. Separate the analytics write path from the redirect path using a message queue. The redirect service is stateless and horizontally scalable behind a load balancer. Use a CDN to absorb viral traffic at the edge. This is a clean, complete design that demonstrates you understand caching, async architecture, and scale estimation.
Related System Design Topics
- Caching Strategies — the redirect path is entirely cache-driven; this is what makes 115K reads/sec feasible.
- Message Queues — async click recording via Kafka decouples analytics from the hot redirect path.
- Consistent Hashing — used to route short codes to the correct DB shard at scale.
- Database Sharding — how to scale the URLs table past what a single Postgres instance can handle.
- Load Balancing — the redirect service is stateless and trivially horizontally scalable.
- SQL vs NoSQL — the decision framework for choosing between Postgres, DynamoDB, and Cassandra for different parts of this system.
Also see: Design Twitter Feed, Design a Chat System, and Design YouTube for more complete system design walkthroughs.
Companies That Ask This System Design Question
This problem type commonly appears in interviews at:
See our company interview guides for full interview process, compensation, and preparation tips.