Design a URL shortener
A short link is seven characters of text. Someone drops it into a WhatsApp group, the group forwards it twice, and by dinner half a million people have opened it. Behind those seven characters is a service that does exactly one thing: take a code, find a URL, send the browser there.
That is why it opens so many interviews. There is nowhere to hide. Get the code wrong and two people walk away with the same link. Get the read path wrong and your database falls over at a tenth of the traffic you just promised to handle.
The order below is the order to use in the room. Ask, count, draw, then defend.
Step 1: Understand the problem
The worst opening move is drawing a box. Spend the first three minutes asking questions instead, because every answer moves a line in the design you are about to draw.
Here are the six that earn their time, with the replies you will usually get and what each one settles.
| You ask | They say | What it settles |
|---|---|---|
| How many links a day, and how many clicks per link? | About 100 million new links a day, and reads run roughly 100 times writes. | Everything else on this page. At 100 to 1 you are building a cache with a database attached, not a database with a cache bolted on. |
| How short does the link have to be? | As short as you can make it while staying unique for years. | Seven characters, and you can prove it in one line of arithmetic instead of guessing. That comes two blocks down. |
| Can people pick their own code, like ig.ee/holi-sale? | Yes, an optional custom alias, but only a small slice of links use it. | A second path through the create flow, and the only place where a create is allowed to fail with a conflict. Everything else stays collision free by construction. |
| Do links expire, and can they be deleted? | Five years by default, and the owner can delete one early. | An expiry column, a nightly sweeper, and a hard rule that a retired code is never handed out again. Reusing a code sends old traffic to a new destination, which is how a shortener turns into a phishing tool. |
| Do we need click analytics, and how fresh? | Counts and rough geography. A minute behind is fine. | Clicks go to an event log, never into the redirect request. A minute of allowed lag is permission to batch, and batching is what keeps the redirect fast. |
| One region or the whole world? | India and the US to start. | The redirect tier runs in both regions. Creates stay in one, because 1,200 writes a second do not need a second home and multi region writes would cost you a week of the interview you do not have. |
Not one of those questions was about technology, and that is the point. You now know the read ratio, the code length, the failure mode that matters, and where the servers live.
What you are building, and what you cut
Say the cuts out loud. An unstated cut reads as something you forgot.
- Create a short link from a long URL. With an optional custom alias and an expiry date.
- Redirect a short link to its target. This is 99% of the traffic and all of the latency budget.
- Delete a link, and expire it after five years. The code retires with it and never comes back.
- Count clicks. Off to the side, and never in the redirect path.
- 100 million new links a day.
- Roughly 100 reads for every write.
- Redirect under 100ms at p99, measured close to the user.
- Links live for 5 years and then expire.
- User accounts, billing and quotas. Assume an authenticated caller and move on.
- Malware and phishing scanning. Real products need it, it is its own pipeline, and it does not change the shape of anything here.
- An analytics dashboard. Clicks land in an analytics store and what reads that store is somebody else's problem today.
- Editing the target of a live link. Say no, and explain that browser caching makes the promise a lie anyway.
Back of the envelope
Drag these. The point is not the default answer, it is watching which decision each input controls.
Two things fall out of this. A 7 character code is enough, so stop worrying about running out. And at 100 to 1 you are reading far more than you write, which decides the shape of the whole design.
Almost everyone computes storage and stops there. Storage is the boring answer. A few dozen terabytes is a solved problem you can buy with a credit card. The number that matters is the read rate, because it is what puts the cache, the read replica and a fat stateless redirect tier into the picture.
Step 2: Propose the high level design
The API
Four endpoints. Each one has a single decision in it worth defending.
{
"longUrl": "https://indgeek.com/learn/hld/design-a-url-shortener",
"customAlias": null,
"expiresAt": "2031-08-25T00:00:00Z"
}The data model
One table carries the entire product.
| code | char(7) | PK | Every read is by this. It is the primary key, not a surrogate. |
| long_url | varchar(2048) | 2KB covers browser limits with room left over. | |
| owner_id | bigint | IDX | Only for the list-my-links screen, never for a redirect. |
| created_at | timestamp | Also the shard hint if you ever range partition by time. | |
| expires_at | timestamp | IDX | A nightly job sweeps this. The cache TTL handles the live path. |
| is_active | boolean | Soft delete. Checked on a cache miss, cached with the row. |
There is no auto increment id here on purpose. It would cost 8 bytes across 180 billion rows and buy you nothing, because nothing ever looks a row up by it.
Clicks do not belong in this table either. One row per click turns a system doing about 1,200 writes a second into one doing 116,000, and it drops a write into the path of every redirect. Clicks get their own pipeline, further down.
The whole system on one whiteboard
Figure 1 is the design, at the level of detail you would actually draw in 45 minutes. Two paths share a front door and then have almost nothing in common. Creates run across the top and are allowed to be slow. Redirects run across the bottom and are not.
Walking Figure 1 in order:
- Every request enters at the load balancer, which terminates TLS and rate limits by caller. Creates are the expensive request, so the limit lives here rather than in your application code.
- A create goes to the create tier. It is stateless and small: 1,200 requests a second across three hosts is nothing.
- The host needs an id and does not ask the database for one. It takes the next id from a block of 1,000 it reserved earlier. That block is the subject of the first deep dive.
- The ID range service is the only thing touching the counter row, and it touches it about once a second for the whole fleet.
- The create writes the row, then writes the same entry straight into Redis so the very first click is already warm.
- A redirect goes to the redirect tier, which is stateless and much bigger. Nothing here remembers anything, so you scale it by adding hosts and you deploy a copy of it in every region you serve.
- The host reads Redis. About 99 requests in 100 end here, in roughly a millisecond, and the user gets a 302.
- On a miss it reads the database, backfills Redis, and returns. A miss is the only time this system touches a disk. If the code does not exist at all, that is a 404 from the same lookup.
- The click is published to an event log and the response goes out immediately. Nothing downstream can make a user wait.
- The aggregator rolls raw events into one minute windows.
- Those windows land in a columnar store, which is what a dashboard would read.
- A nightly sweeper clears rows past their expiry. The live path never scans for expired links, it just stops finding them.
Notice what is missing. No queue in the redirect path. No service mesh. No search index. Every box in Figure 1 is there because a number from the napkin math put it there, and you should be able to name that number for each one.
Boxes and arrows show you what exists. They do not show you who waits for whom, and that is the thing an interviewer is listening for. Figure 2 is the create path in the order it actually happens. Press play, or step through it.
Before the deep dives: where does the short code come from, and what breaks the moment you run a second copy of the service that creates them?
Step 3: Design deep dive
Where the short code comes from
This is the part interviewers push on, and the part most answers get wrong.
The tempting approach is to hash the long URL, take the first seven base62 characters and call it done. It fails twice over. Two people shortening the same URL usually want two different links, because they want separate click counts. And truncated hashes collide: in 62^7 slots a collision becomes likely somewhere around a few million links, which you will pass on day one. You can check the table and retry, but now every create is a read plus a write, and the retry rate climbs as the table fills.
Random codes have the same problem in a nicer suit. Fine while the table is empty, quietly worse forever after.
The approach that holds up never looks at the table at all. Figure 3 steps through it.
Be honest about what the scramble buys you. It hides the sequence from a casual observer, and anyone who collects a handful of codes and does some algebra can reverse it. If the requirement is that links must be unguessable by a motivated attacker, you need real random bits and the collision check that comes with them. Say that trade out loud rather than letting the interviewer find it.
Custom aliases skip this machinery entirely. The user supplies the code, you insert it, and the primary key either accepts it or throws a duplicate key error you turn into a 409. No lookup first, because a lookup then an insert is a race between two users typing the same alias at the same moment.
“What happens when the counter service is down?” Every create host is holding up to 1,000 ids, so creates carry on for about a second per host of buffered capacity. Raise the block size to 100,000 and an outage of several minutes goes unnoticed. The cost is that you burn through the id space faster after every restart, and 62^7 leaves you plenty of room to waste.
The read path, and the one key that ruins it
The redirect is a cache lookup and little else. Cache aside, a one day TTL, and the entry written into Redis at create time so even the first click is a hit. At a 99% hit ratio the database sees about 1,200 reads a second, which one primary and one replica handle without noticing.
Figure 4 is one click, end to end. The last two messages are the ones people forget, and they are the whole argument for a 302.
All of that holds right up until a single link goes viral. Now the traffic is not spread across millions of keys. It is 400,000 requests a second for one key, and one key lives on exactly one Redis shard. Adding shards does not help. The shard that owns the key sits at 100% CPU while the other eleven idle.
The fix is unglamorous and it works: cache the hottest entries in the memory of the redirect process itself, with a TTL of one second. A viral link is then served out of a local hash map. One second of staleness on a link created hours ago is not a problem anybody has, and Redis drops from 400,000 requests a second on that key to one per host per second.
You will watch that break, and then get repaired, under Break it below.
Counting clicks without slowing the redirect
Every redirect wants to record an event. Do it synchronously and the user waits on a write before they get their 302, and a clicks table taking 116,000 writes a second sits directly in front of your most important request.
So do not do it synchronously.
The trade you are making: if a redirect host dies with events still in its buffer, those clicks are gone. For a click counter, losing a handful in a crash is fine, and saying so is a stronger answer than pretending durability is free. If the question were payments instead of clicks, the answer flips, and the write goes into the request path where it belongs.
Break it
Drag the traffic up. Watch which box goes first, and read why it was that one.
The repair takes one box and about forty lines of code.
The hot key problem is not a URL shortener quirk. It is the same shape as one product page during a sale, one match in a live scores feed, and one celebrity account in a social graph. The answer is nearly always the same: move the hottest slice one layer closer to the request and accept a small window of staleness.
Trade-offs
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| 302 over 301 | Every click reaches your servers, so analytics work and a link can be revoked or retargeted later. | You pay for every redirect forever, and browsers never help you out by caching. | Almost always. Pick 301 only if the link is permanent, analytics do not matter, and you want the traffic off your bill. |
| Counter with ranges over random codes | No collision check, so a create is one write. Throughput does not degrade as the table fills. | A counter service to run, and codes that are recoverable by someone who works at it. | Any system where creates are frequent enough that a read before every write hurts. |
| Cache aside over write through | The cache never blocks a write, and a cache outage costs you latency instead of breaking creates. | A window after each create where a click can miss, which you close by writing the entry at create time. | Read heavy systems where the cache is an optimisation and not the source of truth. |
| One second local cache over strict invalidation | Kills the hot key problem outright, at almost no operational cost. | Up to a second of staleness on a deleted or retargeted link, on each host independently. | The data is effectively immutable after creation, which is exactly the case here. |
Interview replay
Checkpoint
1. At 100 million new links a day kept for five years, why is a six character code not enough?
2. Why multiply the counter value by a constant modulo 62^7 instead of generating a random code?
3. Same system, but a partner starts bulk importing and writes now outnumber reads ten to one. What is the first thing that changes?
A URL shortener is a read heavy system, about a hundred to one, so I would design it as a cache with a database attached. Creates go to a small stateless tier that gets ids from a counter service in blocks of a thousand, scrambles each id with a modular multiply so the codes are not walkable, and base62 encodes it to seven characters. That means no collision checks and one write per create. Custom aliases skip the generator and let the primary key reject duplicates. Redirects go to a large stateless tier that reads Redis and returns a 302, falling back to the database on a miss and backfilling. Clicks are published to an event log and aggregated offline, never in the request path. The failure mode I would call out is a single viral link, since it lands on one Redis shard and no amount of sharding helps, and I would fix that with a one second in-process cache on each redirect host.
