System designIntermediate

Design a URL Shortener

A service that turns a long URL into a 7-character code and redirects anyone who opens it. Small feature, complete system: it forces the ID-generation decision, a read-heavy cache, the 301-vs-302 analytics tradeoff and a data set that outgrows one database.

Functional requirements

  • Given a long URL, return a short code (https://sho.rt/aZ3kQ9x); optionally a custom alias and an expiry date.
  • Opening the short URL redirects to the original URL.
  • A code must be unique and must not be guessable from the previous one (no enumerating other people’s links).
  • Authenticated users can list, inspect and delete their links.
  • Click analytics per link: total clicks, clicks per day, top referrers and countries.
  • Expired links return 410 Gone; unknown codes return 404.
  • Abuse controls: rate limit link creation, block known-malicious destinations.

Non-functional requirements

Scale, latency, availability and durability targets — these decide the architecture.

  • Redirect latency p99 < 50 ms at the service (the user-visible cost is the redirect itself, not our compute).
  • Availability 99.99% for redirects (52 min/yr); 99.9% for link creation — reads matter more than writes here.
  • Scale: 100M new links/day, read:write ratio 10:1, retained 5 years.
  • Durability: a created link must never be lost — a short URL printed on a poster cannot be re-issued.
  • Codes stay 7 characters for at least 10 years of growth.

Back-of-the-envelope

Numbers first. Every component below has to be justified by one of these.

QuantityValueArithmetic
Write rate≈ 1,160 /s avg, 3,500 /s peak100M new URLs/day ÷ 86,400 s ≈ 1,160 writes/s; peak 3× → ~3,500/s. Any single Postgres handles this.
Read rate≈ 11,600 /s avg, 35k /s peak10:1 read ratio → 11,600 redirects/s; peak 3× → ~35,000/s. A single DB doing 5–10k point reads/s per replica is not enough on its own — this number is why the cache exists.
Storage≈ 18 TB/yr, 91 TB in 5 years100M rows/day × ~500 B (code, URL up to 2 KB but ~300 B median, owner, timestamps) = 50 GB/day × 365 = 18 TB/yr. Past ~10 TB one Postgres is uncomfortable → shard or use a KV store.
Key space62^7 ≈ 3.5 × 10^12Base62 (a–z, A–Z, 0–9), 7 chars = 3.52 trillion codes. At 100M/day that is 35,000 days ≈ 96 years. 6 chars (5.7 × 10^10) would last only 1.5 years.
Cache size≈ 10 GB for the hot set1.16B reads/day, but ~20% of links take ~80% of reads. If 20M distinct codes are hot on a given day × ~500 B = 10 GB — one Redis node, with headroom on a 32 GB box.
Bandwidth≈ 6 MB/s out11,600 redirects/s × ~500 B response (headers + Location) ≈ 5.8 MB/s. Bandwidth is irrelevant; latency and DB reads are the constraints.

Interface

Endpoints, messages or events.

POST /api/links { url, alias?, expires_at? } → 201 { code, short_url }Requires an API key or session. Not naturally idempotent: two identical calls create two codes unless the client sends an Idempotency-Key; we store key → response for 24 h. Validate the URL (scheme http/https, length ≤ 2 KB, not our own domain).
GET /{code} → 302 Location: <url>The hot path. No auth, no body. 302/307 (not 301) so the browser comes back every time and we can count the click — see the 301-vs-302 decision. Unknown → 404, expired → 410.
GET /api/links/{code} → { url, created_at, expires_at, clicks }Owner only. clicks is the eventually consistent counter from analytics, not a live DB count.
GET /api/links?cursor=&limit=50 → { items, next_cursor }The owner’s links, newest first; cursor = (created_at, code), never an offset.
DELETE /api/links/{code} → 204Idempotent: deleting twice returns 204. Must also evict url:{code} from Redis or the redirect keeps working for up to a TTL.
GET /api/links/{code}/stats?from=&to= → { total, by_day[], referrers[], countries[] }Served from the analytics store (daily rollups), not from the links table.

Build it one problem at a time

Each step names the problem first. Decide what you would add before revealing the reference answer.

1
Start: one service, one table
Problem · Two operations — create a code, resolve a code. The simplest thing that meets the requirement is one stateless service and one table keyed by code. Resist drawing ten boxes before there is a measured reason.
Work through every step to unlock the data model, the request walkthrough, scaling, failure modes and the open decisions.