LearnHLDDesign a URL shortener

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

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.

In scope
  • 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.
The numbers you commit to
  • 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.
Cut, and say so out loud
  • 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.

Napkin math
100 million
100 to 1
5 years
Writes100M / 86,400s = 1,157 per second
Reads1,157 x 100 = 115,741 per second
Links stored100M x 365 x 5 = 182.5 billion
Storage at 500 bytes a row91 TB
Shortest base62 code with 10x headroom62^7 = 7 characters
115,741 reads/sec
the number that decides every other choice on this page

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.

The number people get wrong

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.

POST/api/v1/urls
{
  "longUrl": "https://indgeek.com/learn/hld/design-a-url-shortener",
  "customAlias": null,
  "expiresAt": "2031-08-25T00:00:00Z"
}
returns 201 { "code": "kY7pQ2m", "shortUrl": "https://ig.ee/kY7pQ2m" }
Why: POST and not PUT, because the server picks the code. Send an Idempotency-Key header with it. A client that retries on a timeout should get the same code back, not burn a second one and leave an orphan row behind.
GET/{code}
returns 302 Location: https://indgeek.com/...
Why: 302 and not 301. A 301 gets cached by the browser forever, which means you never see the click again and you can never change or revoke the link. The extra round trip is the product.
GET/api/v1/urls/{code}
returns 200 { code, longUrl, createdAt, expiresAt, clicks }
Why: Metadata lives on a different path from the redirect so the hot path stays a single lookup with no joins and no auth check.
DELETE/api/v1/urls/{code}
returns 204
Why: Soft delete. The row stays, a flag flips, and the cache entry is dropped. Hard deleting frees a code for reuse, and a reused code sends old traffic to a new destination, which is how a link shortener becomes a phishing tool.

The data model

One table carries the entire product.

urlsMySQL, or any KV store
codechar(7)PKEvery read is by this. It is the primary key, not a surrogate.
long_urlvarchar(2048)2KB covers browser limits with room left over.
owner_idbigintIDXOnly for the list-my-links screen, never for a redirect.
created_attimestampAlso the shard hint if you ever range partition by time.
expires_attimestampIDXA nightly job sweeps this. TTL in the cache handles the live path.
is_activebooleanSoft delete. Checked on a cache miss, cached with the row.
Sample row
kY7pQ2m | https://indgeek.com/learn/hld | 4471 | 2026-08-25 | 2031-08-25 | true
No auto increment id column. Adding one costs 8 bytes on 180 billion rows and buys you nothing, because nothing ever looks a row up by it.

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.

Figure 1. The create path runs across the top and is allowed to be slow. The redirect path runs across the bottom and is not.

Walking Figure 1 in order:

  1. The client hits the load balancer. Both paths enter here.
  2. 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.
  3. 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.
  4. It writes the row, then writes the entry into Redis so the first click is warm.
  5. A redirect request goes to the redirect tier, which is stateless and much larger.
  6. It reads Redis. This is where 99 requests out of 100 end.
  7. 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.

1/4 A create host asks for a block of 1,000 ids and gets one. At 1,200 creates a second across the fleet that is roughly one write a second to a single row, which any database will do in its sleep.
Figure 2. Step through it. The counter is touched about once a second by the whole fleet, and nothing after that needs a network call.

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.

The follow up you will get

“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.

Figure 3. The redirect returns as soon as the event is handed to the local producer buffer. Nothing downstream can slow a user down.

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.

Redirect traffic
5k/s
5k/s60k/s120k/s250k/s400k/s
Healthy. Eight redirect hosts, one Redis cluster, one primary with a replica. Nothing here is interesting, which is the whole point of the design.

The repair takes one box and about forty lines of code.

Figure 4. A one second local cache in front of Redis. The viral key is now answered from process memory, and Redis sees one request per host per second for it.
Where this shows up outside interviews

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

ChoiceWhat you gainWhat you payPick it when
302 over 301Every 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 codesNo 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 throughThe 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 invalidationKills 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

Interviewer
Take me through what happens when someone clicks a short link.
Opening move. They want to know if you go to the read path first or waste ten minutes on the create path.
You
It hits the load balancer, goes to any redirect host since they hold no state, and that host looks the code up in Redis. Roughly 99 times out of 100 it is there and we return a 302. On a miss we read the row, put it back in Redis, and return. No writes, no joins, one network hop on the happy path.
Names the hit ratio and the shape of the miss without being asked. That is the difference between describing a diagram and understanding one.
Interviewer
Why 302 and not 301?
A test. There is a right answer and it is not about performance.
You
A 301 gets cached by the browser more or less permanently, so we stop seeing the clicks and we can never revoke or retarget the link. Both of those are product features here, so we pay for the extra round trip.
Trade stated as a choice with a cost. Short.
Interviewer
One link is getting 400,000 requests a second. What happens?
The real question of the round. Everything before this was warm up.
You
Redis shards by key, so all of that lands on one shard and that shard runs out of CPU while the rest sit idle. Adding shards does not help because the key cannot be split. I would put a small in-process cache on each redirect host with a one second TTL. The link is immutable in practice, so a second of staleness costs nothing, and Redis drops from 400,000 requests a second on that key to about eight.
Diagnoses before prescribing, and explains why the obvious fix does not work. Says the number the fix produces.
Interviewer
How would you pick that TTL?
Open ended on purpose. They are checking whether you invent a confident number.
You
I would not pick it from first principles. I would start at one second because it is obviously safe for immutable data, then look at how much Redis traffic remains and whether anyone complains about delete latency. If deletes need to be instant I would add a small invalidation broadcast rather than shrinking the TTL, since that scales better. I have not run this at that size, so I would want the numbers before committing.
Admitting the limit of your experience while still giving a method is a scoreable answer. Guessing here is not.

Checkpoint

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?

Say this in 60 seconds

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.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.