Courses › System Design › Question Breakdowns
Design a URL Shortener
Short code generation (counters vs. a key service), redirects at scale, caching, partitioning and analytics.
A URL shortener like Bitly turns a long URL into a short one (short.ly/aZ3kQ9x) and redirects anyone who visits it. It's a classic first system design question: the functionality is simple, so the interview is all about scale, uniqueness and latency.
Requirements
Functional
- Users can submit a long URL and receive a short URL
- Optionally with a custom alias (
short.ly/my-launch) - Optionally with an expiration time
- Users visiting a short URL are redirected to the original
Out of scope: user accounts and dashboards, link analytics UI, spam detection.
Non-functional
- Short codes must be unique — one code maps to exactly one URL
- Redirects should be fast: < 100ms
- Highly available for redirects (availability > consistency — a newly created link taking a second to work is acceptable)
- Scale: 100M new URLs per month, ~1B redirects per day (read-heavy, ~100:1)
[!TIP] The estimate that matters 1B redirects/day ≈ 12k/second average, maybe 50k/second at peak. That's what drives the caching decision; storage (a few TB over years) fits comfortably in a single database.
Capacity estimates
Clicks pile onto new and viral links, while most old links are almost never visited again. That skew is what lets a cache this small absorb most of the traffic.
Core Entities
- URL mapping — short code, long URL, created at, expires at, owner (optional)
API
POST /v1/urls
{ "longUrl": "https://example.com/very/long/path", "alias": "my-launch", "expiresAt": "2027-01-01" }
→ 201 { "shortUrl": "https://short.ly/my-launch" }
GET /{shortCode}
→ 302 Location: https://example.com/very/long/path
DELETE /v1/urls/{shortCode}
→ 204Preventing abuse: creation is the endpoint attackers want, because a trusted short domain hides phishing pages well. Authenticate creators, rate-limit link creation per account, and check each destination against a malware and phishing blocklist (such as Google Safe Browsing) before issuing a code. Links reported later can be disabled without deleting the row.
High-Level Design
user = client "Client" @0,1
lb = service "Load Balancer" @1,1
create = service "URL Service\n(create)" @2,0
redirect = service "Redirect Service" @2,2
kgs = cache "Code Pool\n(Redis set)" @3,0
db = store "URL Database" @3,1
cache = cache "Redis Cache" @3,2
user -> lb
lb -> create, redirect
create -> kgs : claim codes
create -> db
redirect -> cache : 1. lookup
redirect -> db : 2. on missCreate: validate the URL, generate a short code (or check the alias is free), insert the mapping, return the short URL.
Redirect: look up the code; if found and not expired, return a redirect, otherwise 404.
301 or 302?
- 301 (permanent) — browsers cache the redirect and skip your servers on later visits. Less load, but you lose visibility into clicks and can't change or expire links reliably.
- 302 (temporary) — every visit hits your service. Choose this if you want analytics or expiration. This is usually the answer.
Deep Dives
1. Generating unique short codes
Option A — hash the URL. Take a hash (e.g. MD5) of the long URL, base62-encode it, keep the first 7 characters. Collisions are possible and must be checked and resolved (append a salt and retry). Same URL → same code, which may or may not be desired.
Option B — random codes. Generate 7 random base62 characters and insert with a unique constraint; retry on collision. With 62⁷ ≈ 3.5 trillion possibilities, collisions are rare early on but grow as the space fills.
Option C — a counter, base62-encoded. *(Recommended.)* A global counter guarantees uniqueness with no collision checks:
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def encode(n: int) -> str:
s = []
while n:
n, r = divmod(n, 62)
s.append(ALPHABET[r])
return "".join(reversed(s)) or "0"The counter itself can be Redis INCR (fast, atomic). To avoid a round trip per URL and a single point of failure, each URL-service instance grabs a block of IDs (e.g. 1,000 at a time) and hands them out locally.
[!WARNING] Predictable codes Sequential IDs make codes guessable, so someone could enumerate every link. If that matters, run the counter through a reversible scrambling function (or a block cipher) before encoding.
Option D: a pool of pre-generated codes. A background job keeps a pool of random 7-character codes topped up, for example a Redis set, and creation servers claim codes with SPOP key 500, which is atomic, so two servers can never draw the same code. Codes are unguessable and nothing is checked on the hot path.
- Pool size: a year of demand is 1.2B codes × ~8 bytes ≈ 10 GB. Alert when the pool drops below a few days of demand, and refill it from a job that checks each candidate against the URL table.
- Crashes: codes claimed by a server that dies are simply never used. With 62⁷ ≈ 3.5 trillion codes, that waste doesn't matter.
- Availability: each server keeps its claimed batch in memory, so creation keeps working through a short outage of the pool.
Custom aliases skip code generation: validate the characters, block reserved words that collide with your own routes (api, admin, login), and let the unique constraint reject an alias that's already taken.
2. Making redirects fast
Redirects are the hot path. Put a Redis cache in front of the database (cache-aside, keyed by short code). Link popularity is very skewed, so a modest cache gives a high hit rate. Add read replicas for cache misses, and optionally run redirect logic at the CDN edge for the hottest links.
- Sizing: size from the hit-rate curve, not a rule of thumb. Last week's clicked links (~25 GB) is a good first guess; grow the cache only while extra memory still buys a visibly higher hit rate.
- Eviction: LRU works; LFU keeps a viral link from being pushed out by a burst of one-off clicks.
- On a miss: read from a replica and fill the cache with a TTL. Collapse concurrent misses for the same code into one database read (single-flight), so a link that goes viral before it's cached doesn't stampede the database.
user = client "Browser" @0,0
r = service "Redirect Service" @1.3,0
cache = cache "Cache" @2.6,0
db = store "URL Database" @2.6,1
user -> r : GET /aZ3kQ9x
r -> cache : 1. get
r -> db : 2. miss: readOn a miss the service also writes the result back to the cache, then answers with a 302 (or a 404 if the code doesn't exist or has expired).
The redirect servers hold no state, so they sit behind the load balancer and autoscale on request rate. A redirect is cheap enough that a few dozen servers cover the peak.
3. Scaling
- Separate read (redirect) and write (create) services so they scale independently.
- The database: ~6B rows over five years at ~500 bytes ≈ 3 TB. A single Postgres primary with replicas works; if needed, shard by short code (hash-based) — every lookup is by short code, so no cross-shard queries.
Which database? Every read is a point lookup by code, and rows are written once and rarely changed, so almost any store works. A managed key-value store (DynamoDB) gives horizontal scale and multi-region replication with no operations work. Postgres gives you constraints for aliases and is simpler if the team already runs it; it needs sharding sooner.
How to partition: by a hash of the short code. Two tempting alternatives backfire: partitioning by creation date puts every new write and most hot reads on the newest partition, and partitioning by customer turns your biggest customer into a hot shard.
4. Expiration
Store expires_at and check it on every redirect, answering 410 Gone once a link has expired. Cache entries get a TTL no later than the link's expiry.
- Physical deletion is housekeeping: DynamoDB's built-in TTL does it for free; on Postgres, delete in small batches using an index on
expires_at. - Never reuse a code. An old code can still sit in someone's bookmarks, emails or printed flyers, and reusing it would send those people to a stranger's link.
5. Analytics without slowing redirects
Interviewers often ask: "How many clicks did a link get, and from where?" Updating a counter column on every redirect turns a viral link into a hot row that every request locks.
Instead, the redirect service emits a click event (code, time, country, referrer, user agent) to a stream like Kafka and returns immediately. Consumers aggregate clicks in batches into an analytics store. The redirect stays fast, and counts lag by seconds, which is fine.
6. Private links
Enterprise plans often want links that only work inside the company. Store an access field per link (public or org:{id}); the redirect service checks the visitor's session against it. Answer 404 rather than 403 to outsiders, so the link's existence isn't leaked, and send Cache-Control: private so no CDN caches it.
What's expected at each level
- Mid-level: a working design with unique code generation and a cache, plus a sensible 301/302 choice.
- Senior: compares code-generation approaches with trade-offs, handles the counter as a bottleneck, quantifies caching.
- Staff: goes deeper on availability (multi-region counters, edge redirects), abuse prevention and analytics without slowing the redirect path, and justifies the storage choice and partitioning scheme from the estimates.