Ctrl K
Lesson

Walkthrough: URL Shortener

Intuition: a coat check ticket

You hand over your coat and get ticket "47." The ticket is small and the coat is big. With the ticket, the attendant finds your coat in seconds. A URL shortener works the same way: the long URL is the coat, the short code aZ3kP9q is the ticket, and the redirect is the moment you get your coat back.

That leads to the key observation. Tickets are handed out rarely, but people come back with them all the time. A link is created once and clicked thousands of times. The whole architecture is built around that imbalance.

Step 1. Requirements

Functional:

  • given a long URL, return a short one;
  • given a short one, redirect to the long one;
  • collect click statistics.

Non-functional:

  • fast redirects, p99 < 50 ms;
  • high read availability: a dead link in a marketing email costs money;
  • codes shouldn't be easy to enumerate one after another.

In an interview, start with a question: "Do we need custom aliases and link expiration?" That immediately shows you're narrowing the problem instead of rushing to draw boxes.

Step 2. Estimation

Quantity Calculation Result
New links 100M/month ÷ 2.6M s ~40 writes/s
Reads (100:1) 40 × 100 ~4,000 RPS, ×3 at peak ≈ 12,000
Records over 5 years 100M × 60 months 6B
Storage 6B × 500 B ~3 TB
Code space 62⁷ ≈ 3.5 trillion, plenty of headroom

The conclusion worth saying out loud: writes put very little load on the system; all the difficulty is in reads. So we add a cache and make the redirect cheap.

Step 3. API

POST /api/v1/links
{"url": "https://example.com/very/long/path", "ttl_days": 365}
→ 201 {"code": "aZ3kP9q", "short_url": "https://sh.rt/aZ3kP9q"}

GET /aZ3kP9q
→ 302 Location: https://example.com/very/long/path

Step 4. Generating the short code

This is the heart of the case. There are two main approaches.

Counter + base62. Take a unique number and write it in base 62: 0-9, a-z, A-Z. Example: 125 = 2·62 + 1, so the code is made of two "digits" [2, 1].

62^1 ↓ 20
62^0 ↓ 11
125 in base62: 2·62 + 1

So the counter doesn't become a bottleneck, each API server grabs a range of IDs from a coordinator (ZooKeeper or a dedicated table), say 1,000,000 – 1,999,999, and hands them out locally.

Hash. Compute md5(url) and take the first 7 base62 characters. The codes are unpredictable, but a truncated hash produces collisions. The birthday paradox kicks in: with billions of records, collisions are inevitable. So you have to check whether the code is free, and if it's taken, add a salt and recompute.

Counter + base62 Hash
Collisions none yes, needs a check
Extra DB query no yes, on every write
Predictability sequential codes, easy to enumerate random
One URL → one code no yes, free deduplication

A strong interview answer: "I'll use a counter with range allocation. To keep codes from being sequential, I'll shuffle the ID with a reversible function, like modular multiplication or a Feistel cipher, and only then convert it to base62." That covers the counter's main weakness.

Step 5. Storage

The access pattern is simple: get long_url by code. No JOINs, no complex transactions. A key-value or wide-column store fits: DynamoDB or Cassandra. PostgreSQL sharded by code works too.

Record schema: code (PK), long_url, created_at, expires_at, owner_id. Three terabytes won't fit on one machine, so we shard by the hash of the code: the load spreads evenly.

Step 6. Caching hot links

Clicks follow the 80/20 rule. A single viral link can get a million clicks in an hour. We add Redis with a cache-aside pattern: look in the cache first; on a miss, go to the DB and put the result in the cache.

Cache size: 20% of the links clicked in a day. That's on the order of tens of GB, which fits in a small cluster. Links almost never change, so invalidation is simple: a TTL plus deleting the key when a link is deleted.

Step 7. Redirect: 301 or 302

301 Moved Permanently 302 Found
Cached by the browser yes no
Load on us lower higher
We see every click no yes
Can change the target barely yes

Since analytics is a requirement, we pick 302. Name the trade-off out loud: "We pay with extra requests, but we see every click and can disable a malicious link."

Step 8. Analytics through a queue

Recording stats must not slow down the redirect. The service puts an event {code, ts, ip, user_agent, referer} into Kafka and immediately responds with 302. Workers read the queue in batches and write to ClickHouse. If analytics goes down, redirects keep working and the events wait in the queue.

Final diagram

HTTPSPOSTGET /coderangewritereadmisseventbatchesUserclientLoad balancerLBWrite APIserviceRedirect serviceserviceID range allocatorserviceRedis: hot linkscacheKafka: clicksqueueCassandra: code → urlDBAnalytics workerworkerClickHouseDB
Reads go through the cache; analytics flows asynchronously through a queue

Common mistakes

  • Skipping the estimation and jumping straight to sharding. Then you can't explain why you need it.
  • Choosing a hash and forgetting about collisions.
  • Picking 301 while promising accurate analytics.
  • Writing stats synchronously to the same DB that holds the links.
  • Keeping one global counter without range allocation. That's a single point of failure.

You'll talk this through yourself

In the next step, the interviewer will ask about what's deliberately left out here. Think it over in advance:

  1. Custom aliases (sh.rt/sale2024). How do they coexist with a base62 counter so an alias never collides with a generated code? What happens if two people want the same alias at the same time?
  2. Expiration and abuse. How do you delete expired links without scanning 6 billion records? How do you stop a bot from creating a million links to phishing sites in a minute?

There's no single right answer here. The interviewer wants to see your reasoning: the options, their trade-offs, and your choice with an explanation.