Design: a URL shortener

The interview classic, done honestly: estimation, data model, cache strategy, and the redirect hot path.

30 min read

Start with numbers, not boxes

The mistake in every interview is drawing boxes first. Boxes are free; you can justify any of them. Numbers are not - they tell you which boxes you are allowed to skip.

So estimate out loud. Say 100 million new links a year. That's about 3 writes a second - nothing at all. But links are read far more than written, at maybe 100:1, so that's 300 reads a second, and reads arrive in spikes when something goes viral.

Now storage. A row is roughly 500 bytes: the short code, the long URL, an owner, a timestamp. 100 million rows is 50 GB a year. That fits on one machine with room to spare, for years.

That paragraph just eliminated sharding, and it eliminated it with arithmetic rather than opinion. Say that in an interview and you have already outperformed the candidate who opened with 'I'd shard by hash of the URL'.

estimate.py
python
links_per_year = 100_000_000
reads_per_write = 100
bytes_per_row = 500

writes_per_sec = links_per_year / (365 * 24 * 3600)
reads_per_sec = writes_per_sec * reads_per_write
storage_gb = links_per_year * bytes_per_row / 1e9

print(f"writes/sec  {writes_per_sec:.1f}")
print(f"reads/sec   {reads_per_sec:.0f}")
print(f"storage/yr  {storage_gb:.0f} GB")
What it prints
writes/sec  3.2
reads/sec   317
storage/yr  50 GB
Three writes a second. One Postgres handles this half asleep. The entire design problem is the read path and nothing else - which is a very different system from the one people usually start drawing.
Words you just learned
back-of-envelope estimation
- rough arithmetic that sizes a system before designing it
read:write ratio
- how much more often data is read than written - it drives everything

Generating the short code

You need a short, unique, URL-safe string. There are two honest approaches and one trap.

Hash the URL and take the first seven characters. Simple, and identical URLs collapse to one code - but hashes collide, so you must check for and handle collisions, and the check is a database read on every write.

Encode a counter in base62. The database's auto-incrementing id is already unique, so encode it in [0-9a-zA-Z] and you get a short code with no collision possible, ever. 62^7 is 3.5 trillion codes. This is the better default.

The trap is that sequential ids are guessable - anyone can walk your entire link database by counting. If links are private, that's a breach, and the fix is to encode a scrambled id rather than the raw one.

base62.py
python
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

def encode(n):
    if n == 0:
        return ALPHABET[0]
    out = []
    while n:
        n, rem = divmod(n, 62)
        out.append(ALPHABET[rem])
    return "".join(reversed(out))

for db_id in (1, 1000, 100_000_000, 3_521_614_606_207):
    print(f"{db_id:>15,}  ->  {encode(db_id)}")
What it prints
1  ->  1
          1,000  ->  g8
    100,000,000  ->  6LAze
3,521,614,606,207  ->  ZZZZZZZ
Seven characters cover 3.5 trillion links. Note the last row: that's the ceiling, and at 100 million links a year you reach it in thirty-five thousand years.
Why not a UUID?

A UUID is unique and needs no coordination - but it's 36 characters, which defeats the entire point of a URL shortener. Fit the identifier to the product.

Words you just learned
base62
- encoding a number using 0-9, a-z and A-Z - short and URL-safe
collision
- two different inputs producing the same short code

The redirect is the whole product

317 reads a second, each one a lookup by primary key, each one returning a row you could cache forever - because a short code's target never changes. This is the friendliest caching problem in existence.

So: cache-aside on the short code, with a long TTL. A hit is a memory read and a 301. Expect a hit rate well above 95%, because link traffic is brutally skewed - a handful of viral links carry most of the requests, which is exactly the shape caches love.

One decision has teeth. A 301 is a permanent redirect and browsers cache it, so repeat visits never reach you at all - the cheapest possible outcome, and you lose the ability to count those clicks or ever change the target. A 302 is temporary: every visit comes to you, so analytics work and links stay editable, at the cost of real traffic. Analytics usually wins, and that is why most shorteners use 302.

redirect.py
python
def redirect(code):
    url = cache.get(f"link:{code}")          # ~0.2 ms
    if url is None:
        url = db.query(                       # ~2 ms, by primary key
            "SELECT long_url FROM links WHERE code = %s", code
        )
        if url is None:
            return 404
        cache.set(f"link:{code}", url, ttl=86400)

    queue.push("record_click", code)          # analytics NEVER blocks (module 3)
    return 302, url
What it prints
cache hit    0.2 ms   ~96% of requests
cache miss   2.2 ms   ~4%
click count  0 ms on the request path - a queued job
Every module shows up here: the cache from module 2, the queue from module 3, and the estimate that told us one database is plenty. Notice analytics is queued - counting a click must never slow the redirect down.
Remember these
  • Estimate first - 3 writes/sec means no sharding, no debate
  • base62 of the row id: short, unique by construction, no collision check
  • Cache-aside with a long TTL; the mapping is immutable
  • 302 to keep analytics and editability; 301 only if you truly want to disappear
  • Click counting goes on a queue, never on the redirect