Design Exercise: URL Shortener

You have learned the building blocks. Now let us assemble them.
In this exercise, we design a URL shortener from scratch—like Bitly or TinyURL. This is a classic system design problem because it touches every concept we have covered: APIs, databases, caching, scaling, and trade-offs.
How to approach this exercise:
- Spend 30-40 minutes designing the system yourself
- Compare your design to the solution
- Note the trade-offs you made differently—there is rarely one right answer
This simulates a real system design interview. The goal is not perfection but demonstrating structured thinking.
The Problem
Design a URL shortening service with the following capabilities:
Core functionality:
- Given a long URL, generate a short URL
- Given a short URL, redirect to the original long URL
- Short URLs should be as compact as possible
Additional features (if time permits):
- Custom short URLs (user-provided aliases)
- URL expiration
- Analytics (click counts)
Let us work through this systematically.
Step 1: Requirements Clarification
Before any design, clarify what you are building.
Functional Requirements
Must have:
- Shorten URL: Input long URL → Output short URL
- Redirect: Access short URL → Redirect to long URL
- Short URLs are unique and persistent
Nice to have:
- Custom aliases (user picks the short code)
- Expiration (URLs expire after set time)
- Analytics (view count, referrer, geography)
Out of scope:
- User accounts (keep it simple)
- Rate limiting (important but separate topic)
- Spam detection
Non-Functional Requirements
Let us define the scale:
Traffic estimates:
- 100 million URLs created per month
- Read/write ratio: 100:1 (redirects are much more common)
- URLs stored for 5 years
Performance:
- Redirect latency: < 100ms (users expect instant redirects)
- URL creation latency: < 500ms (acceptable for user action)
Availability:
- 99.9% uptime (redirects must work reliably)
Other:
- Short URLs should be non-guessable (prevent enumeration)
- System should handle traffic spikes (viral links)
Step 2: Capacity Estimation
Let us size the system with back-of-envelope calculations.
QPS (Queries Per Second)
Writes (URL creation):
plaintext100M URLs/month = 100M / (30 days × 24 hours × 3600 seconds) = 100M / 2.6M seconds ≈ 40 writes/second Peak (assume 2x): 80 writes/second
Reads (redirects):
plaintextRead/write ratio: 100:1 40 writes × 100 = 4,000 reads/second Peak (assume 2x): 8,000 reads/second
These numbers are moderate—a well-designed single server could handle this.
Storage
Total URLs over 5 years:
plaintext100M/month × 12 months × 5 years = 6 billion URLs
Storage per URL:
plaintextShort code: 7 bytes Long URL: 200 bytes (average) Created at: 8 bytes Expires at: 8 bytes (nullable) Click count: 4 bytes ─────────────────────────── Total: ~230 bytes → round to 250 bytes
Total storage:
plaintext6 billion URLs × 250 bytes = 1.5 TB With 3x replication: 4.5 TB
Manageable. A single large database server could hold this.
URL Length
How short can our URLs be?
plaintextUsing base62 (a-zA-Z0-9): 62 characters 6 characters: 62^6 = 56.8 billion combinations 7 characters: 62^7 = 3.5 trillion combinations
6 characters gives 56 billion combinations for 6 billion URLs—about 10x headroom. We will use 7 characters for extra safety.
Short URL format: https://short.ly/abc1234
Step 3: API Design
Define the interface before the implementation.
Create Short URL
plaintextPOST /api/shorten Content-Type: application/json Request: { "long_url": "https://www.example.com/very/long/path/to/page", "custom_alias": "mylink", // Optional "expires_in": 86400 // Optional: seconds until expiration } Response (201 Created): { "short_url": "https://short.ly/abc1234", "long_url": "https://www.example.com/very/long/path/to/page", "expires_at": "2024-12-20T10:00:00Z", "created_at": "2024-12-19T10:00:00Z" } Errors: - 400: Invalid URL format - 409: Custom alias already taken - 429: Rate limit exceeded
Redirect
plaintextGET /{short_code} Response: - 301 Moved Permanently (cacheable) or 302 Found (not cached) - Location: https://www.example.com/very/long/path/to/page Errors: - 404: Short URL not found - 410: Short URL expired
301 vs 302:
- 301 Permanent: Browser caches redirect, fewer requests to our server
- 302 Temporary: Every access hits our server, better for analytics
For analytics, use 302. For pure redirects, 301 reduces server load.
Get URL Info (Optional)
plaintextGET /api/urls/{short_code} Response: { "short_url": "https://short.ly/abc1234", "long_url": "https://www.example.com/...", "created_at": "2024-12-19T10:00:00Z", "expires_at": "2024-12-20T10:00:00Z", "click_count": 1523 }
Step 4: High-Level Design
Start with the simplest architecture that meets requirements:
plaintext┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Client │─────────►│ API Server │─────────►│ Database │ └─────────────┘ └─────────────┘ └─────────────┘
Now add components to meet non-functional requirements:
Evolved Architecture
plaintext┌───────────────┐ │ Clients │ └───────┬───────┘ │ ┌───────▼───────┐ │ CDN │ ← Cache redirects at edge └───────┬───────┘ │ ┌───────▼───────┐ │ Load Balancer │ └───────┬───────┘ │ ┌─────────────────┼─────────────────┐ │ │ │ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │ API Srv │ │ API Srv │ │ API Srv │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ └─────────────────┼─────────────────┘ │ ┌─────────────────┼─────────────────┐ │ │ │ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │ Redis │ │ Primary │ │ Replica │ │ Cache │ │ DB │ │ DB │ └───────────┘ └───────────┘ └───────────┘
Component Responsibilities
| Component | Purpose |
|---|---|
| CDN | Cache popular redirects at edge (optional) |
| Load Balancer | Distribute traffic, health checks, SSL termination |
| API Servers | Handle requests, generate short codes, coordinate storage |
| Redis Cache | Store hot URLs for fast redirects |
| Primary DB | Store all URL mappings, handle writes |
| Replica DB | Handle read queries, failover |
Step 5: Core Components Deep Dive
Short Code Generation
The heart of the system. Several approaches:
Option 1: Random Generation
pythonimport random import string def generate_short_code(length=7): chars = string.ascii_letters + string.digits # 62 characters return ''.join(random.choice(chars) for _ in range(length))
Flow:
plaintext1. Generate random code 2. Check if exists in database 3. If exists, regenerate (retry) 4. If not, use it
Pros: Simple, non-predictable Cons: Collision checking on every create
With 6 billion URLs and 3.5 trillion possible codes, collision probability is ~0.2%. One retry almost always succeeds.
Option 2: Counter + Base62 Encoding
pythondef encode_base62(num): chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" result = [] while num > 0: result.append(chars[num % 62]) num //= 62 return ''.join(reversed(result)) # Counter: 1 → "1", 62 → "10", 1000000 → "4c92"
Flow:
plaintext1. Get next counter value (atomic increment) 2. Encode as base62 3. Store mapping
Pros: No collision checking, deterministic Cons: Predictable (users can guess next URL), requires distributed counter
Option 3: Hash-Based
pythonimport hashlib def generate_from_hash(long_url): hash_value = hashlib.md5(long_url.encode()).hexdigest() return encode_base62(int(hash_value[:12], 16))[:7]
Pros: Same URL always gets same short code (deduplication) Cons: Collisions possible, need to handle
Recommendation: Random generation with retry for simplicity. The collision rate is negligible at our scale.
Database Schema
sqlCREATE TABLE urls ( id BIGSERIAL PRIMARY KEY, short_code VARCHAR(10) UNIQUE NOT NULL, long_url TEXT NOT NULL, created_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP, click_count BIGINT DEFAULT 0 ); -- Index for redirect lookups (most common query) CREATE INDEX idx_urls_short_code ON urls(short_code); -- Index for expiration cleanup CREATE INDEX idx_urls_expires_at ON urls(expires_at) WHERE expires_at IS NOT NULL;
Why PostgreSQL?
- ACID transactions for reliable writes
- 6 billion rows is large but manageable
- Excellent indexing support
- Read replicas when needed
Read Path (Redirect)
This is the hot path—optimize aggressively.
pythondef redirect(short_code): # Step 1: Check cache cached = redis.get(f"url:{short_code}") if cached: if cached == "NOT_FOUND": return 404 increment_click_async(short_code) return redirect_to(cached) # Step 2: Cache miss - query database url = db.query("SELECT long_url, expires_at FROM urls WHERE short_code = %s", short_code) if not url: redis.setex(f"url:{short_code}", 300, "NOT_FOUND") # Cache negative result return 404 if url.expires_at and url.expires_at < now(): return 410 # Gone # Step 3: Populate cache redis.setex(f"url:{short_code}", 3600, url.long_url) # Cache for 1 hour increment_click_async(short_code) return redirect_to(url.long_url)
Optimizations:
- Cache first: Most redirects hit cache
- Cache negative results: Prevent repeated DB queries for invalid codes
- Async click counting: Do not block redirect on analytics
Expected performance:
- Cache hit: 1-5ms
- Cache miss: 10-50ms (database query)
- With 90%+ cache hit rate: P50 < 5ms
Write Path (Create URL)
pythondef create_short_url(long_url, custom_alias=None, expires_in=None): # Step 1: Validate URL if not is_valid_url(long_url): raise ValidationError("Invalid URL") # Step 2: Generate or use custom short code if custom_alias: if db.exists(custom_alias): raise ConflictError("Alias already taken") short_code = custom_alias else: short_code = generate_unique_code() # Step 3: Calculate expiration expires_at = None if expires_in: expires_at = now() + timedelta(seconds=expires_in) # Step 4: Store in database db.insert("INSERT INTO urls (short_code, long_url, expires_at) VALUES (%s, %s, %s)", short_code, long_url, expires_at) # Step 5: Optionally warm cache redis.setex(f"url:{short_code}", 3600, long_url) return short_code def generate_unique_code(): for _ in range(5): # Max 5 retries code = generate_random_code(7) if not db.exists(code): return code raise Exception("Could not generate unique code")
Caching Strategy
| Data | Cache TTL | Rationale |
|---|---|---|
| Valid URLs | 1 hour | URLs rarely change |
| Not found | 5 minutes | Prevent enumeration attacks |
| Expired URLs | 5 minutes | Clean up soon |
Cache invalidation:
- On URL creation: warm cache (optional)
- On URL update: delete cache entry
- On URL expiration: let TTL handle it
Click Counting
Counting clicks on every redirect adds latency. Make it async:
python# Approach 1: Async increment in Redis def increment_click_async(short_code): redis.incr(f"clicks:{short_code}") # Background job: Persist to database periodically def persist_clicks(): for key in redis.scan("clicks:*"): short_code = key.split(":")[1] count = redis.getset(key, 0) # Get and reset db.execute("UPDATE urls SET click_count = click_count + %s WHERE short_code = %s", count, short_code)
Approach 2: Message queue
plaintextRedirect → Publish click event → Queue → Worker → Database
This decouples analytics from redirects completely.
Step 6: Handling Scale
Current Capacity
With the design above:
- 3 API servers handle 8,000 redirects/second easily
- Redis handles 100,000+ ops/second
- PostgreSQL handles 5,000+ queries/second
This is sufficient for our requirements. But what if we 10x?
Scaling to 10x (80,000 redirects/second)
API servers: Add more (linear scaling) Redis:
- Still sufficient (100K+ ops/second per instance)
- Add replicas for redundancy
Database:
- Read replicas for redirect queries
- Primary handles 800 writes/second (still fine)
Scaling to 100x (800,000 redirects/second)
Now we need to think harder:
CDN for redirects: Popular URLs can be cached at CDN edge:
plaintextUser → CDN edge (cache hit) → 301 redirect
This absorbs 80%+ of traffic before it reaches our servers.
Database sharding: At 8,000 writes/second, single primary struggles. Shard by short_code:
plaintexthash(short_code) % 4 → Shard 0-3
But 800,000 reads/second across shards is tricky. Rely heavily on caching.
The reality: Most URL shorteners never reach this scale. Twitter's t.co handles massive scale, but typical services stay in the single-database range.
Step 7: Handling Edge Cases
Hot URLs (Viral Links)
One URL gets millions of clicks. Cache stampede risk.
Solution: Longer TTL for hot URLs, lock on cache miss:
pythondef get_url_with_lock(short_code): cached = redis.get(f"url:{short_code}") if cached: return cached # Try to acquire lock if redis.set(f"lock:{short_code}", "1", nx=True, ex=10): # Won lock, fetch from DB url = db.get_url(short_code) redis.setex(f"url:{short_code}", 3600, url) redis.delete(f"lock:{short_code}") return url else: # Someone else is fetching, wait and retry time.sleep(0.05) return get_url_with_lock(short_code)
Expired URL Cleanup
Expired URLs consume storage. Clean up:
python# Background job running every hour def cleanup_expired_urls(): db.execute(""" DELETE FROM urls WHERE expires_at < NOW() AND expires_at IS NOT NULL LIMIT 10000 """) # Batch delete to avoid long-running transactions
Custom Alias Conflicts
User wants "mycompany" but it exists:
pythonif custom_alias and db.exists(custom_alias): raise ConflictError("Alias already taken")
Consider reserving common words (login, admin, api) to prevent confusion.
URL Validation
Do not just store anything:
pythondef is_valid_url(url): parsed = urlparse(url) return parsed.scheme in ('http', 'https') and parsed.netloc
Block malicious URLs, require http/https, validate domain exists (optional).
Step 8: Security Considerations
Rate Limiting
Prevent abuse:
plaintext- 100 URL creations per IP per hour - 10 requests per second per IP for redirects
Implement with Redis:
pythondef rate_limit(ip, limit, window): key = f"rate:{ip}:{int(time.time() / window)}" count = redis.incr(key) if count == 1: redis.expire(key, window) return count <= limit
URL Enumeration
Short codes should not be predictable. Random generation helps. Also:
- Monitor for scanning patterns
- Block IPs that query many non-existent codes
- Consider CAPTCHAs for creation
Malicious URLs
Users will shorten phishing links. Options:
- Check against URL blacklists (Google Safe Browsing)
- Allow abuse reporting
- Display warning page before redirecting to unknown sites
Final Architecture
plaintext┌────────────────────┐ │ Internet │ └──────────┬─────────┘ │ ┌──────────▼─────────┐ │ CDN │ │ (Popular URLs) │ └──────────┬─────────┘ │ ┌──────────▼─────────┐ │ Load Balancer │ │ (SSL, Health) │ └──────────┬─────────┘ │ ┌────────────────────────┼────────────────────────┐ │ │ │ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │ API Srv 1 │ │ API Srv 2 │ │ API Srv 3 │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ └────────────────────────┼────────────────────────┘ │ ┌────────────────────────────────┼────────────────────────────────┐ │ │ │ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │ Redis │ │ Primary │ │ Replica │ │ (Cache) │ │ DB │ │ DB │ │ │ │ (Writes) │ │ (Reads) │ └───────────┘ └───────────┘ └───────────┘
Cost Estimate (AWS)
| Component | Spec | Monthly Cost |
|---|---|---|
| 3× API Servers | t3.medium | ~$100 |
| Load Balancer | ALB | ~$25 |
| Redis | cache.t3.small | ~$25 |
| PostgreSQL | db.t3.medium | ~$100 |
| Read Replica | db.t3.small | ~$50 |
| Storage (5TB) | gp3 | ~$100 |
| Total | ~$400/month |
Very affordable for a service handling 4,000+ requests/second.
Common Interview Mistakes
Mistake 1: Jumping to sharding
"We will shard the database by short_code."
At 40 writes/second and 4,000 reads/second, a single PostgreSQL instance is more than sufficient. Sharding adds massive complexity for no benefit.
Mistake 2: Over-engineering ID generation
"We will use a distributed ID generator with Zookeeper."
A simple random code with collision check works fine. 7-character base62 gives 3.5 trillion combinations. Collision probability is negligible.
Mistake 3: Forgetting caching
Designing only database access for redirects. At 4,000 reads/second, caching is essential. Most requests should never hit the database.
Mistake 4: Ignoring analytics impact
Making click counting synchronous on the redirect path adds latency. Always make analytics async.
Mistake 5: Not clarifying requirements
Starting to design before understanding scale, features, and constraints. Always ask questions first.
Key Takeaways
-
Clarify before designing. Requirements determine architecture. 100M URLs/month is different from 100B.
-
Simple URL generation works. Random codes with collision checking are sufficient for most scales.
-
Cache aggressively. Redirects are read-heavy. 90%+ should hit cache.
-
Make analytics async. Never block the redirect path on click counting.
-
Start simple, scale as needed. One database, a few app servers, Redis. Add complexity only when required.
-
Consider edge cases. Hot URLs, expiration, abuse prevention—these distinguish good designs.
Extension Challenges
Try extending this design:
-
Analytics dashboard: Show clicks over time, referrers, geography. How would you store and query this data?
-
QR code generation: Return QR code image for any short URL. Where does generation happen?
-
Link preview: Show title and image of destination page. How would you fetch and cache this?
-
A/B testing: Same short URL redirects to different destinations based on percentage. How would you implement?
Conclusion: Part 2 Complete
You have now completed Part 2 of System Design Fundamentals. You understand:
- Networking: How data travels between clients and servers
- Load Balancing: Distributing traffic and handling failures
- Caching: Making systems fast by avoiding repeated work
- Databases: Storing data reliably and querying it efficiently
These building blocks appear in every system you will ever design. In Part 3, we will dive deeper into data—SQL vs NoSQL, partitioning, replication, and the CAP theorem.
Keep practicing. Each design exercise reinforces these concepts until they become second nature.