Design a URL shortener
A short link is seven characters of text that has to survive being pasted into a group chat and opened by half a million people in an afternoon. The service behind it does almost nothing: take a code, find a URL, return a redirect. That is the entire feature.
Which is exactly why it opens so many interviews. There is nowhere to hide. Get the code generation wrong and you hand out the same link twice. Get the read path wrong and your database falls over at a fraction of the traffic you just promised to handle.
Before you scroll: where does the short code come from, and what breaks the moment you run a second copy of the service that creates them?
What you are building
Spend three minutes here, not ten. The scope below is the one that fits a 45 minute round. Say the cuts out loud, because an unstated cut reads as something you forgot.
- Create a short link from a long URL. Optionally with a 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.
- Count clicks. Asynchronously, and never in the redirect path.
- 100 million new links a day.
- Roughly 100 reads for every write.
- Redirect under 100ms at p99, measured at the edge.
- Links live for 5 years and then expire.
- User accounts, billing and quotas.
- Link previews and malware scanning, which is a separate pipeline with its own scaling story.
- A dashboard. Clicks land in an analytics store, and what reads that store is somebody else problem today.
- Editing the target of an existing link. Say no, then explain that 301 caching makes it a lie anyway.
The numbers
Drag these. The point is not the default answer, it is watching which decision each input actually 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 building a cache with a database attached, not a database with a cache in front.
Candidates almost always compute storage and stop. Storage is the boring answer here. A few dozen terabytes is a solved problem you can buy. The interesting number is the read rate, because it is what forces the cache, the read replicas and the stateless redirect tier into the design.
The API
Four endpoints. Each one has exactly one 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. TTL in the cache handles the live path. |
| is_active | boolean | Soft delete. Checked on a cache miss, cached with the row. |
Clicks do not belong in this table. One row per click turns a system doing about 1,200 writes a second into one doing 116,000, and it puts a write on the path of every single redirect. Clicks get their own pipeline, further down.
The design
Here is the whole thing at the level of detail you would actually draw on a whiteboard. Two paths share the front door and then have almost nothing in common: the write path on top, the read path underneath.
Walking Figure 1 in order:
- The client hits the load balancer. Both paths enter here.
- A create request goes to the create tier, which is stateless and small. About 1,200 requests a second spread over three hosts is nothing.
- The host needs an id. It does not ask the database. It uses an id from a block it reserved earlier, which is the next section.
- It writes the row, then writes the entry into Redis so the first click is warm.
- A redirect request goes to the redirect tier, which is stateless and much larger.
- It reads Redis. This is where 99 requests out of 100 end.
- On a miss it reads the database, backfills Redis, and returns. A miss is the only time this system touches disk.
Note what is not in the picture. No queue in the redirect path. No service mesh. No search index. Every box in Figure 1 exists because a number from the napkin math put it there.
Deep dive one: where the code comes from
This is the part interviewers actually push on, and the part most answers get wrong.
The obvious approach is to hash the long URL, take the first seven base62 characters and call it done. It fails on two counts. Two different people shortening the same URL should usually get two different links, since they want separate click counts. And truncated hashes collide: with 62^7 slots, a birthday collision becomes likely somewhere around a few million links, which you will hit 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 generation has the same problem in a nicer suit. It works fine while the table is empty and quietly degrades forever after.
The approach that scales does not look at the table at all.
Be honest about what the scramble buys you. It hides the sequence from a casual observer, and it is reversible by anyone who collects a few codes and does some algebra. If your 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.
“What happens when the counter service is down?” Every create host has up to 1,000 ids in hand, so creates keep working for roughly a second per host of buffered capacity. Raise the block size to 100,000 and an outage of several minutes is invisible. The cost is that you burn through the id space faster after restarts, and 62^7 gives you room to waste.
Deep dive two: the read path, and the one key that ruins it
The redirect is a cache lookup. Cache aside, a TTL of a day, and the row is written into Redis at create time so even the first click is a hit. At a 99% hit ratio the database sees around 1,200 reads a second, which one primary and one replica handle comfortably.
That works right up until a single link goes viral. Then 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 that key is at 100% CPU, and the other eleven are 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 served from a local hash map. One second of staleness on a link that was created hours ago is not a problem anybody has. Redis sees at most one request per host per second for that key instead of 400,000.
You will see this exact shape in the next section.
Deep dive three: counting clicks without slowing the redirect
Every redirect wants to record an event. Doing it synchronously means the user waits for a write before they get their 302, and it means a clicks table taking 116,000 writes a second sits directly in the path 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 of events in a crash is fine, and saying so is a stronger answer than pretending you can have durability for free. If the question were payments instead of clicks, the answer would flip, and the write would go 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 a single 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-write hurts. |
| Cache aside over write through | The cache never blocks a write, and a cache outage degrades 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. 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.
