S127 · Design a URL Shortener — the Classic Warm-Up
Simple on the surface, deep underneath. Unique ID generation, sharding, redirect latency budget, and the anti-abuse story every serious shortener has to solve.
🎯 Walk through a URL shortener design end-to-end in the 45-minute interview format, and know why every 'obvious' shortcut is a trap.
Why this session exists
The URL shortener is the compulsory warm-up question in every senior systems interview because it looks trivial for 30 seconds and stays interesting for 60 minutes. Every distributed-systems trade-off — unique IDs, sharding, caching, hot-key handling, abuse prevention, analytics fanout — fits inside a problem simple enough to reason about live. Do this one problem well and you have the template for every other design.
- Estimate QPS + storage + short-code space for a URL shortener in 60 seconds.
- Compare counter-based, hash-based, and random-with-collision-check ID strategies and pick one.
- Design a redirect latency budget where 99% of requests never leave the CDN.
- Explain the 301-vs-302 trap and why every real shortener defaults to 302.
- Handle a viral-link cache stampede with singleflight or stale-while-revalidate.
Prerequisites
- S126 · System Design Framework (the four-step method)
- S063 · Caching — cache-aside, write-through, TTL, invalidation
- S070 · Sharding + S066 · Load Balancers
(a) Intuition · 5 min
You hand your coat to the cloakroom attendant and get a small ticket ("A47"). At the end of the night you show A47, get your coat back. The ticket is short, unique, easy to say. The coat is bulky, unwieldy, but stored once.
URL shortening is the exact same trick. Long URL = coat. Short code = ticket. The service is a giant cloakroom for the internet.
Two operations dominate. Write (rare): POST /shorten {url} generates a unique short code (e.g. bit.ly/aX9k2p) and stores (code → long_url). Read (100–1000× more common): GET /aX9k2p looks up the mapping and returns HTTP 301/302.
The interesting choices: how do you generate short codes without collisions at scale? How do you make redirect latency <30ms globally? What do you cache and where?
- ID generation — counter (needs coordination), hash (leaks + collides), or random+collision-check (needs unique index).
- Read latency — every millisecond above ~30 ms costs conversion. CDN edge is non-negotiable at scale.
- Analytics vs redirect — you MUST NOT block the redirect on click tracking. Async queue only.
- 2002TinyURL launchesThe original — random 4–6 char codes, single database, simple redirect. Still runs today.
- 2008bit.ly launchesBuilt for the Twitter era. Analytics, custom domains, enterprise contracts. Redis + Cassandra stack.
- 2010Twitter's t.coEvery tweet URL is wrapped for click tracking + safety scanning. Billions of redirects/day at sub-30 ms p99.
- 2016bit.ly outageRedis primary OOMs; cascades cross-fleet. Millions of shortened links across social + email temporarily break. Fixed with LRU eviction + stale-serve-on-origin-failure.
- 2020Cloudflare Workers KVEdge KV stores make 'redirect from the edge in <10 ms globally' a solved problem.
- 2024Anti-phishing standardEvery serious shortener now integrates Google Safe Browsing / URLhaus at shorten time AND on hot links.
(b) Visual walkthrough · 15 min
Capacity estimation (memorise these numbers)
The two dominant ID-generation designs
The full HLD (fits on one whiteboard)
The analytics path is async — click tracking must never block the redirect.
The redirect latency budget
5–15 ms
- Cloudflare KV / Workers
- Aggressive Cache-Control
- Pre-warm known-hot URLs
20–60 ms
- Redis cluster in each region
- Warm on write + on first read
- LRU eviction to bound memory
150–300 ms
- Point lookup by code (primary key)
- Read replicas per region
- Fallback if Redis is down
Fail loud
- Serve stale from cache if TTL just expired
- Never let clients hang > 3 s — they will retry
- Health check → drain node
Sharding choice + why
Why we shard by code
"Hash the URL and take the first 7 characters of the digest. That gives short, deterministic codes and identical URLs collapse to one entry for free."
Truncating a hash reintroduces the birthday problem at a scale you will actually hit. With base62 and 7 characters the space is about 3.5×1012, and collisions become likely around the square root of that — roughly two million URLs. Every insert must then read before writing to check for a collision, which is exactly the coordination the hash was supposed to avoid.
Because hashing is genuinely the right instinct for deterministic, stateless ID generation, and it works flawlessly in testing where you insert a few thousand rows. The birthday bound is deeply unintuitive — people reason about the chance that a specific new URL collides (tiny) instead of the chance that some pair among n collides (large). Those differ by a factor of n.
Compute the collision probability at your actual scale before committing to a code length:
import math
for L in (6, 7, 8, 10):
space = 62 ** L
for n in (1e6, 1e8, 1e9):
p = 1 - math.exp(-n*n / (2*space)) # birthday approximation
print(f'len={L} n={n:.0e} space={space:.2e} P(collision)={p:.4f}')
# Note how fast it goes to 1. Then compare: a COUNTER encoded in
# base62 uses the space perfectly -- 7 chars addresses 3.5e12 URLs
# with zero collisions, by construction.Why does a counter-based ID generator beat a hash, and why must the counter be distributed rather than a single database sequence?
- 1The requirement is a unique short code per URL, with codes as short as possible.forced by · shortness is the product; uniqueness is correctness
- 2Shortest possible means the encoding must be injective and dense — every code in the space maps to at most one URL, and no code is wasted.forced by · any collision handling implies wasted codes and a read-before-write
- 3A monotonic counter encoded in base62 is exactly a dense injective map: counter n → a unique string, using the space perfectly with zero probability of collision.forced by · base conversion is a bijection between integers and fixed-alphabet strings
- 4But a single counter is a single point of serialisation. Every write in the system must touch it, so its throughput caps the entire service and its failure stops all writes.forced by · a global monotonic sequence requires a total order, and total order requires coordination
- 5The resolution is to partition the counter space: hand each application server a contiguous range (say a million IDs) from a coordination service, and let it allocate locally with no network call.forced by · coordination cost amortises over the block size — one round trip per million IDs instead of per ID
- 6Uniqueness still holds because ranges are disjoint by construction; a server crash merely leaks its unused range, which is free given the size of the space.forced by · disjointness is guaranteed at allocation time, so no runtime coordination is needed
Therefore range-allocated counters give collision-free, dense, short codes with essentially no coordination on the hot path — strictly better than truncated hashes on every axis except deduplication.
And note what this predicts: sequential counters make codes enumerable, so anyone can walk the ID space and discover every link. If that matters — and for a link shortener carrying private documents it does — you must either encrypt the counter with a format-preserving permutation before encoding, or accept enumeration. That is a security requirement flowing directly from the ID scheme, and it is the follow-up question this design invites.
Strip away the framing and this is a hash map with an enormous read:write ratio and a tiny value size. Writes are rare and must be durable and unique; reads are constant, latency-critical, and identical for a given key forever.
Everything in the design follows from that asymmetry: cache aggressively because entries are immutable, optimise the read path to a single lookup, and spend the complexity budget on ID generation because that is the only place writes are hard.
- Immutable values mean cache invalidation is a non-problem — one of the two hard things simply does not apply. Exploit that.
- Read path should be: CDN/edge → cache → single partitioned lookup. Anything more is a design smell.
- Use a 301 vs 302 redirect deliberately: 301 is cached by browsers so subsequent clicks never reach you (cheap), 302 is not (so you keep analytics). This is a real product tradeoff, not a detail.
- Partition by the short code itself. It is uniformly distributed by construction, so there are no hot partitions from key skew — only from viral individual links, which is a caching problem.
Fire this model the moment you see: any read-heavy immutable-data system · a request for globally unique short identifiers · a design where writes and reads have wildly different requirements · a proposal to truncate a hash for an ID.
How are short codes generated — truncated hash, distributed counter, or pre-generated key pool?
Distributed counters for most cases — collision-free, shortest codes, minimal coordination. Add a format-preserving permutation over the counter if enumeration is a concern, which buys unpredictability without giving up density or introducing a second service.
The instinct worth carrying beyond this problem: when a design tempts you toward probabilistic uniqueness, check whether a deterministic partitioning of the ID space achieves the same goal. It usually does, it is usually simpler to reason about, and "we are fairly sure it is unique" is a sentence you do not want in a durability guarantee.
(c) Hands-on · 25 min
A working URL shortener with Flask + Redis + Postgres. Runs locally in a few minutes.
#!/usr/bin/env python3
# shortener.py — a minimal but real URL shortener.
# pip install flask redis psycopg2-binary
# docker run -d -p 6379:6379 redis:7
# docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=pw postgres:16
import os
import string
import psycopg2
import redis
from flask import Flask, abort, jsonify, redirect, request
app = Flask(__name__)
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
pg = psycopg2.connect("dbname=postgres user=postgres password=pw host=localhost")
pg.autocommit = True
with pg.cursor() as c:
c.execute(
"""
CREATE TABLE IF NOT EXISTS urls (
code TEXT PRIMARY KEY,
long_url TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
)
"""
)
ALPHABET = string.ascii_letters + string.digits # 62 chars
BASE = len(ALPHABET)
CACHE_TTL_SEC = 24 * 3600
def encode_base62(n: int) -> str:
if n == 0:
return ALPHABET[0]
out = []
while n > 0:
n, rem = divmod(n, BASE)
out.append(ALPHABET[rem])
return "".join(reversed(out))
def next_id() -> int:
# Real systems: a range-allocation service (each app gets 10k IDs at a time
# from ZooKeeper or a DB sequence) so INCR isn't a single hot key.
return r.incr("url:counter")
@app.post("/shorten")
def shorten():
body = request.get_json(force=True)
long_url = (body or {}).get("url", "").strip()
if not long_url.startswith(("http://", "https://")) or len(long_url) > 2048:
abort(400, description="Invalid URL")
code = encode_base62(next_id())
with pg.cursor() as c:
c.execute(
"INSERT INTO urls (code, long_url) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(code, long_url),
)
r.setex(f"url:{code}", CACHE_TTL_SEC, long_url) # warm the cache on write
return jsonify({"short": request.host_url + code, "code": code})
@app.get("/<code>")
def follow(code):
# 1. Try Redis (hot path — ~1 ms)
cached = r.get(f"url:{code}")
if cached:
r.incr(f"clicks:{code}") # in prod: push to Kafka instead
return redirect(cached, code=302)
# 2. Cache miss → Postgres (rare — ~50 ms)
with pg.cursor() as c:
c.execute("SELECT long_url FROM urls WHERE code = %s", (code,))
row = c.fetchone()
if not row:
abort(404)
long_url = row[0]
# 3. Warm the cache for next time
r.setex(f"url:{code}", CACHE_TTL_SEC, long_url)
r.incr(f"clicks:{code}")
return redirect(long_url, code=302)
@app.get("/_stats/<code>")
def stats(code):
return jsonify({"clicks": int(r.get(f"clicks:{code}") or 0)})
if __name__ == "__main__":
app.run(port=5000, debug=True)What each block is doing
Anatomy of the shortener
Replace next_id() with a range allocator:
_range_lock = threading.Lock()
_range_start, _range_end = 0, 0
def next_id() -> int:
global _range_start, _range_end
with _range_lock:
if _range_start >= _range_end:
end = r.incrby("url:counter", 10_000) # atomic
_range_start, _range_end = end - 10_000 + 1, end
val = _range_start
_range_start += 1
return valNow Redis sees one INCRBY per 10 000 shortenings instead of one INCR per shortening. Benchmark with wrk or ab and observe the QPS jump.
(d) Production reality · 15 min
Redis primary OOMed. Redirects that should have taken 15 ms became 3-second Postgres queries, then timeouts. Half the shortened links on Twitter and marketing emails temporarily returned nothing usable.
Three post-mortem fixes:
- Redis eviction policy set to
allkeys-lru. Nevernoeviction— OOM becomes a full outage. - "Serve stale on origin failure" — if Postgres is down, keep serving the last-known mapping from Redis past its TTL.
- Client-side timeout budget capped so browsers don't hammer during recovery.
Standard playbook:
- Scan every URL against Google Safe Browsing / URLhaus at shorten time AND periodically after.
- Interstitial warning page when Safe Browsing flags a URL.
- One-click reporting endpoint → auto-quarantine after N reports.
- Rate-limit shortening per user + per IP; require account for high volumes.
Common failure modes
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- Name the three ID-generation strategies and pick one with justification.
- What's the redirect latency budget and where do you spend it?
- Why 302 by default, and when is 301 acceptable?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.