System Design
Design: URL Shortener
TinyURL/bit.ly from scratch. The math, the cache, the resolve path, the storage tier.
Design: URL Shortener (TinyURL / bit.ly)
This is the interview question everyone has seen and almost nobody gets right, because the small surface area is a trap. You can describe the whole product in one sentence — take a long URL, give back a short one, redirect when someone clicks it — and that simplicity tempts people into hand-waving the two decisions that actually matter: how you mint the code, and how you serve a redirect 100 times for every URL you store.
Get those two right and everything else falls out of a single fact: the
data is write-once, read-many, and immutable. A short → long
mapping is created exactly once and never changes. That one property is
the whole design. It tells you the resolve path can be cached
aggressively (the answer can't go stale), the storage tier can lean on
read replicas (lag doesn't make an immutable row wrong), and the write
tier is the easy part even though it feels like the scary one.
The data is write-once, read-many, and immutable. A
short → longmapping never changes after creation — so it caches forever and replication lag can never make it wrong. Every other decision falls out of that.
The functional requirements
- Given a long URL, return a short one.
- Given the short URL, redirect to the long one.
- Optional: custom alias, expiration, click analytics.
Two endpoints. POST /shorten and GET /<short>. The second one is the
product — it runs 100x more than the first, and it has a latency budget
of single-digit milliseconds because a human is staring at a blank tab
waiting for the redirect.
Numbers we work with
Before drawing boxes, do the arithmetic. The whole design is downstream of one ratio (read-heavy, write-modest), and you can't size anything until you've seen the numbers.
| Metric | Value | How we got it |
|---|---|---|
| MAU | 1B | interview-scale worst case (bit.ly does ~10B+ lifetime links) |
| DAU | 200M | ~20% of MAU active per day |
| Writes per DAU | 0.5/day | gives ~100M new URLs/day |
| Read ratio | 100 | every URL is clicked many times |
| Reads per day | 10B | clicks |
| Avg write RPS | 1.2K | 100M / 86,400 |
| Peak write RPS | 6K | × 5 burst |
| Avg read RPS | 116K | 10B / 86,400 |
| Peak read RPS | 580K | × 5 |
| Storage per row | ~500 B | URL ~100B + metadata + index |
| Storage / year | 18 TB | 100M × 500B × 365 |
| 5-year storage | ~90 TB raw, ~150 TB with indexes | |
| Hot cache size | ~10 GB | top 1% URLs × 1 KB |
| Egress / month | ~90 TB | trivial for a CDN |
Two sentences carry the whole sizing argument:
Reads: 580K peak RPS, of which 90%+ should never touch your database. The CDN and Redis absorb the head of the distribution; Postgres only sees the cold tail.
Writes: 6K peak. A single primary in front of replicated Postgres handles that without breaking a sweat and without a shred of sharding. The write tier feels like the hard part and is the easy part.
The short-code generation question
You need a 7-character base62 string (a-z A-Z 0-9, so 62^7 ≈ 3.5 trillion possibilities — plenty). Three ways to mint it, each with a
real downside:
- Hash the long URL (MD5, take 7 chars). Cute, and it gives you a free dedup — same URL hashes to same code. But collisions are real at billions of rows (you must check-and-retry on every write), and two different URLs can collide into the same 7 chars, which is a silent correctness bug if you don't check.
- Auto-increment counter + base62. Deterministic, collision-free,
one row per write. The problem is the URLs are sequential and
guessable: if your code is
00000fX, the next one is00000fY, and an attacker can walk the entire keyspace to scrape every link anyone ever shortened. - Distributed counter + base62. A central source (a Postgres sequence with a bump, or a Snowflake-style ID service) hands each API instance a range of IDs — say 1,000 at a time. The instance burns through its range locally with zero coordination, then fetches the next range. No DB round-trip per write, no collisions, no global lock on the hot path.
Option 3 is what production systems run. The range pre-fetch is the trick that matters: it turns "one DB hit per shorten" into "one DB hit per thousand shortens."
# Per API instance — range pre-fetch
class CodeMinter:
def __init__(self):
self.next_id = None
self.range_end = None
def mint(self):
if self.next_id is None or self.next_id >= self.range_end:
# one round-trip every 1,000 codes, not every code
start = db.execute(
"UPDATE counter SET val = val + 1000 RETURNING val - 1000"
)
self.next_id = start
self.range_end = start + 1000
id = self.next_id
self.next_id += 1
return base62_encode(id)
Range pre-fetch means restarting an instance burns its unused range — if it had used 200 of 1,000, the other 800 codes are gone forever. That is completely fine: you have 3.5 trillion codes and you are throwing away thousands, not billions. Do not add logic to recover the gaps. That logic reintroduces the central coordination you pre-fetched to avoid, and it has burned more than one team who optimized the wrong resource.
The resolve path
GET /<short> happens 100x per write and is the latency-critical path.
Three layers, hottest first:
- CDN (CloudFront / Fastly) at the edge. The first fetch of a short
code reaches the origin, which returns a
301withCache-Control: public, max-age=3600. Every fetch of that code in the next hour is served from the edge and never touches your infrastructure. This alone absorbs 80%+ of read traffic for the cost of a header. - Redis — the
short → longmap for the hot tail (top ~1%), 24h TTL, sub-millisecond. This catches the codes that are hot but not hot enough to live in every CDN POP. - Postgres replica — the cold tail, ~20ms. Most codes get clicked a handful of times and live only here.
GET /aB3xK9
-> CDN edge hit? return cached 301 (80%+ of traffic)
-> Redis hit? return 301, sub-ms (most of the rest)
-> Postgres replica: SELECT long FROM urls WHERE short = ?
warm Redis, return 301 (the cold tail)
The replica is safe precisely because the mapping is immutable. Replication lag means a replica might not have a brand-new code yet — but it can never serve a wrong long URL for a code, because the code's target never changes. The worst case is a fresh code 404s for a few hundred milliseconds until lag catches up, which for a link nobody has clicked yet is invisible.
What about analytics?
The naive design — UPDATE clicks SET count = count + 1 on every resolve
— is the single most common way to kill this system. At 580K peak RPS
that is 580K writes/sec hammering counter rows, with every concurrent
click on a hot link contending for the same row's lock. The redirect
path, the thing with the human waiting on it, now blocks on a write
contention storm.
Push the increment off the hot path entirely:
- Each resolve fires a fire-and-forget event
{short, ts, ip}onto [CONCEPT]message-queues (Kafka). - A consumer aggregates per-minute / per-hour counts asynchronously.
- The dashboard reads the aggregate, never the live counter.
You trade dashboard freshness (delayed by a minute) for a resolve path whose latency and throughput are completely unaffected by analytics load. That is a trade every product owner takes the moment you frame it as "redirects stay fast" vs "the click count is real-time."
What about expiration?
Many shortened URLs are ephemeral — campaign links, one-click
confirmations, password resets. A per-row expires_at lets you serve a
410 Gone after the deadline. Reclaim the storage with either a nightly
sweep job or, better, time-partitioned tables you can drop whole
(DROP TABLE urls_2025_03 is instant; DELETE WHERE expires_at < now()
across 18 TB is a self-inflicted outage).
A war story: the day the redirect path died for the click counter
A mid-size shortener — real product, real revenue from analytics —
launched a "live click count" feature because customers asked for it.
The implementation was the obvious one: on every GET /<short>, run
UPDATE links SET clicks = clicks + 1 WHERE id = ? before issuing the
301. It passed code review. It passed staging, where traffic is gentle
and links are spread evenly. It shipped on a Tuesday.
It held for nine days. Then a customer's link went viral — a single
short code pulling tens of thousands of clicks per second. Every one of
those clicks issued an UPDATE against the same row, and Postgres
serializes writes to a row: each update waited for a row lock the
previous one held. The lock queue backed up, then the connection pool
backed up behind the lock queue, and within ninety seconds every
redirect in the system — not just the viral link — was waiting on a
connection that was stuck waiting on a row lock for a link it had
nothing to do with.
The whole product went down. Not the analytics dashboard — the
redirect, the core function, for every customer, because one hot
counter row had eaten the connection pool. They mitigated by ripping the
UPDATE out and serving redirects with the counter disabled, restored
service in about twenty minutes, and spent the next sprint rebuilding
analytics the way it should have been built from day one: fire an event
onto a queue, aggregate it offline, never touch the hot path.
The lesson is the one the analytics section already stated, but it lands differently after you've watched it take down production: on a read-heavy system, anything you do per-read that writes shared state is a loaded gun pointed at your core path. A counter that "obviously" couldn't matter took out an entire product.
The version that's even harder to catch: the counter UPDATE is wrapped
in the same transaction as the redirect's read, "for consistency." Now
a slow counter write doesn't just queue — it holds a transaction open,
pinning a connection and an MVCC snapshot, and your replica's bloat and
lag start climbing for reasons nobody connects to the redirect path for
days.
What breaks: the failure modes of this exact architecture
Every layer that makes this design fast is also a thing that can fail in a way specific to a shortener. Know them before the interviewer (or production) finds them for you.
| Failure mode | What triggers it | What it costs | The fix |
|---|---|---|---|
| Hot-counter contention | per-resolve UPDATE on a viral link | redirect path dies for everyone (the war story) | events to a queue, aggregate offline |
| Cache stampede | a hot key's TTL expires, thousands of misses race to Postgres at once | a latency spike, or a DB knock-out on a truly hot code | singleflight / probabilistic early refresh — see [CONCEPT]caching-patterns |
| Code-mint thundering herd | the central counter is a single row everyone bumps under load | write contention on the mint, not the data | range pre-fetch (mint 1,000 at a time) moves it off the hot path |
| Replica lag 404s | a just-created code clicked before it replicates | a few-hundred-ms 404 on a brand-new link | read-your-writes via primary for the creating session, or accept it |
| Open-redirect abuse | someone shortens https://your-bank.phish/... | your domain becomes a phishing laundromat; blocklisting follows | URL reputation check at shorten time, nofollow, abuse reporting |
| Custom-alias races | two users claim the same vanity alias concurrently | one silently overwrites the other, or a 500 | unique constraint on short + handle the conflict explicitly |
The two that get glossed over in interviews are open-redirect abuse and custom-alias races. The first is a security and reputation problem — a shortener is, by construction, a tool for hiding a destination, which is exactly what phishers want. The second is a tiny concurrency bug with a unique constraint as the fix, but you have to name that you'll catch the constraint violation and return a clean "alias taken," not let it 500.
What we'd add at 10x scale
- Shard the write tier by short-code prefix. A single primary tops out around 50K writes/sec; sharding spreads writes across N primaries keyed on the code.
- A standalone code-mint service so writes don't hit Postgres for the ID at all — only for the row.
- DynamoDB / Cassandra for the storage tier if you want write-side horizontal scale without the operational tax of sharded Postgres.
- Geo-distributed Redis with read-replicas per region so most reads are served from the closest cache, not a cross-ocean round trip.
What we'd cut at 1/10x scale
Drop the cache — read replicas handle 60K RPS without help. Drop the CDN — serve 301s from the app directly. Drop the queue — inline the click counter, because at 1/10x scale the war story never triggers (the counter contention only bites when a single key gets hot, and at low scale it won't). Engineering is knowing which of these to remove, not just which to add.
[CONCEPT]caching-patterns · [CONCEPT]rate-limiting · [CONCEPT]replication · [CONCEPT]message-queues all show up in this design.