What is it? #
A URL shortener takes a long URL and returns a short one that redirects to it. It is the standard first system design exercise because it is small enough to finish and rich enough to be interesting.
The interesting parts are key generation, the read-heavy access pattern, and what happens when it grows.
Reads outnumber writes by a large factor — roughly a hundred to one is a reasonable assumption. That shapes every decision that follows.
The functional requirements are simple: create a short link, redirect it, optionally set an expiry, and count clicks.
Think of it like this #
A cloakroom ticket. You hand over something long and awkward and receive a short token. Presenting the token returns the original.
The cloakroom must never issue the same ticket twice, must find the matching item instantly, and handles far more lookups than deposits.
Simple example #
Design for 100 million links and 10,000 redirects per second at peak, with links lasting years. Every decision below follows from those numbers.
Code #
1. Requirements
functional shorten a URL, redirect, custom alias, expiry, click count
non-functional redirects under 50ms, 99.9% availability, links never reused
scale 100M links, 1K writes/s, 10K reads/s, read:write ≈ 100:1
storage 100M x ~500 bytes ≈ 50 GB — fits comfortably on one database
2. Key generation — three options
hash the URL md5(url)[:7] deterministic, but collisions need handling
random 7 random base62 chars must check for existing key
counter + base62 encode an incrementing ID no collisions, but sequential
base62 = [0-9a-zA-Z], so 62^7 ≈ 3.5 trillion keys in 7 characters.
Chosen: a counter encoded in base62, with the counter ranges handed out
in blocks to each server so they never collide and never coordinate per write.
Sequential keys are scrambled slightly so they are not trivially enumerable.
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def to_base62(number: int) -> str:
if number == 0:
return ALPHABET[0]
out = []
while number:
number, remainder = divmod(number, 62)
out.append(ALPHABET[remainder])
return "".join(reversed(out))
class KeyAllocator:
"""Each server claims a block of IDs, so no coordination per request."""
BLOCK = 10_000
def __init__(self, counter_store):
self.store = counter_store
self.next_id = 0
self.block_end = 0
def next_key(self) -> str:
if self.next_id >= self.block_end:
start = self.store.increment_by("url_counter", self.BLOCK)
self.next_id, self.block_end = start, start + self.BLOCK
key = to_base62(self.next_id)
self.next_id += 1
return key
3. Storage
links
key VARCHAR(10) PRIMARY KEY
long_url TEXT NOT NULL
user_id BIGINT NULL
created_at TIMESTAMP
expires_at TIMESTAMP NULL
clicks (append-only, written asynchronously)
key, clicked_at, country, referrer, user_agent_family
Key lookup is the only read pattern that matters, so any store with fast
key lookup works. A relational database is fine at this scale; a key-value
store is the natural choice beyond it.
# 4. The redirect path — the hot path, optimised hard
def redirect(key: str):
cached = cache.get(f"u:{key}") # ~95% hit rate expected
if cached == "MISSING":
return response(404)
if cached:
record_click_async(key) # never block the redirect
return response(301, headers={"Location": cached})
link = links.find(key)
if link is None or link.is_expired():
cache.setex(f"u:{key}", 60, "MISSING") # cache negatives, briefly
return response(404)
cache.setex(f"u:{key}", 86400, link.long_url)
record_click_async(key)
return response(302, headers={"Location": link.long_url})
How it works #
The estimates come first because they determine everything. 50 GB of data and 10,000 reads per second is comfortably within one database with a cache — which means this system does not need sharding, and saying so is part of a good design.
Counter-based keys avoid collision handling entirely. Handing each server a block of 10,000 IDs means one coordination round trip per 10,000 links instead of one per link.
The cache carries the read load. With a 95% hit rate, the database sees 500 reads per second rather than 10,000.
Caching negative results matters more than it looks. Bots probe random keys constantly, and without negative caching every one of those probes becomes a database query.
Click recording is asynchronous — pushed to a queue or a buffered counter. Writing a row per click synchronously would make the redirect slower and couple its availability to the analytics path.
The status code choice is a real trade-off. A 301 is cached by browsers, making later redirects instant and free, but you then stop seeing those clicks and cannot change the destination. A 302 keeps control and analytics. Most shorteners choose 302 for that reason.
If this needed to grow further, the next steps would be read replicas, then partitioning by key prefix, and a CDN or edge function performing the redirect close to the user.
Real-world use #
The hardest problems in a real shortener are not technical. Abuse is: short links are used to disguise malicious destinations, so scanning target URLs against threat lists and supporting takedowns is essential.
Custom aliases add a uniqueness constraint and a reservation problem, plus a policy for offensive or trademarked names.
Analytics is usually the product. Click counts, geography, referrers and device breakdown are what users pay for, and they are what makes the write path non-trivial.
Link permanence is a commitment. Once links are shared, they must keep working for years, which makes the storage decision long-lived.
As a design exercise, the answer that demonstrates judgement is recognising the scale honestly: at these numbers, one database, one cache and a stateless application tier is correct, and proposing a large distributed system would be over-engineering.
Common mistakes #
- Jumping to a distributed design without estimating that the data fits on one machine.
- Coordinating a counter per write instead of allocating blocks.
- Not caching misses, so bot traffic hits the database directly.
- Writing a click row synchronously in the redirect path.
- Using 301 redirects and then wondering why click counts are missing.
Practice #
Extend this design with custom aliases and link expiry. Specify how you prevent two users claiming the same alias, how expired links are cleaned up, and what the redirect path does for an expired link. Then estimate the storage and traffic if the system grew tenfold.