System Design Interview: Designing a URL Shortener
A complete system design walkthrough for a URL shortener like bit.ly — requirements, estimation, API, ID generation, database choice, caching, redirects, analytics and scaling.
"Design a URL shortener" is one of the most common system design interview questions, and a great practice problem. It's simple enough to finish in 45 minutes but touches the core topics: estimation, ID generation, storage, caching, read-heavy scaling and analytics. Here's a complete walkthrough using the approach from system design fundamentals.
Step 1: Requirements
Functional
- Given a long URL, create a short URL (
https://sho.rt/aZ3kQ9x). - Visiting the short URL redirects to the long URL.
- Optional: custom aliases, expiration dates, click analytics, user accounts.
Non-functional
- Very low redirect latency. Redirects sit in the user's critical path.
- High availability for redirects. Broken links are highly visible.
- Durability. Links must never be lost.
- Short codes should be hard to guess if links may be private.
- Read-heavy: far more redirects than creations.
Step 2: Estimates
Assume:
- 100 million new URLs per month → ~40 creations/second average.
- A read:write ratio of 100:1 → ~4,000 redirects/second average, with peaks around 10–20k/s.
- 5 years of retention → 100M × 12 × 5 = 6 billion URLs.
500 bytes per record (long URL + metadata) → **3 TB** of storage.
Conclusions: storage is modest, writes are modest, and reads dominate, so caching is key. See capacity planning for the estimation method.
Step 3: API
httpPOST /api/v1/links
{"long_url": "https://example.com/some/very/long/path", "custom_alias": null, "expires_at": null}
→ 201 {"code": "aZ3kQ9x", "short_url": "https://sho.rt/aZ3kQ9x"}
GET /{code}
→ 301/302 Location: https://example.com/some/very/long/path
GET /api/v1/links/{code}/stats
→ {"clicks": 1532, "by_country": {...}, "by_day": [...]}
301 vs 302: a 301 (permanent) lets browsers and intermediaries cache the redirect, which reduces load but means you may not see every click. A 302 (temporary) sends every click through your servers, which is better for analytics and for changing destinations. Many shorteners use 302 or 307 for this reason.
Step 4: How long should codes be?
Using Base62 (a–z, A–Z, 0–9):
text62^6 ≈ 56.8 billion
62^7 ≈ 3.5 trillion
Seven characters give ample room for 6 billion URLs, with space left over for sparse, hard-to-guess codes.
Step 5: Generating unique codes
This is the heart of the problem. Options:
Option A: Hash the long URL
Take a hash (such as SHA-256) of the URL, Base62-encode it and use the first 7 characters.
- ✅ The same URL always maps to the same code (natural deduplication, if desired).
- ❌ Collisions: different URLs can share a 7-character prefix, so you must detect them and retry with a salt.
Option B: Auto-increment counter + Base62
Use a database sequence: ID 125,000,001 → Base62 → 8rS3x.
- ✅ No collisions; simple.
- ❌ Predictable, enumerable codes: anyone can iterate through all links. That's a problem for private links.
- ❌ A single sequence can become a coordination bottleneck at very high scale (mitigated by allocating ID ranges to servers).
Option C: Random codes with a uniqueness check
Generate 7 random Base62 characters and insert with a unique constraint; retry on conflict.
- ✅ Unpredictable codes.
- ✅ With a huge keyspace relative to the number of links, collisions are rare, and a retry is cheap.
Option D: Pre-generated key service
A background service generates unique random codes in advance and hands out batches to application servers.
- ✅ Fast creation, no collision handling in the hot path.
- ❌ More moving parts.
Recommendation: random codes (option C) with a unique constraint are simple, unguessable and scale well. Move to range allocation or a key service only if creation volume demands it.
pythonimport secrets, string
ALPHABET = string.ascii_letters + string.digits
def new_code(length=7) -> str:
return "".join(secrets.choice(ALPHABET) for _ in range(length))
def create_link(long_url: str) -> str:
for _ in range(5):
code = new_code()
if db.insert_if_absent("links", code=code, long_url=long_url):
return code
raise RuntimeError("could not allocate code")
Step 6: Storage
The access pattern is a key-value lookup by code, with a simple schema:
sqlCREATE TABLE links (
code VARCHAR(10) PRIMARY KEY,
long_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
- A relational database handles this comfortably at the estimated scale, especially with read replicas and caching.
- A distributed key-value store is a natural fit if you expect massive scale and want built-in horizontal partitioning.
Either works; justify the choice by access patterns and operational simplicity (SQL vs NoSQL). If you shard later, shard by code (hash-based), since every redirect looks up by code (database scaling: replication and sharding).
Step 7: The redirect path (make it fast)
textBrowser ─▶ CDN/edge ─▶ Load balancer ─▶ Redirect service ─▶ Cache (Redis) ─miss─▶ DB
- Cache
code → long_urlin Redis with a long TTL. Link popularity follows a power law, so a small cache covers most traffic (caching strategies). - Optionally cache redirects at the CDN edge for extremely popular links, if analytics can be collected at the edge.
- Keep the redirect service stateless and horizontally scaled (how load balancers work).
- Cache negative lookups (unknown codes) briefly, to blunt enumeration attacks.
Step 8: Analytics without slowing redirects
Don't write to the database synchronously on each click. Instead:
textRedirect service ──(async event)──▶ Queue/stream ──▶ Analytics consumers ──▶ Warehouse / counters
Emit a click event (code, timestamp, country from IP, referrer, user agent) to a queue or log (message queues explained), and aggregate asynchronously. The redirect stays fast even if analytics lags.
Step 9: Extra features and edge cases
- Custom aliases: check availability, reserve with a unique constraint, and block offensive or reserved words.
- Expiration: check
expires_aton redirect; clean up expired links in background jobs. - Abuse: malware and phishing links are a real problem for shorteners. Scan destinations against threat-intelligence lists, rate-limit creation (rate limiting algorithms) and provide abuse reporting.
- Deduplication: optionally return the existing code when the same user shortens the same URL again.
Step 10: Reliability
- Replicate the database, with automated failover.
- Multi-AZ deployment for the redirect service and cache.
- If the cache fails, the DB must handle the fallback load, so rate-limit and load-test the cold-cache scenario (load testing guide).
- Back up link data. It's the entire product.
Final architecture
text ┌──────────── Create path ───────────┐
Client ─▶ LB ─▶ API service ─▶ DB (links, unique code)
└──────────── Redirect path ─────────┐
Client ─▶ CDN ─▶ LB ─▶ Redirect service ─▶ Redis ─▶ DB (replicas)
│
└─▶ Click events ─▶ Queue ─▶ Analytics store
FAQ
Why not just use a hash of the URL?
Hash prefixes can collide, so you need collision handling anyway, and identical URLs from different users would share analytics. Random codes with a uniqueness check are simpler and unguessable.
How do you make short links unguessable?
Use cryptographically random codes from a large keyspace (7+ Base62 characters) instead of sequential IDs, and rate-limit lookups for unknown codes.
Should redirects use 301 or 302?
Use 302/307 if you need accurate analytics or might change destinations; use 301 if you want maximum caching and the destination is permanent.
How would you handle 1 million redirects per second?
Aggressive edge caching, a large replicated in-memory cache tier, a horizontally scaled stateless redirect service, a partitioned key-value store for the long tail, and asynchronous analytics ingestion.