Search Tech Journey

Find topics, journeys and posts

6-month learning plan127 / 130
back to blog
systemsadvanced 55m read

S127 · Design a URL Shortener — the Classic Warm-Up

Simple on the surface, deep underneath. Unique ID generation, sharding, redirect latency budget, and the anti-abuse story every serious shortener has to solve.

⚙️SystemsM15 · System Design· Session 127 of 130 90 min

🎯 Walk through a URL shortener design end-to-end in the 45-minute interview format, and know why every 'obvious' shortcut is a trap.

Why this session exists

The URL shortener is the compulsory warm-up question in every senior systems interview because it looks trivial for 30 seconds and stays interesting for 60 minutes. Every distributed-systems trade-off — unique IDs, sharding, caching, hot-key handling, abuse prevention, analytics fanout — fits inside a problem simple enough to reason about live. Do this one problem well and you have the template for every other design.

You will be able to
  • Estimate QPS + storage + short-code space for a URL shortener in 60 seconds.
  • Compare counter-based, hash-based, and random-with-collision-check ID strategies and pick one.
  • Design a redirect latency budget where 99% of requests never leave the CDN.
  • Explain the 301-vs-302 trap and why every real shortener defaults to 302.
  • Handle a viral-link cache stampede with singleflight or stale-while-revalidate.

Prerequisites

  • S126 · System Design Framework (the four-step method)
  • S063 · Caching — cache-aside, write-through, TTL, invalidation
  • S070 · Sharding + S066 · Load Balancers


(a) Intuition · 5 min

A cloakroom at a nightclub
🌍 Real world

You hand your coat to the cloakroom attendant and get a small ticket ("A47"). At the end of the night you show A47, get your coat back. The ticket is short, unique, easy to say. The coat is bulky, unwieldy, but stored once.

URL shortening is the exact same trick. Long URL = coat. Short code = ticket. The service is a giant cloakroom for the internet.

💻 Code world

Two operations dominate. Write (rare): POST /shorten {url} generates a unique short code (e.g. bit.ly/aX9k2p) and stores (code → long_url). Read (100–1000× more common): GET /aX9k2p looks up the mapping and returns HTTP 301/302.

The interesting choices: how do you generate short codes without collisions at scale? How do you make redirect latency <30ms globally? What do you cache and where?

The three deep trade-offs hiding in a trivial problem
  • ID generation — counter (needs coordination), hash (leaks + collides), or random+collision-check (needs unique index).
  • Read latency — every millisecond above ~30 ms costs conversion. CDN edge is non-negotiable at scale.
  • Analytics vs redirect — you MUST NOT block the redirect on click tracking. Async queue only.
  1. 2002
    TinyURL launches
    The original — random 4–6 char codes, single database, simple redirect. Still runs today.
  2. 2008
    bit.ly launches
    Built for the Twitter era. Analytics, custom domains, enterprise contracts. Redis + Cassandra stack.
  3. 2010
    Twitter's t.co
    Every tweet URL is wrapped for click tracking + safety scanning. Billions of redirects/day at sub-30 ms p99.
  4. 2016
    bit.ly outage
    Redis primary OOMs; cascades cross-fleet. Millions of shortened links across social + email temporarily break. Fixed with LRU eviction + stale-serve-on-origin-failure.
  5. 2020
    Cloudflare Workers KV
    Edge KV stores make 'redirect from the edge in &lt;10 ms globally' a solved problem.
  6. 2024
    Anti-phishing standard
    Every serious shortener now integrates Google Safe Browsing / URLhaus at shorten time AND on hot links.

(b) Visual walkthrough · 15 min

Capacity estimation (memorise these numbers)

The two dominant ID-generation designs

The full HLD (fits on one whiteboard)

The analytics path is async — click tracking must never block the redirect.

The redirect latency budget

CDN edge hit (target: 99% of requests)

5–15 ms

  • Cloudflare KV / Workers
  • Aggressive Cache-Control
  • Pre-warm known-hot URLs
Cache miss → Redis (~1%)

20–60 ms

  • Redis cluster in each region
  • Warm on write + on first read
  • LRU eviction to bound memory
Cache miss → DB (~0.1%)

150–300 ms

  • Point lookup by code (primary key)
  • Read replicas per region
  • Fallback if Redis is down
Everything down (rare)

Fail loud

  • Serve stale from cache if TTL just expired
  • Never let clients hang > 3 s — they will retry
  • Health check → drain node

Sharding choice + why

Why we shard by code

Access pattern is point lookup by code
Every redirect is 'find long_url where code = X'. The shard key must equal the lookup key.
correctness
Random codes → even distribution
Because codes are random or counter-hashed, hash-sharding by code gives near-perfect load balance across shards.
balance
Don't shard by user
Reads happen without knowing the user. Sharding by user forces a scatter-gather on every redirect.
anti-pattern
Don't shard by URL
The URL is huge and the lookup is by code, not URL. Sharding by URL forces an extra index lookup.
anti-pattern

Common misconception
✗ What most people think

"Hash the URL and take the first 7 characters of the digest. That gives short, deterministic codes and identical URLs collapse to one entry for free."

✓ What is actually true

Truncating a hash reintroduces the birthday problem at a scale you will actually hit. With base62 and 7 characters the space is about 3.5×1012, and collisions become likely around the square root of that — roughly two million URLs. Every insert must then read before writing to check for a collision, which is exactly the coordination the hash was supposed to avoid.

Why the myth is so sticky

Because hashing is genuinely the right instinct for deterministic, stateless ID generation, and it works flawlessly in testing where you insert a few thousand rows. The birthday bound is deeply unintuitive — people reason about the chance that a specific new URL collides (tiny) instead of the chance that some pair among n collides (large). Those differ by a factor of n.

Prove it to yourself

Compute the collision probability at your actual scale before committing to a code length:

import math
for L in (6, 7, 8, 10):
    space = 62 ** L
    for n in (1e6, 1e8, 1e9):
        p = 1 - math.exp(-n*n / (2*space))     # birthday approximation
        print(f'len={L}  n={n:.0e}  space={space:.2e}  P(collision)={p:.4f}')

# Note how fast it goes to 1. Then compare: a COUNTER encoded in
# base62 uses the space perfectly -- 7 chars addresses 3.5e12 URLs
# with zero collisions, by construction.
From first principles
Start with the question

Why does a counter-based ID generator beat a hash, and why must the counter be distributed rather than a single database sequence?

  1. 1
    The requirement is a unique short code per URL, with codes as short as possible.
    forced by · shortness is the product; uniqueness is correctness
  2. 2
    Shortest possible means the encoding must be injective and dense — every code in the space maps to at most one URL, and no code is wasted.
    forced by · any collision handling implies wasted codes and a read-before-write
  3. 3
    A monotonic counter encoded in base62 is exactly a dense injective map: counter n → a unique string, using the space perfectly with zero probability of collision.
    forced by · base conversion is a bijection between integers and fixed-alphabet strings
  4. 4
    But a single counter is a single point of serialisation. Every write in the system must touch it, so its throughput caps the entire service and its failure stops all writes.
    forced by · a global monotonic sequence requires a total order, and total order requires coordination
  5. 5
    The resolution is to partition the counter space: hand each application server a contiguous range (say a million IDs) from a coordination service, and let it allocate locally with no network call.
    forced by · coordination cost amortises over the block size — one round trip per million IDs instead of per ID
  6. 6
    Uniqueness still holds because ranges are disjoint by construction; a server crash merely leaks its unused range, which is free given the size of the space.
    forced by · disjointness is guaranteed at allocation time, so no runtime coordination is needed
⇒ Therefore

Therefore range-allocated counters give collision-free, dense, short codes with essentially no coordination on the hot path — strictly better than truncated hashes on every axis except deduplication.

And note what this predicts: sequential counters make codes enumerable, so anyone can walk the ID space and discover every link. If that matters — and for a link shortener carrying private documents it does — you must either encrypt the counter with a format-preserving permutation before encoding, or accept enumeration. That is a security requirement flowing directly from the ID scheme, and it is the follow-up question this design invites.

Mental modelAn extremely lopsided key-value store

Strip away the framing and this is a hash map with an enormous read:write ratio and a tiny value size. Writes are rare and must be durable and unique; reads are constant, latency-critical, and identical for a given key forever.

Everything in the design follows from that asymmetry: cache aggressively because entries are immutable, optimise the read path to a single lookup, and spend the complexity budget on ID generation because that is the only place writes are hard.

  • Immutable values mean cache invalidation is a non-problem — one of the two hard things simply does not apply. Exploit that.
  • Read path should be: CDN/edge → cache → single partitioned lookup. Anything more is a design smell.
  • Use a 301 vs 302 redirect deliberately: 301 is cached by browsers so subsequent clicks never reach you (cheap), 302 is not (so you keep analytics). This is a real product tradeoff, not a detail.
  • Partition by the short code itself. It is uniformly distributed by construction, so there are no hot partitions from key skew — only from viral individual links, which is a caching problem.
🔔 Fires when you see

Fire this model the moment you see: any read-heavy immutable-data system · a request for globally unique short identifiers · a design where writes and reads have wildly different requirements · a proposal to truncate a hash for an ID.

The tradeoff

How are short codes generated — truncated hash, distributed counter, or pre-generated key pool?

Truncated hash of the URL
+ you gain completely stateless, so any server can generate a code with no coordination at all; and identical URLs naturally map to the same code, giving deduplication for free
− you pay collisions are certain at scale, so every write needs a read-before-write check and a retry loop with a salt; codes are longer than necessary because the space must be oversized to keep collisions rare
pick when deduplication of identical URLs is a genuine product requirement and write volume is low enough that read-before-write is acceptable
Distributed counter with range allocation
+ you gain zero collisions by construction, densest possible encoding so the shortest codes, and one coordination round trip per million IDs — effectively free on the hot path
− you pay requires a coordination service (ZooKeeper, etcd, or a database sequence) as a dependency; codes are enumerable unless you permute them; and identical URLs produce different codes
pick when high write volume and shortest-possible codes matter — the default choice for a real shortener
Pre-generated key pool
+ you gain the ID generation problem moves entirely offline: a batch job produces and shuffles unused keys, so codes are non-sequential and non-enumerable while still collision-free; the write path becomes a single pop from a queue
− you pay an extra service to operate and monitor; running out of keys is an outage with no graceful degradation; and used/unused state must itself be managed durably
pick when non-enumerability is a security requirement and you want the write path as simple as possible
What a senior engineer actually does

Distributed counters for most cases — collision-free, shortest codes, minimal coordination. Add a format-preserving permutation over the counter if enumeration is a concern, which buys unpredictability without giving up density or introducing a second service.

The instinct worth carrying beyond this problem: when a design tempts you toward probabilistic uniqueness, check whether a deterministic partitioning of the ID space achieves the same goal. It usually does, it is usually simpler to reason about, and "we are fairly sure it is unique" is a sentence you do not want in a durability guarantee.


(c) Hands-on · 25 min

A working URL shortener with Flask + Redis + Postgres. Runs locally in a few minutes.

#!/usr/bin/env python3
# shortener.py — a minimal but real URL shortener.
# pip install flask redis psycopg2-binary
# docker run -d -p 6379:6379 redis:7
# docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=pw postgres:16
import os
import string
 
import psycopg2
import redis
from flask import Flask, abort, jsonify, redirect, request
 
app = Flask(__name__)
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
pg = psycopg2.connect("dbname=postgres user=postgres password=pw host=localhost")
pg.autocommit = True
 
with pg.cursor() as c:
    c.execute(
        """
        CREATE TABLE IF NOT EXISTS urls (
            code TEXT PRIMARY KEY,
            long_url TEXT NOT NULL,
            created_at TIMESTAMPTZ DEFAULT now()
        )
        """
    )
 
ALPHABET = string.ascii_letters + string.digits  # 62 chars
BASE = len(ALPHABET)
CACHE_TTL_SEC = 24 * 3600
 
def encode_base62(n: int) -> str:
    if n == 0:
        return ALPHABET[0]
    out = []
    while n > 0:
        n, rem = divmod(n, BASE)
        out.append(ALPHABET[rem])
    return "".join(reversed(out))
 
def next_id() -> int:
    # Real systems: a range-allocation service (each app gets 10k IDs at a time
    # from ZooKeeper or a DB sequence) so INCR isn't a single hot key.
    return r.incr("url:counter")
 
@app.post("/shorten")
def shorten():
    body = request.get_json(force=True)
    long_url = (body or {}).get("url", "").strip()
    if not long_url.startswith(("http://", "https://")) or len(long_url) > 2048:
        abort(400, description="Invalid URL")
    code = encode_base62(next_id())
    with pg.cursor() as c:
        c.execute(
            "INSERT INTO urls (code, long_url) VALUES (%s, %s) ON CONFLICT DO NOTHING",
            (code, long_url),
        )
    r.setex(f"url:{code}", CACHE_TTL_SEC, long_url)  # warm the cache on write
    return jsonify({"short": request.host_url + code, "code": code})
 
@app.get("/<code>")
def follow(code):
    # 1. Try Redis (hot path — ~1 ms)
    cached = r.get(f"url:{code}")
    if cached:
        r.incr(f"clicks:{code}")  # in prod: push to Kafka instead
        return redirect(cached, code=302)
 
    # 2. Cache miss → Postgres (rare — ~50 ms)
    with pg.cursor() as c:
        c.execute("SELECT long_url FROM urls WHERE code = %s", (code,))
        row = c.fetchone()
    if not row:
        abort(404)
    long_url = row[0]
 
    # 3. Warm the cache for next time
    r.setex(f"url:{code}", CACHE_TTL_SEC, long_url)
    r.incr(f"clicks:{code}")
    return redirect(long_url, code=302)
 
@app.get("/_stats/<code>")
def stats(code):
    return jsonify({"clicks": int(r.get(f"clicks:{code}") or 0)})
 
if __name__ == "__main__":
    app.run(port=5000, debug=True)

What each block is doing

Anatomy of the shortener

encode_base62(n)
Positional base-62 encoding. Counter value 1,234,567,890 → '1LY7VK'. Shorter than decimal or hex.
ids
next_id() via r.incr
One hot Redis key. Fine for a demo; in prod use range allocation (each app grabs 10k IDs at a time) to avoid the hot key.
coordination
ON CONFLICT DO NOTHING
Idempotent insert. If code collides (very unlikely with counter, more with random), returns without error.
safety
r.setex(...) on write
Warm the cache on shorten so the first click is already a cache hit.
caching
return redirect(url, code=302)
302 (temporary) — NOT 301. See War Story #1: 301 is cached forever by browsers + CDNs, so you can never fix a broken mapping.
http
r.incr('clicks:...')
Toy click counter. In prod this becomes 'produce to Kafka topic click-events' and a downstream ClickHouse pipeline aggregates.
analytics
Try itRange-allocate IDs so INCR isn't a hot key

Replace next_id() with a range allocator:

_range_lock = threading.Lock()
_range_start, _range_end = 0, 0
 
def next_id() -> int:
    global _range_start, _range_end
    with _range_lock:
        if _range_start >= _range_end:
            end = r.incrby("url:counter", 10_000)  # atomic
            _range_start, _range_end = end - 10_000 + 1, end
        val = _range_start
        _range_start += 1
        return val

Now Redis sees one INCRBY per 10 000 shortenings instead of one INCR per shortening. Benchmark with wrk or ab and observe the QPS jump.

💡 Hint · Each app process requests a range of 10 000 IDs in one INCRBY, then hands them out locally until exhausted. This is exactly how Instagram / Twitter solved it.

(d) Production reality · 15 min

War story bit.ly· 2016millions of links across Twitter/email
🔥 What broke

Redis primary OOMed. Redirects that should have taken 15 ms became 3-second Postgres queries, then timeouts. Half the shortened links on Twitter and marketing emails temporarily returned nothing usable.

🧯 The fix

Three post-mortem fixes:

  1. Redis eviction policy set to allkeys-lru. Never noeviction — OOM becomes a full outage.
  2. "Serve stale on origin failure" — if Postgres is down, keep serving the last-known mapping from Redis past its TTL.
  3. Client-side timeout budget capped so browsers don't hammer during recovery.
🎓 Lesson to steal
Redirect latency budgets must include failure modes. A 3-second cache miss during a Postgres outage causes retries that cascade. Fast fail + stale serve beats slow correctness in a redirect.
War story Every serious shortener · 2010→now30–40% of traffic is spam/phishing
🔥 What broke
Public shorteners are irresistible to phishers because the short URL hides the real target. Left unchecked, 30–40% of shortened URL traffic on open shorteners is malicious.
🧯 The fix

Standard playbook:

  • Scan every URL against Google Safe Browsing / URLhaus at shorten time AND periodically after.
  • Interstitial warning page when Safe Browsing flags a URL.
  • One-click reporting endpoint → auto-quarantine after N reports.
  • Rate-limit shortening per user + per IP; require account for high volumes.
🎓 Lesson to steal
Abuse is not an edge case for URL shorteners — it is the dominant workload if you do nothing. Integrate a threat feed before you launch, not after.
War story Common junior mistake · every review cycleevery year
🔥 What broke
Junior engineer picks HTTP 301 (permanent redirect) because "more SEO-friendly." Six months later a customer needs to redirect an already-shortened URL to a new target (typo, product moved) — but browsers and CDNs have cached the 301 forever. The mapping is impossible to update for existing users.
🧯 The fix
Switch to 302 (or 307). Add a rewrite path only for freshly-created codes. Document that 301 is only used when the underlying URL is provably immutable (e.g. archive links).
🎓 Lesson to steal
Use 302 by default. 301 is a one-way door — you can never take it back for cached clients. The tiny SEO win is not worth the operational cost.

Common failure modes

Where this shows up in the rest of the plan

URL shortener is the smallest problem that exercises every core primitive
S126 · System Design Framework
The four-step method you just used.
S128 · Chat System
Different problem shape — realtime, presence, delivery — but same framework.
S129 · Newsfeed
Read-heavy fanout at scale; extends the caching + hot-key story here.
S063 · Caching
Cache-aside pattern + stampede protection — you used both.
S070 · Sharding
Shard-by-code is the canonical hash-sharding example.
S140 · Rate Limiting
Abuse prevention on the shorten endpoint.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

  1. Name the three ID-generation strategies and pick one with justification.
  2. What's the redirect latency budget and where do you spend it?
  3. Why 302 by default, and when is 301 acceptable?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.