The classic interview question — and a masterclass in caching.
"Design a URL shortener like bit.ly. I paste a long URL, I get a short link, clicking it takes me to the original. How do you generate the codes, handle millions of links, and keep redirects fast?"
A URL shortener is a key-value lookup wearing a trench coat. Shortening = store long URL under an auto-incrementing integer, then write that integer in base62 (0-9, a-z, A-Z) so it looks like a random string. Redirecting = look up the code, reply 302 with the long URL in the Location header. The entire "system design" is what you wrap around that lookup: a cache in front (redirects are 100:1 read-heavy), and a way to mint IDs without collisions.
The interview sentence: "Base62-encode an incrementing ID for the code, 302 redirect for the hop, and cache the hot codes — the write path is trivial, so I spend the design on the read path."
"The short code should be a random hash of the URL." Tempting, wrong-ish. Hashing means handling collisions (two URLs, same hash — now what?), and the same URL submitted twice should arguably get the same code, which hashing gives you but at the cost of a collision-resolution path nobody wants to operate. The industry-standard answer is dumber and better: a counter + base62. Codes are short, unique by construction, and generation is O(1). If you want dedup, hash the URL separately as a lookup key — don't make the hash be the code.
POST /shorten {url} → short code. Same URL twice → same code (dedup).GET /{code} → 302 redirect to the long URL./my-launch.Analogy first: base62 is just writing a number in a 62-letter alphabet instead of a 10-digit one — like writing "255" as "ff" in hex, but with more letters so the string stays tiny.
| Quantity | Assumption | Result |
|---|---|---|
| Code space | 627 for 7-char codes | 3.5 trillion — at 100M URLs/month, lasts ~2,900 years |
| Writes | 100M URLs/month | ~40 writes/sec average — a single Postgres handles this asleep |
| Reads | 10B redirects/month (100:1 ratio) | ~3,800 reads/sec — this is the number the cache exists for |
| Storage, 5 years | 6B URLs × ~500 bytes (code + URL + metadata) | ~3 TB — fits on one big disk; shard only when ops says so |
| Cache sizing | 80/20: 20% of codes get 80% of clicks; 1M hot codes × 500 bytes | ~500 MB — the entire hot set fits in one Redis |
| Bandwidth | 3,800 redirects/sec × ~500 byte responses | ~15 Mbps — redirects are tiny; bandwidth is never the bottleneck |
Takeaway after the math: writes are a rounding error; reads dominate 100:1; the hot set fits in RAM. The design is a cache with a database behind it, not the other way around.
Think of it like a coat check: you hand over your long URL, get a tiny ticket (the code), and redeeming the ticket is a single lookup — with the most popular tickets photocopied at the counter (the cache) so nobody walks to the back room.
flowchart LR
A["User"] --> B["API
shorten + redirect"]
B --> C["ID service
counter / range allocator"]
B --> D[("URL store
code to long URL")]
B --> E[("Cache
hot codes, TTL")]
B --> F["Analytics
async click log"]
G["Browser"] -- "GET /abc123" --> B
B -- "cache hit" --> E
B -- "cache miss" --> D
B -- "302 + Location" --> G
Use 302 Found (temporary), not 301 (permanent). Browsers cache 301s aggressively and indefinitely — if you ever need to change where a code points (abuse takedown, corrected URL), a 301 means the browser never asks you again and keeps going to the old destination. 302 keeps you in control of every click, which is also what makes click analytics possible. The tiny cost: one extra lookup per click, which the cache absorbs.
The ID service hands out integers — from a DB sequence, or in ranges (each app server grabs 1,000 at a time so there's no per-write coordination). The integer is then written in base62: divide by 62 repeatedly, map remainders to 0-9a-zA-Z. ID 125 → 21. ID 1,000,000 → 4c92. Short, unique, collision-free by construction.
sequenceDiagram
participant U as User
participant A as API
participant I as ID service
participant S as URL store
U->>A: POST /shorten {url}
A->>S: hash(url) already stored?
alt duplicate URL
S->>A: existing code 4c92
else new URL
A->>I: next id
I->>A: 1000000
A->>A: base62(1000000) = 4c92
A->>S: store 4c92 to url
end
A->>U: sho.rt/4c92
Every click does: cache lookup → (miss) DB lookup → populate cache → 302. The analytics write goes to a queue, never blocking the redirect — a click counter must never slow down a redirect.
sequenceDiagram
participant B as Browser
participant A as API
participant C as Cache
participant S as URL store
participant Q as Analytics queue
B->>A: GET /4c92
A->>C: get 4c92
alt cache hit
C->>A: long URL
else cache miss
A->>S: lookup 4c92
S->>A: long URL
A->>C: set 4c92, TTL 24h
end
A->>Q: fire-and-forget click event
A->>B: 302 Location: long URL
POST /api/shorten
{ "url": "https://...", "alias?": "my-launch" }
-> { "code": "4c92",
"short_url": "https://sho.rt/4c92" }
GET /{code}
-> 302, Location: <long url>
GET /api/stats/{code}
-> { "clicks": 1234, "created_at": ... }
code (pk)varchar(16)long_urltexturl_hash (unique)for dedup lookupscreated_at / clickscounter (async updated)id_countersingle-row sequence tableCustom aliases live in the same table with a flag — but they need a uniqueness check on write, which is the one place a race can bite (two users claiming /launch at once: unique constraint decides).
| Decision | Option A | Option B | Verdict |
|---|---|---|---|
| Code generation | Counter + base62 — unique by construction | Hash of URL — dedups free, collisions need handling | Counter; hash separately for dedup if you want it |
| Redirect code | 302 — you keep control + analytics | 301 — browsers cache, faster repeat clicks | 302. Control beats a saved lookup |
| ID minting | DB sequence — simple, single choke point | Range allocator / ZooKeeper — distributed, more parts | Sequence until the write rate (40/sec!) says otherwise |
| Analytics | Sync counter increment — simple, slows redirects | Async queue + batch — more infra, zero redirect impact | Async. Never let counting slow down the hop |
/shorten, blocklist checks, one-click takedown that rewrites the target to a warning page (302 makes this instant).62^7 = 3.5 trillion on the board. Interviewers love watching the "will we run out of codes?" worry evaporate.A real miniature of the system: an incrementing ID counter, genuine base62 encoding in JS, an in-memory store + cache layer with TTL, and a click-through redirect walkthrough showing the 302 hop step by step.
no links yet — shorten something above
Base64 includes +, /, and = — characters that are either reserved in URLs or just ugly in a link people read aloud. Base62 (0-9a-zA-Z) is URL-safe with zero escaping, at the cost of slightly longer codes (62 < 64). Nobody has ever complained that their short link was 7 characters instead of 6.5.
Built as a single self-contained file · diagrams render locally, no CDN · back to the takeaway ↑