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/pathStep 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].
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
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:
- 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? - 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.