Design a URL Shortener
Work the classic interview question end to end: requirements, estimates, API, data model, the design, short-code generation, caching and failure.
On this page
You paste a long link and get back something like sho.rt/008sudj. Anyone who opens the short link lands on the long one. It looks like a weekend project, which is why interviewers like it: a small surface hides a read-heavy workload, an ID scheme that has to work across many servers, a cache that does most of the work, and a database that must never lose a row.
This case study follows the same steps as A Framework for Any Design. Try each "Your turn" box before you read on. The point is to practise the thinking, not to memorise this answer.
Clarify the problem
The prompt is one sentence on purpose. The interviewer wants to see whether you pin down the scope before drawing boxes. Ask about features first, then scale, then what matters most when things go wrong.
Design a URL shortener.
Let me check the scope first. Users submit a long URL and get a short one, and opening the short one redirects. Can users pick a custom alias? Do links expire? Do we need click counts?
Custom aliases, yes. Links live forever unless the user sets an expiry. A click count per link is enough; no dashboards.
How many links are created, and how often is each one opened?
Around 100 million new links a month. Reads are about 100 times the writes.
Then this is read-heavy, and the redirect is the path to protect. I'll aim for redirects that add under 20 ms on our side and stay up even when link creation is down. Losing a link once created is not acceptable. I'll leave out accounts, spam scanning and analytics dashboards unless you want them.
Write the result down where the interviewer can see it:
- Functional: create a short link for a long URL, with an optional custom alias and expiry; redirect a short code to its long URL; count clicks per link.
- Non-functional: redirects are fast (under 20 ms added) and highly available; created links are never lost; codes are short.
- Out of scope: user accounts, abuse scanning, analytics dashboards.
Estimate the scale
Turn the interviewer's numbers into per-second rates and bytes. A month has about 30 × 86,400 ≈ 2.6 million seconds.
- Writes: 100 million ÷ 2.6 million ≈ 40 links a second on average. Assume peaks of 5x: 200 a second.
- Reads: 100 × 40 = 4,000 redirects a second on average, 20,000 at peak.
- Code length: with 62 characters (0-9, a-z, A-Z), 6 characters give 626 ≈ 57 billion codes and 7 give 627 ≈ 3.5 trillion. Five years of links is 6 billion, so 7 characters leave plenty of room.
How much storage do the link records need after five years?
- 100 million new links a month
- About 500 bytes per record (code, long URL, timestamps)
- Nothing is deleted
Show the working
- 100 million × 12 months × 5 years = 6 billion links
- 6 × 109 × 500 B = 3 × 1012 B = 3 TB
- With 3 copies for durability: about 9 TB of disk
Estimate 3 TB (anything from 1 TB to 10 TB is a good answer)
3 TB is more than you want on one machine, but small for a cluster. It points at partitioning later, not on day one.
About how many redirects per second must the system serve on average?
- 100 million new links a month
- 100 reads for every write
- A month is about 2.6 million seconds
Show the working
- Reads per month: 100 million × 100 = 10 billion
- 10 × 109 ÷ 2.6 × 106 s ≈ 3,900, call it 4,000 a second
Estimate 4 thousand/s (anything from 2 thousand/s to 8 thousand/s is a good answer)
Two more numbers shape the design. Each redirect response is about 500 bytes, so even 20,000 a second is only 10 MB/s of traffic. And if the hottest 10 million links get most clicks, caching them takes 107 × 500 B = 5 GB of memory, which fits on one cache node.
Write the two endpoints before reading on: method, path, request body, and what each returns, including the error cases.
Compare with a model answer
A create endpoint (POST /api/links with the long URL and optional alias and expiry, returning the code) and the redirect itself (GET /{code}, returning a redirect status with a Location header). The interesting choice is which redirect status, covered next.
Define the API
POST /api/links
body: { "long_url": "https://...", "alias": "spring-sale"?, "expires_at": "2027-01-01"? }
201 Created { "code": "008sudj", "short_url": "https://sho.rt/008sudj" }
400 Bad Request the URL is not valid
409 Conflict the alias is taken
GET /{code}
302 Found Location: https://the-long-url...
404 Not Found no such code, or it has expiredThe redirect status is a real decision. A 301 (moved permanently) lets browsers cache the redirect, so repeat clicks never reach you: less load, but you cannot count those clicks or change the target later. A 302 (found) is not cached, so every click comes through. We need click counts, so use 302 and accept the extra traffic, which the estimate says is small.
Protect POST with a per-client rate limit; a shortener without one becomes a free tool for spammers within days.
What does one record hold, how is it looked up, and which kind of database fits that access pattern?
Compare with a model answer
One record per code. Every read is "give me the row for this code", every write is an insert, and nothing joins. That is a key-value access pattern.
Model the data
code CHAR(7) PRIMARY KEY -- "008sudj" or a custom alias
long_url TEXT -- up to 2 KB
created_at TIMESTAMP
expires_at TIMESTAMP NULL
clicks BIGINT -- updated in batches, not per clickThe only query on the hot path is a lookup by primary key. There are no joins, no range scans, and rows never change after they are written (except the click count). A key-value or wide-column store (such as DynamoDB or Cassandra) fits this well: it partitions by key out of the box, which we will want at 3 TB and beyond. A relational database also works at this size, with read replicas; say so, and say you would pick it if the team already runs one. What matters is the reason: single-key reads, no joins, easy partitioning by code.
Custom aliases live in the same table. Because code is the primary key, "insert if absent" makes alias claims safe: two users racing for spring-sale cannot both win.
Sketch the boxes for both paths: what sits between a browser and the database on a redirect, and where does a new code come from?
Compare with a model answer
Split the read path from the write path so each scales on its own: a stateless redirect service with a cache in front of the store, and a create service that hands out codes. The next section draws it.
High-level design
Redirects outnumber creates 100 to 1, so they get their own service and their own cache. Creating a link needs a code that no other server will hand out; a small ID allocator gives each create server a block of numbers to use.
Step through creating a link, then opening it twice.
1A user submits a long URL. The load balancer sends
POST /api/linksto any create server; they are all alike.2This server reserved IDs 125,000,000 to 125,000,999 from the allocator earlier, so it takes the next one without asking anyone: 125,000,017.
- code
- 008sudj
3It writes the number in base 62 and pads it to 7 characters:
008sudj.- code
- 008sudj
4It inserts the row into the links store. The store keeps three copies before saying yes, so the link survives a disk failure.
- code
- 008sudj
5The short URL goes back to the user. One store write, no other coordination.
- code
- 008sudj
6Someone opens
sho.rt/008sudj. The load balancer sends theGETto a redirect server.- code
- 008sudj
7The redirect server asks the cache. The link is new, so it is a miss.
- code
- 008sudj
8It reads the row from the store by primary key, then puts it in the cache. Links never change, so the cached copy cannot go stale; only deletes and expiry need care.
- code
- 008sudj
- status
- 302
9The browser gets
302 Foundwith the long URL inLocation, and follows it.- code
- 008sudj
- status
- 302
10The next click is a cache hit. Most redirects end here and never touch the store, which is why the store can stay small.
11If the allocator goes down, create servers keep using the IDs left in their blocks. At 40 writes a second spread over 10 servers, a block of 1,000 lasts about 4 minutes: time to fail over.
Deep dives
Interviewers push on two or three parts. For this question it is almost always how codes are made, how redirects stay fast, and how clicks are counted without slowing redirects.
Generating short codes
Every server must hand out codes that no other server hands out, without a slow check on each request.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Hash the long URL | The same URL always gets the same code, so duplicates are free to detect. | Taking 7 characters of a hash collides; you must check and re-hash. Two users shortening the same URL share one code, and one link's expiry affects the other. | Deduplication matters more than anything else, which is rare. |
| Random code, retry on collision | No coordination at all. Codes are not guessable. | Each create needs an insert-if-absent and the occasional retry. The collision rate grows as the table fills. | Codes must not be guessable (private documents, unlisted links). |
| Counter blocks + base 62 Usual default | No collisions, ever. Each server talks to the allocator once per thousand links. | Codes are sequential, so anyone can walk through them. Needs a small, highly available allocator. | Public links where guessability is fine. A good default. |
The arithmetic behind the random option: with 6 billion links stored and 3.5 trillion possible codes, a new random code collides with probability 6 × 109 ÷ 3.5 × 1012 ≈ 0.2%. One retry in 500 creates is fine. If an interviewer worries about guessable codes from the counter, keep the counter and scramble the number with a reversible function before encoding: still no collisions, no longer sequential.
Keeping redirects fast
Clicks are lopsided: a few links get most of the traffic. A cache in front of the store, with LRU eviction, serves most redirects in about a millisecond. Because a link never changes after it is created, there is no invalidation problem on writes. Deletes and expiry are the exceptions: delete the cache key when a link is deleted, and check expires_at on every redirect so an expired link is never served from cache. The caching lesson covers stampedes, which matter here when a link goes viral.
Counting clicks without slowing redirects
Updating a counter row on every click would put a write on the hot path, and a viral link would make one row the hottest spot in the database. Instead, the redirect service publishes a small click event to a queue and returns immediately. A worker reads the events, adds them up per code, and writes one update per link every few seconds.
request and replyasynchronous message
The trade-off to say out loud: counts lag by a few seconds, and if the queue loses events, counts are slightly low. For click counts that is fine. For billing per click it would not be, and you would make the events durable and processing idempotent.
Bottlenecks and failures
At 10x the traffic (200,000 redirects a second at peak, 30 TB over five years), the redirect servers scale by adding more, since they hold no state. The cache becomes a cluster, split by consistent hashing so adding a node moves only a small share of keys. The store is partitioned by code, which spreads evenly because codes are spread evenly. The allocator barely notices: blocks of 1,000 at 2,000 writes a second is 2 requests a second.
| When this dies | What happens | What you do |
|---|---|---|
| A redirect or create server | Requests to it fail until the load balancer notices. | Health checks remove it in seconds; the others absorb its share. |
| A cache node | Its keys miss and fall through to the store. | Size the store for the extra reads, or run cache replicas. |
| A store node | Its partition loses one of three copies. | Reads and writes continue on the other replicas; the node is rebuilt from them. |
| The ID allocator | No new blocks are handed out. | Servers live on their blocks; run the allocator on three nodes with a consensus store. |
| The click queue | Counts stop updating. | Redirects are unaffected, which is the point of making counting asynchronous. |
What a strong answer sounds like
Every level gets to a working design. What differs is how much of it is driven by the numbers and how the trade-offs are argued.
| Level | What the answer does |
|---|---|
| Junior | Draws a service and a database, hashes the URL for a code, and gets a working path once prompted about collisions. |
| Mid | Estimates traffic and storage, separates reads from writes, adds a cache with a reason, and compares two code schemes. |
| Senior | Leads with the read/write ratio, chooses 302 because of click counts, takes counting off the hot path, explains what fails first at 10x, and names what they would leave out and why. |
Why not just hash the URL?
I could, but 7 characters of a hash collide, so I would still need a check-and-retry on every insert, and two users would share a code. Counter blocks give me no collisions with one allocator call per thousand links. The cost is guessable codes; if that matters here, I would scramble the number before encoding.
Follow-up questions
How would you run this in several regions?
Serve redirects from every region with a local cache and a local replica of the store; replication lag only delays brand-new links by a second or two. Give each region its own ID ranges so creates never coordinate across regions.
The same long URL is submitted twice. Same code?
Not by default. Deduplication needs an index on the long URL and lets one user's expiry or deletion affect another's link. Offer it per user if asked.
How do you stop abuse?
Rate limit creates per API key and per IP, check new URLs against a blocklist, and scan them asynchronously so a bad link can be disabled after the fact.
How do you delete a link quickly?
Mark it deleted in the store and delete the cache key. Because redirects use 302, browsers have not cached it, so the next click sees the change.
What if one link gets a million clicks a minute?
That is about 17,000 a second on one key. Keep a copy in each redirect server's memory for a few seconds so the cache node holding it is not overloaded.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".