Chapter 8 · System Design Fundamentals

Design a URL Shortener

A short, satisfying design that reuses the unique-ID ideas from Chapter 7 and turns a long link into seven characters. It is the classic interview warm-up — small enough to finish, deep enough to show how you think about read paths, identifiers, and trust.

Open the companion slides
Reading time ~11 min Prerequisites Ch 7 · Unique IDs, Ch 6 Audio 🔊 Hinglish read-aloud Next Design a Web Crawler

The whole product is two endpoints: one that mints a short code for a long URL, and one that redirects a visitor back to it. Everything interesting hides in the ratios — roughly 100 reads for every write — and in two old questions wearing new clothes: how do we generate an identifier that is unique, short, and hard to guess? You answered most of that in Chapter 7; here we spend it.

Primary source

Alex Xu, System Design Interview (Vol. 1), Chapter 8. Companion deck: slides — jump to any slide with the Slide N chips. Go deeper: the System Design Primer.

What we're building Slide 2

Nail the scope before drawing boxes. The functional surface is tiny — shorten and redirect, with optional custom aliases, expiry, and click counts — but the non-functional requirements are where the design lives. The redirect is the hot path: it must be fast, always up, and read-optimised.

Functional

Shorten a long URL into one rooted at our domain, redirect the short URL to its target, plus optional custom alias, expiration, and basic click analytics.

Non-functional

Read-heavy (~100:1), low-latency redirects, four-nines availability (a dead link breaks every place it was shared), strict uniqueness, and unpredictable codes.

The two request flows Slide 4

Two paths carry the entire system. The write path runs once per link and can afford to be careful; the read path runs a hundred times more often and must be brutally fast. Read the diagram first — the asymmetry is the whole design.

WRITE · once per link Client POST long URL Shortener generate short code links DB store mapping return short URL https://sho.rt/aB3xK9q READ · ~100× more often Client GET short URL Redirect svc look up code cache code → long_url links DB on cache miss 301 / 302 redirect → long URL
Write stores a mapping and returns a short URL; read resolves the code (cache first, DB on miss) and answers with a redirect. The read path is the one you optimise.

Back-of-envelope numbers Slide 3

Pick numbers you can defend, then multiply — the goal is the order of magnitude, not precision. Start from 30 million daily users writing ~3 links each, at a 100:1 read-to-write ratio.

writes/day = 30M × 3 = 90M  →  ~1,000 wps
reads/day = 90M × 100 = 9B  →  ~104,000 rps (peak ~3× → ~310k rps)
storage/5yr = 90M × 365 × 5 × 500B = ~82 TB (with indexes/replicas → ~250 TB)

A hundred thousand redirects per second and multi-petabyte storage over a decade. One Postgres won't carry it — you need a shard plus an aggressive cache, which is exactly what the rest of the design builds.

Public API Slide 4

Two endpoints carry the whole product. Keep the surface small — every extra knob is something to version, document, and defend.

EndpointDoesReturns
POST /api/v1/shortenCreate a short link from long_url (+ optional alias, expiry)201 with short_url · 409 alias taken · 422 blocked target
GET /:short_codeRedirect a browser to the original target302 with Location · 404 unknown · 410 expired
idempotent
same code
same URL, same user
/shorten
rate-limited
per IP & per key
redirect
not gated
it is just a read

Data model Slide 5

One main table does almost everything. Lean on the primary key for the only query that matters on the hot path: lookup by short code. The table is small and key-value-shaped on purpose — joins and per-click analytics live elsewhere.

ColumnTypeNotes
short_codeVARCHAR(10)Primary key. 7 base62 chars in practice.
long_urlTEXTUp to ~2 KB; stored verbatim, not normalised.
owner_idBIGINT, nullFK to users; NULL for anonymous links.
expires_atTIMESTAMP, nullNULL means never. Indexed for the reaper job.
is_blockedBOOLEANSet by the abuse scanner — short-circuits redirect.

Sharded by hash of short_code; a secondary index on owner_id serves a user's "my links" page. Per-click rows go to a separate columnar store, never this hot table.

How many characters do we need? Slide 6

Short codes are drawn from base62 — digits 0-9, lowercase a-z, uppercase A-Z, 62 symbols. Each character carries log₂(62) ≈ 5.95 bits, so n characters give you 62n codes. The job is to pick the smallest n with comfortable headroom for every link we'll ever issue.

base62 alphabet — 62 symbols 0 1 2 3 4 5 6 7 8 9 a b c … z A B C … Z 10 + 26 + 26 = 62 counter id 125,308,411 repeated ÷ 62 8 T m R… 7 base62 characters  →  62⁷ ≈ 3.5 trillion codes at ~33 billion links/year, that is roughly a century of headroom
An integer id becomes a short string by repeated division by 62, reading the remainders as base62 digits. Seven characters reach 627 ≈ 3.5 trillion.
Length nCapacity 62nVerdict
5~916 millionFits today, corners us in a few years.
6~56.8 billionComfortable now; tight over a decade.
7~3.5 trillionOur choice — overkill on purpose.
8~218 trillionOne more character we never need to type.

Don't underbuy: adding a character later is a flag day for every cached link. Trillions of codes is overkill for one product's lifetime — and that is exactly the point.

Approach A — hash & resolve collisions Slide 7

Run the long URL through a cryptographic hash, truncate to ~42 bits, base62-encode into 7 characters, and hope the slot is free. If the row is missing, insert it. If it already maps to the same URL, return it. If it maps to a different URL, that is a collision — perturb the input with a salt and try again, a loop bounded by a small retry cap.

1 · hash
code = base62( SHA-256(url + salt)[:42 bits] )
2 · check
read code → free? insert. same URL? return. different URL? collision
3 · resolve
salt++ → re-hash → retry until a free slot is found

What it gets you

Stateless workers — any server can shorten without coordinating. Re-shortening the same URL naturally returns the same code, so idempotency is free.

What it costs

Every write does a read-before-write to check uniqueness; as the key space fills, the retry rate creeps up. Codes look pseudo-random — good for privacy, awkward to debug.

Approach B — counter + base62 Slide 8

Hand each new link a unique integer id, then base62-encode it. No collisions and no retries, by construction — every id is distinct, so every code is distinct. The id can come from a sharded counter, ticket batches (each worker grabs 1,000 ids at a time), or a Snowflake-style generator straight out of Chapter 7.

Sequential codes are guessable

A bare counter produces adjacent, enumerable codes — a competitor can scrape your neighbouring links and read off your traffic volume, and "private" links become trivially discoverable. Mitigate by pushing the id through a fixed bijection (XOR with a secret, or multiply mod 2n) before base62-encoding, so the output looks random while staying collision-free.

The cost is coordination: the id source is a point of contention you must scale, exactly the tradeoff Chapter 7 explored. The win is simplicity: one write per shorten, zero collision logic.

Approach A vs Approach B Slide 8

The same identifier problem, scored on the things an interviewer pushes on.

DimensionA · hash + collisionB · counter + base62
Collision riskPossible — needs retriesNone — unique by construction
CoordinationNone — stateless workersCentral id source to scale
Writes per shortenRead-before-write (≥1 read)One write
Guessable?No — looks pseudo-randomYes, unless you obfuscate

Rule of thumb: want stateless writers and free idempotency, accept retries → hashing. Want guaranteed uniqueness and one clean write, accept a contended id service → the counter. Pick the failure mode you would rather operate.

Caching the read path Slide 9

Redirects follow a power law — a handful of viral links serve most of the traffic — so push those rows as close to the user as possible. Three tiers absorb the load in turn: the CDN catches the viral tail, Redis catches the warm body, and the DB sees only the cold misses.

CDN at the edge

With a cacheable redirect, a hot short URL is answered at the edge and never touches our servers. Best for the long-lived viral tail.

Redis at the app tier

Hash-partitioned by short_code, storing code → long_url with LRU eviction and a ~24 h TTL — long enough to ride spikes, short enough to pick up edits and revocations.

Negative caching

Cache 404s for a minute or two so scrapers probing random codes can't torch the DB.

Invalidation

When a link is blocked or edited, delete it from Redis and purge the CDN; the TTL is the safety net if either delivery fails.

301 vs 302 — permanent vs temporary Slide 10

Both status codes redirect the browser. The difference is who remembers — and that decides whether you ever see another click. This is the highest-signal tradeoff in the whole design.

Aspect301 · permanent302 · temporary
BrowserCaches the mapping, often forever; later visits skip your server.Treats each visit as fresh; hits your service every time.
Server loadLower — fewer clicks reach you once a link goes viral.Higher — every click touches origin.
AnalyticsYou see roughly the first click, then nothing — counts under-report.You see every click — accurate counts.
Editing / revokePainful — stale caches keep hitting the old target for months.Trivial — the next click sees the new target.

The textbook answer is "302 for shorteners," because analytics, abuse takedowns, and editability matter more than shaving a hop — it is why bit.ly and friends default to 302. The honest answer is "302 unless you have measured server load and decided you'd rather lose analytics than scale the redirect tier."

Keeping bad actors out Slide 11

A shortener is a redirection oracle for the open web — phishing kits, malware drops, and scam funnels all want to hide behind your domain. Defence is a layered pipeline, never a single switch.

Scan on create

On POST /shorten, check the target against Safe Browsing, PhishTank, and internal blocklists; reject known-bad outright.

Scan again, async

Re-scan in the background as feeds update — yesterday's clean URL can be today's compromised host.

Rate-limit creators

Token bucket per IP, key, and user on /shorten only; reads stay unrated so viral content isn't punished.

Reports & interstitials

User reports trip a soft-block; medium-confidence risk shows a "may be unsafe" click-through page before forwarding.

Five principles Slide 12

Read paths win the argument

At 100:1 reads, every microsecond on the redirect dominates total cost. Design the lookup first; the writer can be slower.

Hash vs counter is about coordination

Hashing buys stateless workers and pays in retries; counters buy guaranteed uniqueness and pay in a contended id service.

Cache aggressively, in layers

CDN catches the viral tail, Redis the warm body, the DB only the cold misses — each with its own TTL and invalidation story.

302 unless you've measured

Temporary redirects keep analytics honest and let you revoke abusive links. A 301's load saving is hypothetical until dashboards prove it.

Short codes are public — design for it

Anyone holding the URL can follow it. Don't lean on obscurity; randomise enough that adjacent codes don't reveal each other.

Active recall

Cover the answers. Say each one out loud before you tap to check.

Why is the read path the one to optimise first?
Look at the ratio.
Traffic is ~100 reads per write, so the redirect dominates cost and latency. Design the lookup (cache → DB) first; the writer can be slower.
Why does 7 base62 characters suffice?
62 to a power.
627 ≈ 3.5 trillion codes. At ~33 billion links/year that is roughly a century of headroom — overkill on purpose so you never have to grow the code.
Approach A vs B in one line each.
Collisions vs coordination.
A — hash: stateless, idempotent, but read-before-write and retries on collision. B — counter: one clean write, no collisions, but a contended id source and guessable codes unless obfuscated.
What is the danger of a bare sequential counter?
Adjacency.
Codes are enumerable and guessable — competitors scrape neighbours and read your traffic, and private links leak. Push the id through a bijection before encoding.
Why do shorteners default to 302, not 301?
Who remembers the mapping?
302 makes every click hit your service, so you keep accurate analytics and can revoke or edit links instantly. 301 is cached by browsers, so most clicks never reach you.
Why is abuse defence a pipeline, not a switch?
Targets move.
Scan on create, re-scan async as feeds update, rate-limit creators, take reports, and interstitial the borderline. No single layer catches everything.

Check yourself

Q1 Why do we optimise the redirect path before the shorten path?
Why: Traffic is roughly 100 reads per write, so the redirect dominates total cost and latency — the lookup gets designed first.
Q2 Why are seven base62 characters enough for this service?
Why: 627 ≈ 3.5 trillion codes — at ~33 billion links a year that is roughly a century of headroom, so we never grow the code.
Q3 What does Approach B (counter) buy you over Approach A (hash)?
Why: Distinct integer ids make distinct codes by construction, so there is no collision loop and exactly one write — at the cost of a contended id source.
Q4 What is the privacy risk of a bare sequential counter?
Why: Sequential codes are adjacent, so anyone can scrape neighbours, read off traffic volume, and discover "private" links — obfuscate the id first.
Q5 Why do URL shorteners usually answer with 302 rather than 301?
Why: 302 sends each click to your service, so analytics stay accurate and you can revoke or edit links — a 301 is browser-cached and skips you.