A URL shortener converts long URLs into shorter aliases that redirect to the original. Services like Bit.ly and TinyURL have made this a classic system design interview question—it's accessible enough for junior candidates yet offers depth for senior discussions.
This walkthrough follows the Interview Framework and focuses on what you'd actually present in a 45-60 minute interview.
Before touching the whiteboard, clarify exactly what you're building. A URL shortener sounds simple, but the requirements significantly impact the design.
Frame these as user capabilities:
Create short URL — Users submit a long URL and receive a shortened version
Redirect — Users visit a short URL and are redirected to the original
Custom aliases (optional) — Users can specify their own short code (e.g., short.ly/my-link)
Expiration (optional) — URLs expire after a set time
Keep it to 3-4 core features. Analytics, user accounts, and link editing are "below the line"—acknowledge them but defer unless the interviewer specifically asks.
These define how well the system performs:
| Requirement | Target | Rationale |
|---|---|---|
| Scale | 100M new URLs/month | Typical for a major service |
| Read:Write Ratio | 100:1 | URLs are created once, clicked many times |
| Latency | < 100ms redirects | Users expect instant redirects |
| Availability | 99.9%+ | Broken short links damage trust |
| Uniqueness | Guaranteed | Each short code maps to exactly one URL |
Ask clarifying questions:
"Should we prioritize availability over consistency?" — Is eventual consistency acceptable? A newly created URL might take a moment to propagate to read replicas. (Usually yes—prioritize uptime over perfect read-after-write consistency)
"Do we need the short URLs to be unpredictable?" — Can someone guess other short codes by incrementing? (Security concern if links point to private content)
Do a quick back-of-envelope calculation to inform your design decisions:
Given: 100M new URLs/month, 100:1 read/write ratio
Writes (URL creation):
100M / (30 days × 24 hours × 3600 sec) ≈ 40 URLs/second
Reads (redirects):
40 × 100 = 4,000 redirects/second
Storage (5-year retention):
100M URLs/month × 12 months × 5 years = 6 billion URLs
Average record size: ~500 bytes (short code + long URL + metadata)
Total: 6B × 500 bytes ≈ 3 TB
3 TB fits comfortably on a single modern database server with no sharding needed. And 4,000 reads/second is well within PostgreSQL's capability with proper indexing—add a Redis cache and you'll handle 10x that easily.
Short code length calculation:
Using Base62 (a-z, A-Z, 0-9 = 62 characters):
6 characters: 62⁶ ≈ 57 billion combinations
7 characters: 62⁷ ≈ 3.5 trillion combinations
7 characters is more than sufficient for our scale and leaves room for growth.
Identify the key entities before jumping into APIs. This establishes shared vocabulary with your interviewer.
URL
├── short_code (string, 7 chars) - Primary key for lookups
├── long_url (string, 2048 chars) - Original URL
├── created_at (timestamp)
├── expires_at (timestamp, optional)
└── user_id (string, optional) - If tracking creators
User (optional)
├── id
├── email
└── api_key
Keep the data model minimal initially. You can add fields like click_count or last_accessed later during the scaling discussion. In an interview, saying "I'll start simple and add analytics fields if we have time" shows good prioritization.
Define the contract between clients and your system. REST is the right choice here—it maps naturally to our CRUD operations.
POST /api/v1/urls
Content-Type: application/json
{
"long_url": "https://example.com/very/long/path/to/resource",
"custom_alias": "my-link", // optional
"expires_in": 86400 // optional, seconds
}
Response: 201 Created
{
"short_url": "https://short.ly/abc1234",
"expires_at": "2024-12-31T23:59:59Z"
}
GET /{short_code}
Response: 302 Found
Location: https://example.com/very/long/path/to/resource
301 vs 302: Which redirect?
When your server returns a redirect, the HTTP status code tells the browser how to handle future visits:
301 (Moved Permanently):
User visits short.ly/abc123 → Server returns 301 → Browser caches this
Next visit: Browser skips your server entirely, goes straight to destination
The browser remembers the redirect and never asks your server again (until cache expires or is cleared).
302 (Found / Temporary Redirect):
User visits short.ly/abc123 → Server returns 302 → Browser does NOT cache
Next visit: Browser asks your server again → Server redirects again
Every click goes through your server first.
| Code | Browser Caches? | Server Sees Every Click? | Best For |
|---|---|---|---|
| 301 | Yes | No | Static redirects where performance matters most |
| 302 | No | Yes | Dynamic links, analytics, expiring URLs |
Use 302 for a URL shortener. It lets us:
Track click analytics (every click hits our server)
Update the destination URL later
Expire or disable links
Block malicious destinations if needed
This is the core of your interview. Start with a working design, then evolve it.
Start with the simplest design that satisfies requirements:
Client
Load Balancer
API Server
Database
Cache
Components:
Load Balancer — Distributes traffic across API servers
API Servers — Stateless servers handling business logic
Database — Stores URL mappings (PostgreSQL or DynamoDB)
Cache — Redis for frequently accessed URLs
Walk through the data flow as you draw:
"When a user submits a long URL, the request hits our load balancer, routes to an API server. The server generates a unique short code, stores the mapping in our database, and returns the short URL."
Client sends POST /api/v1/urls with long_url
API Server validates the URL format
Generate unique short_code (we'll discuss how shortly)
Store mapping: short_code → long_url in database
Return short URL to client
"For redirects—the hot path—user visits short.ly/abc1234. We first check the cache. If it's a hit, we redirect immediately. On cache miss, we query the database, cache the result, then redirect."
Client sends GET /abc1234
Check cache for short_code
- Cache hit: Return 302 redirect immediately
- Cache miss: Continue to step 3
Query database for long_url
If found and not expired:
- Cache the mapping
- Return 302 redirect
Narrating the data flow as you draw keeps the interviewer engaged and demonstrates you understand how the pieces connect—not just what they are.
If the user provides a custom alias (e.g., my-link), skip the generation step entirely:
Check if the alias already exists in the database
If available, store the custom alias as the short_code
If taken, return an error (don't auto-generate a fallback—the user wanted that specific alias)
This works seamlessly with any ID generation approach since custom aliases bypass the generator entirely.
This is the most interesting design decision. There are three main approaches:
Approach 1: Hash the Long URL
Take a hash (MD5/SHA256) of the long URL and use the first 7 characters.
hash("https://example.com/long/path") → "a1b2c3d..."
short_code = "a1b2c3d"
Pros:
Same long URL always produces same short code (deduplication)
No coordination needed between servers
Cons:
Collisions: Different URLs might hash to same prefix
Must check database and retry with different hash on collision
Collision resolution adds latency and complexity
Collision handling: If short_code exists, append a counter to the original URL and rehash:
hash("https://example.com/long/path" + "1") → new hash
You can use a Bloom filter to quickly check if a code might exist before hitting the database—reducing collision-check latency.
Approach 2: Counter + Base62 Encoding (Recommended)
Use a global counter to generate unique numeric IDs, then convert to Base62.
counter = 1000000001
base62_encode(1000000001) → "abc1234"
Pros:
Guaranteed uniqueness — No collision handling needed
Simple and predictable
Compact codes (6-7 characters for billions of URLs)
Cons:
Sequential IDs are predictable (security concern)
Counter is a single point of failure
Distributed coordination needed at scale
Making it unpredictable: If security matters, shuffle the bits or XOR with a secret before encoding. This maintains uniqueness while obscuring the sequence.
Approach 3: Pre-generated Key Pool
Generate random short codes in advance and store in a "key pool" database. When creating a URL, grab a key from the pool.
Pros:
No runtime generation overhead
Naturally unpredictable
Cons:
Need to manage key pool (ensure it doesn't run out)
Additional database for keys
Coordination for distributed key distribution
| Approach | Best For | Avoid When |
|---|---|---|
| Hash + Collision | Deduplication matters, distributed generation | High volume (collision rate increases) |
| Counter + Base62 | Simplicity, guaranteed uniqueness | Need unpredictable URLs |
| Pre-generated Pool | High security requirements | Simpler systems |
For most interviews, Counter + Base62 is the sweet spot. It's simple to explain, guarantees uniqueness, and you can address predictability concerns with bit shuffling if asked.
SQL (PostgreSQL) works well here:
Simple schema with one main table
Strong consistency for URL creation
B-tree index on short_code for fast lookups
Mature, well-understood
NoSQL (DynamoDB) also works:
short_code as partition key
Built-in horizontal scaling
Good for read-heavy workloads
Either is fine—pick based on your experience and justify it.
CREATE TABLE urls (
short_code VARCHAR(10) PRIMARY KEY,
long_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
click_count BIGINT DEFAULT 0
);
-- Optional: index for deduplication (only if same URL should return same short code)
-- CREATE INDEX idx_long_url ON urls(long_url);
With a working design in place, address the non-functional requirements and potential bottlenecks.
With a read-heavy workload (100:1 ratio), caching dramatically improves latency and reduces database load.
Cache Strategy:
Cache Key: short_code
Cache Value: long_url
TTL: 24 hours (or until expiration)
Eviction: LRU
Use the cache-aside pattern:
Check cache first
On miss, query database
Populate cache with result
Return to client
Cache sizing:
Top 20% of URLs likely get 80% of traffic
6B URLs × 20% × 500 bytes = 600 GB
A Redis cluster can easily handle this. For even better performance, consider:
Application-level caching — In-memory cache on each API server for ultra-hot URLs
CDN caching — For globally popular links
If the counter becomes a bottleneck or single point of failure:
Range-based distribution: Assign ID ranges to each server.
Server 1: IDs 1-1,000,000
Server 2: IDs 1,000,001-2,000,000
Server 3: IDs 2,000,001-3,000,000
When a server exhausts its range, it requests a new range from a coordinator.
Counter batching: Each server requests batches of IDs (e.g., 1000 at a time) from a centralized Redis counter using atomic increment.
INCRBY url_counter 1000 // Returns new counter value
Server uses the batch locally without network calls per URL.
If a server crashes with unused IDs in its batch, those IDs are "lost" but that's acceptable—we have trillions of combinations. Trade-off: efficiency vs perfect utilization.
Read replicas: Route redirect queries to replicas, writes to primary. With async replication, a newly created URL might not be immediately readable—but the creator can be routed to primary for read-your-writes consistency.
Sharding (if needed): Partition by short_code hash. Since each redirect only needs one lookup by short_code, queries don't span shards.
| Component | Strategy |
|---|---|
| Load Balancer | Multiple LBs with DNS failover |
| API Servers | Stateless, auto-scaling group |
| Database | Primary-replica with automatic failover |
| Cache | Redis Cluster with replication |
| ID Generator | Redis with persistence, or range-based |
Protect against abuse:
Limit URL creation per IP/API key (e.g., 100/minute)
Limit redirects per IP to prevent bot enumeration and server overload
Mention these proactively:
Latency percentiles (p50, p99 for redirects)
Cache hit rate (target > 90%)
Error rates by endpoint
ID range depletion alerts (if using range-based distribution)
Over-engineering early — Don't propose sharding before you've established the scale requires it. Our capacity estimation showed 3 TB fits on one machine. Start simple.
Ignoring collision handling — If you choose the hash approach, you must explain how you handle collisions. "We'll just retry with a different hash" is insufficient—explain how you generate a different hash.
Forgetting the hot path — Redirects are 100x more frequent than creates. Optimize for reads first. Your caching strategy matters more than your database choice.
Not discussing trade-offs — Every decision has trade-offs. 301 vs 302, SQL vs NoSQL, hash vs counter—state what you're gaining and giving up.
Before wrapping up, verify you've covered:
Requirements Phase
3-4 functional requirements identified
Scale, latency, availability clarified
Quick capacity estimation completed
Data Model
Key entities identified with basic attributes
Primary key strategy established (short_code)
API Design
Each requirement maps to an endpoint
Request/response formats defined
Redirect status code choice justified (302)
High-Level Design
Architecture diagram with data flow
Short code generation approach explained
Database choice justified
Scaling & Trade-offs
Caching strategy for read-heavy workload
ID generation scaling addressed
At least one trade-off discussed explicitly
| Aspect | Recommendation | Rationale |
|---|---|---|
| Short code length | 7 characters, Base62 | 3.5 trillion combinations |
| ID generation | Counter + Base62 | Simple, guaranteed unique |
| Database | PostgreSQL or DynamoDB | Either works for this scale |
| Caching | Redis with cache-aside | Essential for read-heavy workload |
| Redirect code | 302 Temporary | Enables analytics and updates |
| Availability | Replicated DB + Redis cluster | No single points of failure |
The URL shortener is an excellent interview question because it appears simple but reveals depth in ID generation, caching strategy, and scalability reasoning. Focus on explaining why you make each decision, not just what you're building.