Design a URL Shortener (TinyURL / bit.ly)
Difficulty: Tier 1 (warm-up) Asked at: Almost everywhere — Tkxel, Systems Ltd, Careem, Amazon, Google phone screens Time budget: 45 min
The classic first system design question. It looks trivial (“it’s just a hash map”) and that’s the trap:
the interviewer is watching whether you clarify, estimate, and reason about a read-heavy system at scale
— not whether you can shorten a string. Master this one and you have the shape of every design round.
Prerequisites: The Framework, Caching, Unique ID Generation
1. Requirements
Always start here. Ask, don’t assume.
Functional:
- Given a long URL, return a short URL (a short unique code).
- Visiting the short URL redirects (HTTP 301/302) to the original long URL.
- Custom aliases — users can optionally choose their own short code (
bit.ly/my-launch).
- Expiration — links can optionally expire after a time.
Non-functional:
- Extremely read-heavy — redirects vastly outnumber creations (100:1 or more). This is the defining
property.
- High availability — a dead redirect is a broken link on someone’s website/email. Favour availability
over strong consistency.
- Low latency — redirects must be fast (< 100 ms); they sit in the critical path of loading a page.
- Short codes — 7 characters is plenty (see estimation).
Out of scope (state this explicitly): click analytics (assume a separate pipeline consumes redirect
logs), user accounts/auth, spam/malware detection (mention it exists in the real world).
🎙️ “Before I design — is analytics in scope, or does a separate system handle it? And do we need custom
aliases?” Clarifying scope up front is the single highest-value move in this round.
2. Back-of-the-envelope estimation
Assume 100 million new URLs / month.
- Writes: 100M / (30 × 86,400) ≈ ~40 writes/sec average. Trivial.
- Reads: at 100:1, ~4,000 reads/sec average; peak ~5× → ~20,000 reads/sec.
- Storage: 100M/month × 12 × 5 years = 6 billion URLs. At ~500 bytes each (long URL + code +
metadata) ≈ 3 TB over 5 years. Fits comfortably in a sharded key-value store.
- Bandwidth: redirects are tiny (a 301 header), so bandwidth is negligible.
Key-space check: how many characters does the code need?
- Base-62 (a–z, A–Z, 0–9). 62⁷ ≈ 3.5 trillion combinations. We need 6 billion. ✅ 7 characters is
ample (62⁶ ≈ 56 billion would also do; 7 gives comfortable headroom).
🚨 The numbers tell the whole story: writes are easy, reads need a cache, storage is modest, 7-char
codes suffice. Every design decision below follows from these numbers.
3. API design
POST /urls
body: { longUrl, customAlias?, expiresAt? }
→ 201 { shortUrl: "https://sho.rt/aB3xK9p" }
GET /{shortCode}
→ 301 Moved Permanently, Location: <longUrl>
(or 302 if you want redirects to hit your server each time — see deep dive)
DELETE /urls/{shortCode} (optional, if users manage links)
301 vs 302: 301 (permanent) lets browsers/CDNs cache the redirect → fewer hits to your server, but you
lose per-click tracking. 302 (temporary) means every click hits you → good for analytics, more load. 🚨
This is a trade-off the interviewer will probe — state it explicitly.
4. Data model
A single table/collection keyed by the short code:
| Field |
Type |
Notes |
short_code |
string (PK) |
7 chars, base-62 |
long_url |
string |
the destination |
created_at |
timestamp |
|
expires_at |
timestamp? |
optional |
creator_id |
string? |
if accounts exist |
Access pattern is purely key → value (short_code → long_url). This is a textbook key-value / NoSQL
use case — no joins, no complex queries. A relational DB works too, but the access pattern doesn’t need it.
5. High-level design
flowchart LR
Client -->|POST /urls| LB[Load Balancer]
Client -->|GET /code| LB
LB --> App1[App Server]
LB --> App2[App Server]
App1 --> Cache[(Cache<br/>Redis)]
App2 --> Cache
Cache -.miss.-> DB[(Key-Value Store<br/>sharded on code)]
App1 --> IDGen[ID / code generator]
Write path: app server generates a code (§6), writes code → longUrl to the DB, returns the short URL.
Read path: app server checks the cache; on hit, return the redirect immediately; on miss, read the DB,
populate the cache, return the redirect. 🚨 The cache is the load-bearing decision — it absorbs the
20K reads/sec so the DB stays quiet.
App servers are stateless → scale horizontally behind the load balancer.
6. Deep dives
6a. How do you generate the short code?
Three approaches — know all three and the trade-offs:
| Approach |
How |
Pro |
Con |
| Hash + truncate |
MD5/SHA the long URL, take 7 base-62 chars |
Stateless, deterministic |
Collisions — must check-and-retry; same URL → same code (may be undesirable) |
| Counter + base-62 |
Global auto-increment, encode the number |
No collisions ever |
Counter is a bottleneck; codes are sequential/guessable |
| Counter with ranges ⭐ |
Each app server grabs a block of IDs (e.g. 1,000) from a central allocator, hands them out locally |
No collisions, no per-write bottleneck |
Codes still roughly sequential; small gaps if a server dies |
Recommended: counter-with-ranges (like a distributed ID generator / Snowflake-style or a Zookeeper/DB range allocator). No collisions, no bottleneck. If
guessability matters, XOR/permute the counter before encoding so codes aren’t obviously sequential.
Custom aliases: check the code isn’t taken (a single DB read on the PK); if free, insert; if taken,
return a 409. Store alongside the same table.
6b. Scaling the read path
At 20K reads/sec, the DB alone would strain. The cache does the heavy lifting:
- Popular links are read constantly → high cache hit rate (often 90%+). The DB sees a fraction of the
traffic.
- Cache eviction: LRU. Hot links stay resident.
- 🚨 Hot key / viral link: one link goes megaviral (a tweet, a news story). A single cache node serving
it can become a bottleneck. Fixes: replicate that key across cache nodes, use client-side/CDN caching of
the 301, or a local in-process cache on app servers. (Hot Keys)
6c. Scaling storage
6 billion rows / 3 TB. Shard the key-value store on short_code (consistent hashing so adding nodes
doesn’t reshuffle everything → Consistent Hashing). Each
lookup is O(1) on the PK. This scales horizontally to any size.
6d. Availability & failure
- DB down: the cache keeps serving popular redirects, so most traffic survives — aligns with favouring
availability. Writes fail temporarily (tolerable — a new link not working for a moment is far better than
existing links breaking).
- Replication: replicate the DB with async replication + failover to a replica. Trade-off: a
just-created link might not be on the replica yet (read-your-writes gap) — acceptable given our NFRs.
- Multi-region: for global low latency, replicate read-only copies to each region; writes go to a
primary region. Redirects (reads) are served locally everywhere.
7. Bottlenecks & scaling further
- DB read load → cache (done). Then read replicas. Then shard.
- Code generator bottleneck → range-based allocation removes the single counter.
- Hot key → key replication / CDN / in-process cache.
- Global latency → CDN + multi-region read replicas; a 301 can even be served from the CDN edge.
- Storage growth → shard on code; expire old links to reclaim space.
8. Trade-off summary
| Decision |
Chosen |
Alternative |
Why |
| Consistency |
Availability (AP) |
Strong consistency |
A dead redirect is worse than a slightly stale one |
| Code generation |
Counter + ranges |
Hash+truncate / single counter |
No collisions, no bottleneck |
| Storage |
Sharded key-value store |
Relational |
Access is pure key → value |
| Redirect |
301 (cacheable) |
302 (trackable) |
Fewer server hits; analytics via async logs |
| Read scaling |
Cache-first |
DB replicas only |
Read-heavy → cache gives the biggest win cheapest |
9. Follow-up questions an interviewer may ask
How do you prevent two users from getting the same code?
With range-based counter generation, codes are globally unique by construction — each server hands out
numbers from a disjoint block, so no two codes ever collide. With hash-and-truncate you'd need a
conditional insert (insert only if the code doesn't exist; on conflict, append a salt and retry). The
range approach avoids the read-before-write entirely, which is why it's preferred at scale.
How do you handle analytics (click counts) without slowing redirects?
Don't do it synchronously on the redirect path — that would add latency and coupling. Instead, emit an
event (short_code, timestamp, referrer, geo) to a message queue / log stream (Kafka) *asynchronously* as a
fire-and-forget after issuing the redirect, and have a separate consumer aggregate counts into an analytics
store. This keeps the redirect fast and decouples analytics failures from the core service. Note the 301-
vs-302 tension: 301 lets browsers cache the redirect, so you *miss* some clicks; if accurate analytics
matter, use 302 so every click hits you (at the cost of more load).
What if someone shortens a malicious URL?
Real shorteners run destination URLs against a safe-browsing/malware blocklist (e.g. Google Safe Browsing)
at creation time and periodically re-scan. You can also rate-limit creation per user/IP to curb abuse, and
support takedown of reported links. This is out of scope for the core design but worth naming to show
real-world awareness.
How would you support link expiration efficiently?
Store `expires_at` on the record. On read, check it and return 404/410 if expired (lazy expiration).
Physically reclaim space with a background job (or a TTL feature of the store, e.g. Redis/DynamoDB TTL)
that deletes expired rows. Lazy-check-on-read guarantees correctness even if the cleanup job lags.
Why not just use a hash map in memory?
3 TB over 5 years doesn't fit in one machine's memory, and an in-memory-only store loses everything on
restart. You need durable, sharded storage. The cache *is* effectively an in-memory hash map — but it's a
front for a durable, horizontally-scalable backing store, not a replacement for it.
10. What junior / mid / senior answers look like
- Junior: produces a working design — generate a code, store it, redirect. May jump to “use a hash”
without clarifying, may forget the cache, may not estimate. Gets the happy path right.
- Mid: clarifies requirements and NFRs, estimates with purpose, adds the cache because it’s read-heavy,
discusses code-generation trade-offs, handles collisions. Solid, complete.
- Senior: all of the above plus names the hot-key problem unprompted, discusses 301-vs-302 and its
analytics implication, reasons about multi-region and the read-your-writes gap under async replication,
and ties every decision back to the AP-over-CP choice made up front. Drives the conversation and knows
what to leave out.
Further reading