System DesignIntermediate 16 min Lesson 35 of 42

Design a URL Shortener

A complete walkthrough: requirements, key generation, storage choice, caching, redirects and analytics for a link shortener.

System Design · Lesson 35 of 42
0/42 done(0%)

What is it? #

A URL shortener takes a long URL and returns a short one that redirects to it. It is the standard first system design exercise because it is small enough to finish and rich enough to be interesting.

The interesting parts are key generation, the read-heavy access pattern, and what happens when it grows.

Reads outnumber writes by a large factor — roughly a hundred to one is a reasonable assumption. That shapes every decision that follows.

The functional requirements are simple: create a short link, redirect it, optionally set an expiry, and count clicks.

Think of it like this #

A cloakroom ticket. You hand over something long and awkward and receive a short token. Presenting the token returns the original.

The cloakroom must never issue the same ticket twice, must find the matching item instantly, and handles far more lookups than deposits.

Simple example #

Design for 100 million links and 10,000 redirects per second at peak, with links lasting years. Every decision below follows from those numbers.

Code #

TEXT
1. Requirements

functional      shorten a URL, redirect, custom alias, expiry, click count
non-functional  redirects under 50ms, 99.9% availability, links never reused
scale           100M links, 1K writes/s, 10K reads/s, read:write ≈ 100:1
storage         100M x ~500 bytes ≈ 50 GB — fits comfortably on one database
TEXT
2. Key generation — three options

hash the URL       md5(url)[:7]   deterministic, but collisions need handling
random             7 random base62 chars   must check for existing key
counter + base62   encode an incrementing ID   no collisions, but sequential

base62 = [0-9a-zA-Z], so 62^7 ≈ 3.5 trillion keys in 7 characters.

Chosen: a counter encoded in base62, with the counter ranges handed out
in blocks to each server so they never collide and never coordinate per write.
Sequential keys are scrambled slightly so they are not trivially enumerable.
PYTHON
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"


def to_base62(number: int) -> str:
    if number == 0:
        return ALPHABET[0]
    out = []
    while number:
        number, remainder = divmod(number, 62)
        out.append(ALPHABET[remainder])
    return "".join(reversed(out))


class KeyAllocator:
    """Each server claims a block of IDs, so no coordination per request."""

    BLOCK = 10_000

    def __init__(self, counter_store):
        self.store = counter_store
        self.next_id = 0
        self.block_end = 0

    def next_key(self) -> str:
        if self.next_id >= self.block_end:
            start = self.store.increment_by("url_counter", self.BLOCK)
            self.next_id, self.block_end = start, start + self.BLOCK
        key = to_base62(self.next_id)
        self.next_id += 1
        return key
TEXT
3. Storage

links
  key         VARCHAR(10) PRIMARY KEY
  long_url    TEXT NOT NULL
  user_id     BIGINT NULL
  created_at  TIMESTAMP
  expires_at  TIMESTAMP NULL

clicks (append-only, written asynchronously)
  key, clicked_at, country, referrer, user_agent_family

Key lookup is the only read pattern that matters, so any store with fast
key lookup works. A relational database is fine at this scale; a key-value
store is the natural choice beyond it.
PYTHON
# 4. The redirect path — the hot path, optimised hard
def redirect(key: str):
    cached = cache.get(f"u:{key}")                 # ~95% hit rate expected
    if cached == "MISSING":
        return response(404)
    if cached:
        record_click_async(key)                    # never block the redirect
        return response(301, headers={"Location": cached})

    link = links.find(key)
    if link is None or link.is_expired():
        cache.setex(f"u:{key}", 60, "MISSING")     # cache negatives, briefly
        return response(404)

    cache.setex(f"u:{key}", 86400, link.long_url)
    record_click_async(key)
    return response(302, headers={"Location": link.long_url})

How it works #

The estimates come first because they determine everything. 50 GB of data and 10,000 reads per second is comfortably within one database with a cache — which means this system does not need sharding, and saying so is part of a good design.

Counter-based keys avoid collision handling entirely. Handing each server a block of 10,000 IDs means one coordination round trip per 10,000 links instead of one per link.

The cache carries the read load. With a 95% hit rate, the database sees 500 reads per second rather than 10,000.

Caching negative results matters more than it looks. Bots probe random keys constantly, and without negative caching every one of those probes becomes a database query.

Click recording is asynchronous — pushed to a queue or a buffered counter. Writing a row per click synchronously would make the redirect slower and couple its availability to the analytics path.

The status code choice is a real trade-off. A 301 is cached by browsers, making later redirects instant and free, but you then stop seeing those clicks and cannot change the destination. A 302 keeps control and analytics. Most shorteners choose 302 for that reason.

If this needed to grow further, the next steps would be read replicas, then partitioning by key prefix, and a CDN or edge function performing the redirect close to the user.

Real-world use #

The hardest problems in a real shortener are not technical. Abuse is: short links are used to disguise malicious destinations, so scanning target URLs against threat lists and supporting takedowns is essential.

Custom aliases add a uniqueness constraint and a reservation problem, plus a policy for offensive or trademarked names.

Analytics is usually the product. Click counts, geography, referrers and device breakdown are what users pay for, and they are what makes the write path non-trivial.

Link permanence is a commitment. Once links are shared, they must keep working for years, which makes the storage decision long-lived.

As a design exercise, the answer that demonstrates judgement is recognising the scale honestly: at these numbers, one database, one cache and a stateless application tier is correct, and proposing a large distributed system would be over-engineering.

Common mistakes #

  • Jumping to a distributed design without estimating that the data fits on one machine.
  • Coordinating a counter per write instead of allocating blocks.
  • Not caching misses, so bot traffic hits the database directly.
  • Writing a click row synchronously in the redirect path.
  • Using 301 redirects and then wondering why click counts are missing.

Practice #

Extend this design with custom aliases and link expiry. Specify how you prevent two users claiming the same alias, how expired links are cleaned up, and what the redirect path does for an expired link. Then estimate the storage and traffic if the system grew tenfold.

Quick quiz

  1. 1. Why does the read-to-write ratio shape the design?

  2. 2. Why allocate counter blocks to each server?

  3. 3. Why cache negative lookups?

  4. 4. What is the trade-off of a 301 redirect?

  5. 5. What does good judgement look like in this exercise?

Summary

  • Start with requirements and numbers; they determine the architecture.
  • Counter plus base62 with block allocation avoids collisions and coordination.
  • The redirect path is hot: cache aggressively, including misses.
  • Record clicks asynchronously so analytics never slows a redirect.
  • At this scale, one database plus a cache is the correct answer.