# Design a URL Shortener: Where Every Decision Comes From the Read Path (2026)

> The classic warm-up problem, walked end to end in 45 minutes. Short codes, a hundred reads for every write, 301 versus 302, and the 2026 twist of abuse, with diagrams that grow as the design does.

*Source: https://www.infowok.com/design-a-url-shortener/ · Sukhveer Kaur · Published October 19, 2026*

---

"Design a URL shortener." It sounds like a warm-up, and that is the trap. The candidate who treats it as easy draws a database, a hash function and an arrow, and is done in ten minutes. Then the interviewer asks what happens at twelve thousand clicks a second, why a cached link still points at a phishing page, and whether codes can be guessed in order. The remaining thirty-five minutes go badly.

This problem is a favourite because it is small enough to finish and deep enough to separate levels. **Every important decision in it comes from one fact: people click links far more often than they create them.** This lesson walks the full 45-minute round with the course framework, and the design grows one diagram at a time.

<Prerequisites>

- [The system design interview framework](/system-design-interview-framework-2026/) and [back-of-envelope estimation](/back-of-envelope-estimation-primer/), Module 0 of this course.
- [Caching strategies](/caching-strategies/) helps for the read-path deep dive.

</Prerequisites>

<Callout type="note" title="Remember from lesson 2">
For 100 million new links a month with 100 reads per write, what did the estimate decide? Reads shape the design, so a cache earns its place, and a six-character code is enough.
</Callout>

<KeyTakeaways>

- Reads outnumber writes about a hundred to one, so the read path decides the architecture.
- Short codes come from a unique number encoded in base62. Hand out numbers in blocks, and scramble them if codes must not be guessable.
- Return 302, not 301, unless you never need click counts, expiry or takedowns.
- A cache in front of the database serves most redirects; the database is the source of truth.
- In 2026, abuse is part of the design: rate-limit creation and check destinations.

</KeyTakeaways>

<LessonOutcome>

Design a URL shortener end to end in 45 minutes: pick a code-generation scheme and defend it, size and cache the read path, choose the redirect status code, and handle expiry and abuse.

</LessonOutcome>

## Step 1: Pin Down the Problem

Start with three questions out loud: what must it do, what will it not do, and how big is it? Write the answers where the interviewer can see them.

**Functional requirements:**

1. Users can submit a long URL and get a short link back.
2. Anyone opening the short link is redirected to the long URL.
3. Users can choose a custom alias and an expiry time (optional).

**Out of scope** (say these out loud so the interviewer can object):

- Accounts and login, editing a link after creation, analytics dashboards.

**Non-functional requirements**, each with a number. These are assumptions you announce, not facts:

- Redirects are fast: under 100 ms on the server for most requests.
- Redirects are highly available. A broken short link breaks every page that contains it.
- Codes are unique, and a code never silently points somewhere new.
- Scale: 100 million new links a month, 100 reads for every write.

The last line is the one to underline. It says this is a read-heavy system, and almost everything that follows is about serving reads.

## Step 2: Entities and API

```text
ShortLink  code, long_url, created_at,
           expires_at, owner_id

POST /links   { long_url, alias?, expires_at? }
              -> 201 { code, short_url }
GET  /{code}  -> 302, Location: long_url
              -> 404 unknown · 410 expired
```

One entity and two endpoints. The redirect status code in that sketch is a decision, not a detail, and step 5 comes back to it.

## Step 3: A Design That Works

Walk the endpoints one at a time. `POST /links` needs something to create a unique code and somewhere to store it. `GET /{code}` needs to look the code up and return a redirect. That is a service and a database, nothing more.

One detail belongs here, not in a deep dive: **validate and normalise the long URL before storing it.** The standard URI rules ([RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-6)) give you most of it:

- accept only `http` and `https`;
- lowercase the scheme and host, which are case-insensitive;
- drop a default port such as `:80`, and treat an empty path as `/`.

`HTTP://Example.com:80` and `http://example.com/` are then stored as one destination, and the abuse checks later see the real host.

![First working design: a client sends POST /links and GET /{code} to a URL service, which stores and reads code to long URL mappings in a database](./design-a-url-shortener-v1.svg)

This design is correct. It is also where many candidates stop. Before reading on, find its weak point.

<Callout type="tip" title="Predict first">
At 12,000 redirects a second at peak, which part of this picture breaks first, and why? Commit to an answer, then check it against step 4.
</Callout>

## Step 4: Estimate What Matters

These are the same assumptions as lesson 2, so the numbers carry over.

```text
Writes   100M / 2.5M s     ≈ 40 /s       peak ≈ 120 /s
Reads    40 x 100          ≈ 4,000 /s    peak ≈ 12,000 /s
Storage  100M x 500 B x 60 months ≈ 3 TB
         x 3 replicas      ≈ 9 TB
IDs      100M x 60 months  ≈ 6 billion
         62^6 ≈ 56.8 billion   62^7 ≈ 3.52 trillion
```

Each line ends in a decision. Writes at 120 a second are light, so code generation does not need heroics. Reads at 12,000 a second all hit the same lookup, so the database read path is what breaks first: **put a cache in front of it.** Storage of 9 TB with replicas is real but not urgent. Six characters cover the IDs; seven leave room to scramble them.

## Step 5: Deep Dives

Three questions decide this design. Pick them in this order unless the interviewer steers you elsewhere.

### How Do We Generate Unique Short Codes?

A short code is a number written in base62. The question is where the number comes from.

<DesignOption tier="works" title="Hash the long URL">
Hash the long URL and keep the first seven base62 characters. It needs no shared state, and the same URL always gets the same code.
<Tradeoff>

Different URLs can collide on seven characters, so every write must check the database and retry with a salt. Near six billion stored links, about one insert in 600 collides (6 billion ÷ 3.52 trillion).

Two users shortening the same URL also share a code, so one user's expiry affects the other.

</Tradeoff>
</DesignOption>

<DesignOption tier="scales" title="One global counter, encoded in base62">
Keep a counter in a store with an atomic increment, such as Redis `INCR`, which is O(1) and works on 64-bit integers ([Redis](https://redis.io/docs/latest/commands/incr/)). Each new link takes the next number and encodes it. No collisions, no retries.
<Tradeoff>Every write now calls one counter, which is a single point of failure on the write path. And the codes come out in order: `aaaab`, `aaaac`. Anyone can walk the whole keyspace and read every link people assumed was private.</Tradeoff>
</DesignOption>

<DesignOption tier="survives" title="Number blocks per server, scrambled before encoding">
Each URL service instance reserves a block of numbers from the counter, say 1,000 at a time, and hands them out from memory. Before encoding, pass the number through a keyed, reversible scramble over the 7-character space, so neighbouring numbers produce unrelated codes.
<Tradeoff>A server that crashes wastes the rest of its block, which is harmless in a space of 3.5 trillion codes. The counter is touched once per thousand writes, so it can fail for minutes without stopping link creation. The scramble key becomes a secret you must protect and never change.</Tradeoff>
</DesignOption>

![Code generation with number blocks: a counter store hands blocks of 1,000 numbers to each URL service instance, which scrambles each number and encodes it in base62](./design-a-url-shortener-codes.svg)

Custom aliases skip the counter: check the alias is free with a conditional insert, and reserve a prefix or minimum length so aliases cannot collide with generated codes.

### How Do We Serve 12,000 Redirects a Second?

<Concept href="/caching-strategies/" title="Caching strategies">
The same small set of popular links is read again and again and almost never changes. That is the textbook case for cache-aside.
</Concept>

<DesignOption tier="works" title="Look up the database on every click">
The code is the primary key, so each redirect is one indexed lookup.
<Tradeoff>Every click is a database round trip. At peak, the database spends its capacity answering the same popular codes repeatedly.</Tradeoff>
</DesignOption>

<DesignOption tier="scales" title="Cache-aside in memory">
Look the code up in an in-memory cache first; on a miss, read the database and store the result with a time-to-live. Popular links stay hot, and the database sees mostly misses.
<Tradeoff>A cached entry can outlive a change. Cap the time-to-live at the link's remaining lifetime, and delete the entry when a link is taken down.</Tradeoff>
</DesignOption>

<DesignOption tier="survives" title="Cache plus replicas across zones">
Run the cache and read replicas of the database in more than one availability zone, with the primary handling writes. Losing a zone loses capacity, not links.
<Tradeoff>Replicas lag the primary slightly, so a link read in the first moments after creation may miss. Read your own writes from the primary, or fall back to it on a miss.</Tradeoff>
</DesignOption>

<Callout type="key" title="One step further: answer at the edge">
At very high or very global traffic, move the redirect itself to a CDN. Edge platforms run small functions close to users that can return a redirect directly, for example a Cloudflare Worker calling `Response.redirect` ([Cloudflare](https://developers.cloudflare.com/workers/examples/redirect/)). Popular codes then never reach your servers. The catch is control: a 302 is not cached unless you send explicit cache headers ([RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-15.4)), so give edge entries a short `max-age` and purge them on takedown. See [CDNs and object storage](/cdn-object-storage/).
</Callout>

![Read path: requests reach the URL service, which checks an in-memory cache first and falls back to database replicas, with the cache and replicas spread across availability zones](./design-a-url-shortener-reads.svg)

### Which Database?

<Concept href="/sql-vs-nosql/" title="SQL vs NoSQL">
The access pattern is one key, one value. A relational database with the code as primary key handles this scale; a key-value store is the natural next step once you partition by code.
</Concept>

### 301 or 302? The Status Code Is a Design Decision

The redirect status code decides who remembers the mapping.

| | 301 / 308 (permanent) | 302 / 307 (temporary) |
|---|---|---|
| Browser may cache it | yes, by default | no, unless you say so |
| Later clicks reach you | maybe not | yes |
| Click counts, expiry, takedown | lost for cached clients | work |
| Load on your servers | lower | every click |

The difference comes straight from the HTTP standard. A 301 response "is heuristically cacheable", so a browser may reuse it without asking again, while 302 and 307 are not unless explicit cache headers allow it ([RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-15.4)). The same section notes that browsers may turn a POST into a GET on 301 and 302, which 307 and 308 prevent; for links opened with GET that does not matter.

**Default to 302.** You keep every click, links can expire, and a link you take down stops working for everyone. Choose 301 only for links that are truly permanent and where you never need those controls.

### What Happens When a Link Expires?

Return **410 Gone** rather than 404. It tells clients the link was removed on purpose and is likely gone for good, while 404 is meant for cases where the server cannot say ([RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-15.5.11)). Then:

- **Check `expires_at` on every read**, so a link stops at the right moment even before cleanup runs.
- **Cap cache entries** at the link's remaining life.
- **Delete expired rows in small batches** with a background job in quiet hours, so cleanup never competes with redirects.

![Stopping a link at every layer: the edge purges the code on takedown and uses a short max-age, the cache deletes the entry and caps its TTL at the remaining life, and the database checks expires_at on every read and returns 410 Gone](./design-a-url-shortener-takedown.svg)

<Callout type="key" title="Takedown uses the same three layers">
Each layer has to forget the link on its own, which is why the 302 matters: with it, no browser holds a copy you cannot reach.
</Callout>

### Scaling Further: Viral Links, More Regions

Two things change when the system grows past the estimate.

**A link goes viral.** Every request now asks for the same key, and one cache node takes the whole load. This is a hot key. Copy hot entries to several cache nodes or keep them in each service's local memory for a few seconds, and let the edge absorb the rest.

**The service spans regions.** Three changes keep writes local and reads fast:

- **Split read and write services** and scale them separately. Reads are stateless and grow far faster.
- **Give each region its own number range**, so regions never coordinate on writes.
- **Run the counter store with automatic failover.** Redis Sentinel, for example, promotes a replica when the primary fails ([Redis](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/)). A failover may skip part of a block, which is harmless, and a UNIQUE constraint on `code` stays the final safety net.

New links copy to the other regions asynchronously. A click that lands in another region before its link arrives can still be served: reverse the scramble to get the number, and the range it falls in names the home region to ask.

![Two regions, each with its own write service, ID allocator holding a separate number range, read service and database, connected by asynchronous replication of new links](./design-a-url-shortener-regions.svg)

Interviewers often ask for alternatives to the counter. Know these four:

| Scheme | How it works | Why or why not here |
|---|---|---|
| Number blocks (this design) | counter hands out ranges | short codes, one rare dependency |
| Snowflake-style IDs | 41-bit time + 10-bit machine + 12-bit sequence, no coordination ([Twitter](https://github.com/twitter-archive/snowflake/tree/snowflake-2010)) | about 11 base62 characters: too long for a short link |
| Pre-generated key pool | a separate service fills a pool of unused random codes | random codes, but one more service to run |
| Hash + Bloom filter | a Bloom filter answers "definitely unused" without a database read ([Wikipedia](https://en.wikipedia.org/wiki/Bloom_filter)) | cuts collision checks; false positives cost a retry |

### The 2026 Twist: Abuse

A shortener hides the destination, which makes it useful to anyone sending a phishing link. Ask early whether abuse is in scope; at senior level and above, raise it yourself. The plan:

- **Rate-limit link creation** per user and per IP address. See [rate limiting](/rate-limiting-explained/).
- **Check the destination** when the link is created, using a URL-reputation service. Google's Web Risk, for example, checks URLs against lists of phishing, malware and unwanted-software sites, and warns that it can miss risky sites or flag safe ones ([Google Cloud](https://docs.cloud.google.com/web-risk/docs/overview)).
- **Re-check popular links later**, because a page can turn malicious after the link is made.
- **Make takedowns instant**: mark the link disabled, delete it from the cache, and rely on 302 so browsers ask again.

## The Final Design in One Picture

![Final design: clients reach an edge layer that answers hot redirects, then an API gateway with rate limiting; a read service serves redirects from a cache backed by a database with replicas across zones, and a write service takes number blocks from an ID allocator, checks destinations with a URL safety service and writes to the primary](./design-a-url-shortener-final.svg)

Follow one click: the edge answers it if the code is hot; otherwise the gateway passes `GET /{code}` to the read service, which finds the code in the cache and returns a 302 with the long URL. A cache miss reads a database replica and refills the cache.

A new link goes to the write service: its URL is validated and normalised, it takes a number from the server's local block, is scrambled and encoded, has its destination checked, and is written to the primary.

## What Each Level Should Show

| Level | Expected in the room |
|---|---|
| Mid-level | A working design, base62 codes, a cache for reads. The interviewer may lead the deep dives. |
| Senior | Drives the deep dives, sizes the read path, compares code-generation options, argues 302 over 301. |
| Staff+ | Raises abuse and takedown, guessable codes, hot keys, per-region number ranges, counter failover, edge serving and its cost, and what to build first. |

## Red Flags and Green Flags

<AnswerCompare>
<Weak label="Red flag">
"I'll take an MD5 of the URL." Then no word about collisions, guessable codes or how two users with the same URL are kept apart.
</Weak>
<Strong label="Green flag">
"Writes are about 40 a second and reads a hundred times that, so I'll keep code generation simple and spend the deep dive on the read path."
</Strong>
</AnswerCompare>

<Callout type="warning" title="The most common way to lose this round">
Spending twenty minutes on the hash function. Code generation is one deep dive of three, and the read path is where the load is. Pick a scheme, state its trade-off, and move on.
</Callout>

## Try It: Add Click Analytics

<Exercise title="Count clicks without slowing redirects" time="15 min">

The product team now wants a daily click count per link. Redirects must stay as fast as before. Sketch:

1. Where the click is recorded, and why not in the database on the redirect path.
2. How counts reach a daily total.
3. One trade-off of your choice.

<Hint>

At 12,000 redirects a second, a database write per click doubles the work on the hot path. What can the redirect hand off instead of doing itself?

</Hint>

<Expected title="One reasonable answer">

```text
GET /{code} -> 302 (unchanged)
            -> emit click event (code, time) to a log
               or queue, fire and forget
Consumer    -> aggregate per (code, day) in batches
            -> write daily totals to an analytics store
```

The redirect only emits an event and returns; counting happens off the hot path. A durable log such as Kafka lets the consumer fall behind and catch up without losing clicks. Trade-off: counts are minutes behind real time, and you must decide whether a lost event matters for billing or only for charts. See [event-driven architecture](/event-driven-architecture/) and [Kafka vs RabbitMQ](/kafka-vs-rabbitmq/).

</Expected>

</Exercise>

## Test Yourself

<InInterview>

Expect these follow-ups once the design works: "Two users shorten the same URL; do they get the same code?", "A link points to malware; how fast can you stop it?", and "Your counter store goes down; what still works?" Each one maps to a deep dive above, so answer by pointing at the part of the diagram that handles it.

</InInterview>

<CheckYourself>

<Question q="Why does a sequential counter make codes guessable, and how do you fix it without collisions?">

Consecutive numbers encode to consecutive codes, so anyone can enumerate links. Pass each number through a keyed, reversible scramble before encoding: unique numbers still give unique codes, but neighbours look unrelated.

</Question>

<Question q="Traffic grows tenfold to 120,000 redirects a second at peak. What changes?">

Writes are still modest, so code generation is unchanged. The cache takes most of the growth: more cache capacity and more replicas spread across zones. Watch the cache hit rate, because every miss now costs ten times as much.

</Question>

<Question q="One link gets a hundred times more clicks than any other. What breaks, and what do you do?">

It becomes a hot key: every request lands on the one cache node holding it. Replicate that entry to several cache nodes or hold it briefly in each server's memory, and let the edge serve it so most clicks never reach you.

</Question>

<Question q="A link was returned as 301 for a month and is now malicious. Why can't you fully stop it?">

Browsers that cached the 301 go straight to the destination without asking your servers again, so disabling the link only stops clients that have not cached it. That is the case for 302.

</Question>

</CheckYourself>

<Callout type="tip" title="Learning tip">
Close this tab and redraw the final design from memory in five minutes. Then add one box for the analytics exercise. Drawing it cold twice, a day apart, fixes the shape far better than reading it again.
</Callout>

## In One Picture

```text
Requirements  shorten · redirect · alias/expiry
Scale         40 writes/s · 4,000 reads/s · 9 TB
Codes         number blocks -> scramble -> base62
Reads         edge -> cache -> replicas in 3 zones
Redirect      302 by default (301 = browser caches)
Abuse         rate limit · check URL · instant takedown
Scale         split read/write · ranges per region
```

## Conclusion

A URL shortener is a read-path problem wearing a write-path costume. Get the estimate first, and the design follows: simple, collision-free codes on the write side, a cache and replicas on the read side, and a redirect code that keeps you in control of every link. The same shape, a cheap write and a hot read, returns in the next problems of this module.

**Which deep dive would you pick first in the room?** Tell us in the comments.

<NextSteps>

- **Revisit the maths:** [back-of-envelope estimation](/back-of-envelope-estimation-primer/) derives every number used here.
- **Go deeper on the read path:** [caching strategies](/caching-strategies/) covers invalidation, the hard part of the cache above.
- **Next in Module 4:** design a rate limiter, the box guarding link creation in the final design.

</NextSteps>
