Chapter 8 · System Design Interview

Design a URL Shortener

How a service like bit.ly or tinyurl turns a long link into seven characters, hands billions of redirects per day, and keeps it all from being abused — pulled apart layer by layer.

Study Notes · 12 slides
02 / 12
1

What Are We Building?

Nail the scope before drawing boxes. A URL shortener sounds trivial — until you ask how many, how fast, and what happens when the input is hostile.

Functional

  • Shorten — accept a long URL, return a short one rooted at our domain.
  • Redirect — visiting the short URL forwards the browser to the original target.
  • Custom alias — let signed-in users pick a memorable suffix (e.g. /launch) when it's still free.
  • Expiration — optional time-to-live so a link can stop working after a chosen date.
  • Basic analytics — count clicks per short code, broken down by day and country.

Non-functional

  • Latency — redirects under ~50 ms at the edge; this is the hot path.
  • Availability — four nines or better. A broken short link breaks every place it was shared.
  • Read-heavy — roughly 100 reads per write. Optimise the read path first.
  • Uniqueness — no two long URLs ever collide into the same short code.
  • Unpredictability — adjacent codes shouldn't reveal each other; protects privacy of private links.
03 / 12
2

Back-of-Envelope Numbers

Pick numbers you can defend, then multiply. The point is the order of magnitude — does this fit on one box or do we need a fleet?

Daily active users
30 M
our chosen baseline
New links per user · day
3
low intent, occasional use
Read : write ratio
100 : 1
shares get clicked a lot
Row size on disk
~500 B
code + URL + metadata
Queries per second
writes/day = 30M × 3 = 90M
reads/day = 90M × 100 = 9B
avg write QPS = 90M / 86 400 ≈ 1 040 wps
avg read QPS = 9B / 86 400 ≈ 104 000 rps
peak ≈ 3× avg → plan for ~310k rps
Storage at 5 years
rows/year = 90M × 365 ≈ 33B
rows/5yr = 33B × 5 ≈ 165B
bytes = 165B × 500 ≈ 82 TB
+ indexes & replicas → ~250 TB
hot working set ≈ 1% → ~2.5 TB in cache

Read this aloud: a hundred thousand redirects per second, multi-petabyte over a decade. One Postgres won't do it; a shard plus an aggressive cache will.

04 / 12
3

Public API

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

EndpointPurposeRequestResponse
POST /api/v1/shorten Create a short link { "long_url": "https://...", "alias": "launch", "expires_at": "2027-01-01" }
Bearer token if the user wants ownership / analytics; anonymous otherwise.
201 { "short_url": "https://sho.rt/aB3xK9q", "expires_at": "..." }
409 if the alias is taken, 400 on malformed URL, 422 on blocked target.
GET /:short_code Redirect to long URL Path only. The client is a browser following a link.
No auth — short URLs are bearer tokens themselves; whoever holds the link follows it.
302 Found with Location: <long_url>
404 if unknown, 410 Gone if expired, 451 if blocked.

Idempotency

The same long URL submitted by the same user returns the same short code — don't burn a new code per retry.

Versioned path

/api/v1/ lives under a separate hostname from the redirect domain so the short host stays tiny and fast.

Rate limited

POST /shorten is gated per IP and per API key. Redirects are not gated — they're just reads.

05 / 12
4

Data Model

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.

ColumnTypeNotes
short_codeVARCHAR(10)Primary key. 7 base62 chars in practice.
long_urlTEXTUp to ~2 KB; we don't normalise or rewrite.
owner_idBIGINT, nullableFK to users. Null for anonymous links.
created_atTIMESTAMPTZDefaults to now(). Used for analytics & cleanup.
expires_atTIMESTAMPTZ, nullableNULL means never. Indexed for the reaper job.
is_blockedBOOLEANSet by abuse scanner — short-circuits redirect.
click_countBIGINTAsync, eventually consistent. Real numbers in the analytics store.

Sharded by hash of short_code. Secondary index on owner_id for a user's "my links" page. Analytics (per-click rows) live in a separate columnar store, never in this table.

links short_code PK long_url owner_id FK created_at expires_at is_blocked users user_id PK email plan_tier owner clicks (columnar / OLAP) short_code · ts · country · referrer · ua written async via a queue
Hot table is small & key-value-shaped. Joins live elsewhere.
06 / 12
5

How Many Characters Do We Need?

Short codes are drawn from base62: digits 0-9, lowercase a-z, uppercase A-Z. Each character carries log₂(62) ≈ 5.95 bits.

Address space vs. code length 5 chars ≈ 916 million codes 6 chars ≈ 56 billion 7 chars ≈ 3.5 trillion ← our choice 8 chars ≈ 218 trillion
62⁷ ≈ 3.5 × 10¹². At our 33 B links / year, that's roughly a century of headroom.

The arithmetic

n characters → 62ⁿ codes. We need at least one code per link we'll ever issue, plus a safety margin so collisions stay rare under random generation.

Why base62

URL-safe without percent-encoding, case-sensitive (more bits per character than base36), no ambiguous punctuation. Some services strip 0/O and 1/l/I for human-readability and lose a little of that headroom.

Seven feels right

Trillions of codes is overkill for one product's lifetime — and that's the point. We never want to run out, never want to grow the URL, never want users to retype it.

Don't underbuy

Six characters fits today's traffic but corners us in five years. Adding a character later means a flag day for every cached link.

07 / 12
A

Approach A · Hash & Resolve Collisions

Run the long URL through a cryptographic hash, truncate to 7 base62 characters, hope it's free. If it isn't, perturb and try again.

How it goes

SHA-256 the URL, take the first ~42 bits, base62-encode them into 7 characters. Look it up. If the row is missing, insert. If it already maps to the same URL, return it. If it maps to a different URL — collision.

Resolving the clash

Append a salt (random suffix, user-id, attempt counter) to the input and hash again. Retry until you find a free slot. Loop bounded by a small max — practical collisions are exceedingly rare at 42 bits.

What it gets you

Stateless workers — any server can shorten without coordinating with peers. Re-shortening the same URL naturally returns the same code (idempotent without an extra index).

What it costs

Every write does a read first to check uniqueness. At high write rates and a fuller key space, the retry rate creeps up. Codes look pseudo-random — fine for privacy, awkward for debugging.

https://example.com/long-path SHA-256(url + salt) take 42 bits base62 → "kP3xWq2" lookup & insert collision → salt++ → retry
Read-before-write on every shorten. Workers stay independent.
08 / 12
B

Approach B · Counter + Base62

Hand each new link a unique integer ID, then base62-encode it. No collisions, no retries, by construction.

Encoding the counter value 125 308 411 125 308 411 ÷ 62 = 2 021 103 r 5 → '5' 2 021 103 ÷ 62 = 32 598 r 27 → 'R' 32 598 ÷ 62 = 525 r 48 → 'm' 525 ÷ 62 = 8 r 29 → 'T' 8 ÷ 62 = 0 r 8 → '8' read remainders bottom-up 8 T m R 5 → short code "8TmR5"
Pad with leading '0' chars if you want fixed 7-character codes.

How it goes

A central ID service hands out monotonically increasing integers. The shortener encodes the integer in base62 (5–7 chars) and stores the row.

What it gets you

Zero collision logic. One write per shorten. Compact codes — early users get 1–2 character URLs (in theory). Easy to range-scan for batch operations.

What it costs

The ID service is a single point of contention. Solve with sharded counters, ticket batches (each worker fetches 1 000 IDs at a time), or a distributed ID generator like Snowflake.

The leakage problem

Sequential IDs reveal traffic volume and adjacency — competitors can scrape neighbouring codes. Mitigate by hashing the ID through a fixed bijection (e.g. XOR with a secret, multiply mod 2ⁿ) before base62-encoding.

09 / 12
6

Caching The Read Path

Redirects follow a power law — a handful of viral links serve most of the traffic. Push those rows as close to the user as possible.

Browser GET /aB3xK9q CDN edge 301 cached ~10 ms LB redirect svc Redis code → long_url TTL 24 h links DB (sharded) cache miss only populate
Three tiers of cache: CDN → Redis → DB. Each absorbs >90% of what reached it.

CDN at the edge

Frequently-hit short URLs cache the redirect response itself. With a 301 + far-future Cache-Control, the request never touches our servers. Effective for the long-lived viral tail.

Redis at the app tier

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

Negative caching

Cache 404s for a minute or two. Stops scrapers probing random codes from torching the DB.

Invalidation

When a link is blocked or edited, push a delete to Redis and a purge to the CDN. The TTL is the safety net if either delivery fails.

10 / 12
7

301 vs 302 · Permanence vs Visibility

Both redirect the browser. The difference is who remembers — and that decides whether you can ever see another click.

301 Moved Permanently
302 Found (Temporary)
Browser behavior
Caches the mapping for a long time — often forever. Subsequent visits skip our server entirely and go straight to the long URL.
Treats each visit as fresh. The browser hits our redirect service every time before following on.
CDN behavior
Cacheable by default — load drops dramatically once a link goes viral. The CDN serves the redirect itself.
Generally not cached unless explicitly configured. Every click touches origin.
Analytics
You see the first click from a browser and nothing else. Counts under-report repeat traffic by an unknown factor.
You see every click. Counts are accurate. This is why bit.ly and friends default to 302.
Editing the target
Painful. Old browsers will keep hitting the stale URL until their cache expires — possibly months.
Trivial. The next click sees the new target.
Best for
Truly permanent redirects (your own site reshuffles its URLs) — when you actually want browsers to stop asking.
A shortener service, where analytics, abuse takedowns, and editability matter more than shaving a hop.

The textbook answer is "302 for shorteners." The honest answer is "302 unless you've actually measured server load and decided you'd rather lose analytics than scale the redirect tier."

11 / 12
8

Keeping Bad Actors Out

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 layered, never single-shot.

Pre-shorten URL scan

On POST /shorten, check the target against Google Safe Browsing, PhishTank, internal blocklists. Reject outright if it's a known bad. Async re-scan in the background as feeds update — yesterday's clean URL can be today's compromised host.

Rate limiting

Token bucket per IP, per API key, per user — for /shorten only. A residential IP that submits 10 000 URLs in a minute is automation. Redirect endpoint stays unrated; throttling reads punishes legitimate viral content.

Domain reputation

Newly-registered domains, free hosting providers, and IDN-homoglyph URLs (Cyrillic 'а' for Latin 'a') get extra scrutiny — sometimes an interstitial warning page before forwarding.

User reports & takedown

Every short URL has a ?report path. Three independent reports trip a soft-block (interstitial); a human moderator confirms hard-block. Audit log captures who set is_blocked and why.

Interstitials for risk

When confidence is medium ("looks suspicious but not confirmed"), show a "this link may be unsafe" page that requires a click-through. Buys time and protects the cautious.

POST /shorten Rate limiter IP · key · user URL scanner Safe Browsing · feeds Shortener create row async re-scan reject 422
Sync checks gate creation; async checks chase the moving target.
12 / 12
Chapter 8 · Summary

Five Principles For Designing Tiny URLs

The product is simple. The interesting choices are about traffic shape, identifier strategy, and where to draw the line on trust.

Read paths win the argument

At 100:1 read-to-write, every microsecond on the redirect dominates total cost. Design the lookup first; the writer can be slower if it has to be.

Hash vs counter is about coordination

Hashing buys you stateless workers and pays in collision retries. Counters give you guaranteed uniqueness and pay in a contended ID service. Pick the failure mode you'd rather operate.

Cache aggressively, layer the cache

CDN catches the viral tail, Redis catches the warm body, the DB sees only the cold misses. Each layer needs its own TTL and its own invalidation story.

302 unless you've measured

Temporary redirects keep analytics honest and let you revoke abusive links. The "load saving" of a 301 is hypothetical until your dashboards prove otherwise.

Abuse defence is a pipeline, not a switch

Scan on create, scan again in background, rate-limit creators, take user reports, interstitial the borderline. No single layer catches everything.

Short codes are public — design for it

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

prev·next· pressFfor fullscreen
📖 Read the lesson