System design · build it to learn it

Design a URL Shortener

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?"

On this page: takeaway · requirements · capacity math · architecture · deep dives · API + data model · trade-offs · failure modes · what I'd actually build · interview tips · live demo

🎯 The takeaway, first

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."

🚫 Misconception, busted

"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.

01Requirements

Functional

  • POST /shorten {url} → short code. Same URL twice → same code (dedup).
  • GET /{code} → 302 redirect to the long URL.
  • Custom aliases (optional): let users pick /my-launch.
  • Click analytics: count, timestamp, referrer per code.
  • Links never expire (or expire only with an explicit TTL).

Non-functional

  • Read-heavy: ~100 redirects per 1 shorten. Optimize the read path ruthlessly.
  • Redirect latency: p99 under 50ms — it's a hop before the real page loads.
  • 100M URLs/month written; 10B redirects/month served.
  • Codes stay short: 7 characters covers 3.5 trillion URLs.

02Back-of-envelope math

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.

QuantityAssumptionResult
Code space627 for 7-char codes3.5 trillion — at 100M URLs/month, lasts ~2,900 years
Writes100M URLs/month~40 writes/sec average — a single Postgres handles this asleep
Reads10B redirects/month (100:1 ratio)~3,800 reads/sec — this is the number the cache exists for
Storage, 5 years6B URLs × ~500 bytes (code + URL + metadata)~3 TB — fits on one big disk; shard only when ops says so
Cache sizing80/20: 20% of codes get 80% of clicks; 1M hot codes × 500 bytes~500 MB — the entire hot set fits in one Redis
Bandwidth3,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.

03Architecture

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
Go deeper: 301 vs 302 — it actually matters

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.

04Component deep-dives

Shortening: counter → base62

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

Redirect: the read path that pays the bills

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

05API + data model

API

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": ... }

Data model

code (pk)varchar(16)
long_urltext
url_hash (unique)for dedup lookups
created_at / clickscounter (async updated)
id_countersingle-row sequence table

Custom 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).

06Trade-offs

DecisionOption AOption BVerdict
Code generationCounter + base62 — unique by constructionHash of URL — dedups free, collisions need handlingCounter; hash separately for dedup if you want it
Redirect code302 — you keep control + analytics301 — browsers cache, faster repeat clicks302. Control beats a saved lookup
ID mintingDB sequence — simple, single choke pointRange allocator / ZooKeeper — distributed, more partsSequence until the write rate (40/sec!) says otherwise
AnalyticsSync counter increment — simple, slows redirectsAsync queue + batch — more infra, zero redirect impactAsync. Never let counting slow down the hop

07Failure modes

08What I'd actually build

09Interview tips

10🔬 Live widget: shorten a URL, then walk the redirect

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.

0
URLs shortened
0
redirects served
0
cache hits
0
cache misses

Your links tap a short link to walk the redirect

no links yet — shorten something above

🚶 Redirect walkthrough

click a short link above to trace its redirect…
Go deeper: why base62 and not base64?

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 ↑

v2026.10.03-01