LearnHLDDesign a URL shortener

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 askThey sayWhat 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.

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

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 reading far more than you write, which decides the shape of the whole design.

The number people get wrong

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.

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 times out and retries should get the same code back, not burn a second one and leave an orphan row behind. If customAlias is set, this is the one request that can come back 409.
GET/{code}
returns 302 Location: https://indgeek.com/...
Why: 302 and not 301. A 301 gets cached by the browser more or less forever, so you stop seeing the clicks and you can never revoke the link. The extra round trip is the product. An unknown or expired code returns 404 from the same cheap lookup, never a database scan.
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 one lookup with no joins and no auth check. Different path also means different rate limits.
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 the code for reuse, and a reused code points old traffic at a new destination.

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. The cache TTL 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
A custom alias needs no extra column. It is just a code that a human chose instead of the generator, so the primary key does the uniqueness check for you and returns a 409 when it fails.

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.

Figure 1. Everything above the load balancer row is the write path, about 1,200 requests a second. Everything below it is the read path, about 116,000. They share nothing but the front door and one database.

Walking Figure 1 in order:

  1. 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.
  2. A create goes to the create tier. It is stateless and small: 1,200 requests a second across three hosts is nothing.
  3. 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.
  4. The ID range service is the only thing touching the counter row, and it touches it about once a second for the whole fleet.
  5. The create writes the row, then writes the same entry straight into Redis so the very first click is already warm.
  6. 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.
  7. The host reads Redis. About 99 requests in 100 end here, in roughly a millisecond, and the user gets a 302.
  8. 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.
  9. The click is published to an event log and the response goes out immediately. Nothing downstream can make a user wait.
  10. The aggregator rolls raw events into one minute windows.
  11. Those windows land in a columnar store, which is what a dashboard would read.
  12. 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.

1/7 The idempotency key is the client saying "if you already did this, do not do it twice". A retry after a timeout gets the first code back instead of burning a second one.
Figure 2. Creating a link touches four systems and waits for two of them. The id comes out of memory, so the common create never asks another service for anything.
Your answer

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.

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 about one write a second to a single row, which any database will do in its sleep.
Figure 3. The counter is touched about once a second by the entire fleet. Everything after that happens in memory on one host, with no network call and no lock.

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.

The follow up you will get

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

1/8 Any host will do. The redirect tier holds no state, so the load balancer can send this anywhere, including a host in another region.
Figure 4. One click. The cache answers 99 times in 100, the click event is fired without anybody waiting for it, and the browser makes a second request of its own to reach the target.

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.

Figure 5. The redirect returns the moment the event is handed to the local producer buffer. Nothing downstream of that arrow 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 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.

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 6. A one second local cache in front of Redis. The viral key is answered out of process memory, and Redis sees one request per host per second for it instead of 400,000.
Where this shows up outside interviews

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

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 every write hurts.
Cache aside over write throughThe 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 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 is left 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. 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.

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