The whiteboard for this problem is in
excalidraw/6. Designing URL Shortener.excalidraw. This file is the full write
up of that board. It is the best problem to start with, because almost every
other design in this folder reuses one idea from here.
1. Problem Statement
Design a URL shortener that turns a long URL into a short, human readable, pseudo random code. Visiting the short URL takes the user to the original site.
Why anyone wants this: long URLs are painful to share over SMS and social media, and short ones look better in print and in messages.
Scale to design for: about 100 million new URLs per month. Custom aliases are not supported for now.
2. Requirements
2.1 Functional Requirements
| Requirement | Detail |
|---|---|
| Shorten | Accept a long URL, return a short code |
| Redirect | Visiting the short URL redirects to the original |
| Human readable code | Alphanumeric only, no confusing punctuation |
| Unguessable | Someone must not be able to enumerate other people's links |
| Analytics | Count clicks, and break them down by time, location, device |
| Expiry | Optional expiry on a link |
2.2 Non Functional Requirements
- Redirect latency must be tiny. The redirect sits between a click and the destination page, so it must be a few milliseconds.
- Read heavy. Reads outnumber writes by a large factor, so the read path gets the cache and the replicas.
- No collisions, ever. Two different long URLs must never receive the same code.
- Unguessable codes. Sequential codes let anyone scrape the whole database.
- Highly available. A dead shortener breaks every link ever shared, which is far worse than a dead signup page.
- Horizontally scalable. Code generation must not depend on one machine.
2.3 Out of Scope
Custom aliases, link editing, team accounts, malware scanning of destinations.
3. How to Shorten a URL
3.1 Approach 1: Hash the URL
Take SHA-256 of the long URL and use a prefix of the digest.
Problems:
- A full digest is 256 bits, which encoded is far too long to be a short URL. Even truncated to 16 characters it is not short.
- Truncation creates collisions, and two different URLs can land on the same prefix.
- The same long URL always produces the same code, so two different users shortening the same link share one code and therefore share analytics. That is not what either of them wants.
3.2 Approach 2: Use the Integer Id
If the row lands at id 1729, the short URL is url.sml/1729.
Very short, and guaranteed unique. But completely predictable. Anyone can walk 1, 2, 3 and scrape every link in the system, including private ones.
3.3 Approach 3: Custom Encoding
We cannot expose the integer directly, so we encode it into a string.
The character set is a-z plus A-Z plus 0-9, which is 26 + 26 + 10 = 62
characters. Six bits give us 2^6 = 64 slots, which is enough to hold 62
characters with two unused.
A plain mapping looks like this:
000000 -> a
000001 -> b
000010 -> c
000011 -> dWorked example. Suppose the id assigned in the database is 79.
79 in binary = 1001111
left pad to a multiple of 6 = 000001 001111
000001 -> b
001111 -> p
short URL = url.sml/bpStill predictable, because the mapping is the obvious alphabetical one and ids are sequential. So we use a random mapping that only we know:
000000 -> q
000001 -> d
000010 -> e
000011 -> ANow the code is much harder to guess from the outside. But someone firing back to back requests would collect consecutive ids, see the pattern in the output, and reverse engineer the mapping.
So the real fix is at the source: instead of picking sequential ids, pick random ones. That is the crux of the whole design. The encoding is just a way to turn an integer into characters. The unguessability comes from which integers we hand out.
4. Generating Unique Pseudo Random Ids
This is the interesting part of the problem.
Constraints:
- Going one by one is easy to predict.
- Generating a random integer and hoping is prone to collisions, and checking for a collision on every write is a read plus a write on the hot path.
- One machine must not own generation, because it would be both a bottleneck and a single point of failure.
Volume check:
100M per month / 30 = 3.3M per day
3.3M / 24 ~ 138K per hour
138K / 60 ~ 2.3K per minuteThat is the average. Peak is considerably worse, so the design has to leave headroom.
The answer uses partitioning, sharding and transactions: a set of database backed ticket servers whose only job is to hand out one unique integer at a time, atomically.
4.1 Ticket Servers, Small Example
Take a range of 0 to 1000 and split it into four partitions:
| id | start | end | current |
|---|---|---|---|
| 1 | 0 | 250 | 0 |
| 2 | 250 | 500 | 250 |
| 3 | 500 | 750 | 500 |
| 4 | 750 | 1000 | 750 |
current is the value that will be returned next from that partition.
Getting one integer:
- The user asks for a short URL.
- The API server picks a ticket server, at random.
- The ticket server knows which range ids it owns, from configuration.
- The API server, or the ticket server, selects one range at random.
- It reads
currentfor that range. - It increments
currentby one, as long ascurrentis not equal toend.
All inside a transaction:
BEGIN;
SELECT current FROM ranges WHERE id = ? FOR UPDATE;
UPDATE ranges SET current = current + 1 WHERE id = ?;
COMMIT;No matter how many requests hit the database at the same moment, there are no collisions, because the row lock serializes them. For example on range id 2, the next value becomes 251.
When a range is exhausted, meaning current has reached end, the server drops
that range id from its in memory list and stops selecting it.
4.2 Why This Looks Random
Randomness comes from three layers of choice:
- Pick one ticket server at random.
- Pick one range on that server at random.
- Take the next value from that range.
Within a single range the order is sequential, but consecutive requests almost never come from the same range, so the observed sequence of ids jumps around. Combined with the secret six bit character map, the codes are hard to guess and hard to reverse engineer.
4.3 Range Math
How long does the id space last?
32 bit ids: 2^32 ~ 4 billion
4 billion / 100M per month = 40 months ~ 3 yearsThree years is not enough, so go to 64 bits:
2^64 ~ 1.84 x 10^19
1.84 x 10^19 / 10^8 per month = 1.84 x 10^11 months
~ 1.53 x 10^10 yearsFifteen billion years, so we are completely safe on space. But a 64 bit number encodes to about 11 characters, which is longer than we want.
So bound the code length instead. Say the code should be at least 3 and at most 8 characters:
min length 3 = 3 x 6 = 18 bits, 2^18 = 262,144
max length 8 = 8 x 6 = 48 bits, 2^48 ~ 2.81 x 10^14Start ids at 100,000 rather than 0, so every code is at least 3 characters long and we never emit a one character link.
Now size the ranges. If each range should last about a year:
100M per month x 12 = 1200M = 1.2 x 10^9 ids per range
total ranges = 2.81 x 10^14 / 1.2 x 10^9 ~ 2.3 x 10^5Which is a couple of hundred thousand ranges, each holding 1.2 billion ids. The board rounds this to roughly half a million ranges. The exact count is not the point, the point is that it is a fixed, precomputable table:
range 1: 100,000 to 1,200,000,000
range 2: 1,200,000,000 to 2,400,000,000
range 3: 2,400,000,000 to 3,600,000,000
...We create these rows up front. On each request we pick one range at random and take one integer out of it. If one ticket database becomes a bottleneck, spin up another one and move some ranges to it, then let the API server pick a ticket server at random.
5. Storage
We store the mapping between the short code and the actual URL.
The important property: the short code is not derived from the URL. It comes from the ticket server, so the URL row is a plain key value entry.
short_code | url | metadataSizing it:
short_code ~ 8 B
url ~ 120 B
per row ~ 128 B
100M rows x 128 B = 12.8 GB per monthStorage is not the concern at this size, and a single node could hold years of
it. We shard for request throughput, not for space, and because the access
pattern is purely key based we shard on short_code.
Any key value store works, and so does a relational database with sharding. The lookup is always the same query:
SELECT * FROM urls WHERE short_code = ?;6. Redirect Flow
When someone visits url.sml/bp:
- The request reaches an API server.
- The server looks up the short code, cache first, then the shard.
- Before responding it emits an analytics event.
- It returns a redirect with the original URL.
The analytics event carries the short code plus everything the request already tells us: user agent, referrer, IP derived location, operating system, device type. It goes onto Kafka and the response goes out immediately. Nothing about recording a click should slow down a redirect.
6.1 Which Redirect Status
| Status | Meaning | Effect |
|---|---|---|
| 301 Moved Permanently | Browsers cache the redirect | Fastest for repeat visits, but later clicks never reach us so analytics undercount, and the link can never be changed |
| 302 Found | Browsers do not cache it | Every click hits our servers, so analytics are complete and the destination stays editable |
The board uses 301 for speed. In practice, if analytics matter, 302 is the safer default, and 301 is the right pick only for links whose destination will never change. Say which one you chose and why.
7. Caching
Newly published URLs are far more likely to be clicked. A link shared this morning gets thousands of hits today and almost none next year. That is a textbook case for a cache, to cut database load and improve latency.
So we cache the short codes that are new or popular.
| Aspect | Choice |
|---|---|
| Store | Redis |
| Key | url:{short_code} |
| Value | Long URL plus expiry timestamp |
| Policy | LRU eviction, plus a warm write on creation |
| TTL | Hours for the long tail, longer for hot keys |
| Invalidation | Delete the key on link deletion or expiry change |
Because the mapping is immutable once created, invalidation is almost never needed. That is what makes this cache easy, unlike the product catalog cache in the ecommerce problem where prices change.
8. API Design
8.1 Public
| Method | Path | Purpose |
|---|---|---|
| POST | /api/urls | Create a short URL |
| GET | /:shortCode | Redirect to the original URL |
| GET | /api/urls/:shortCode | Metadata without redirecting |
| DELETE | /api/urls/:shortCode | Delete a link the caller owns |
| GET | /api/urls/:shortCode/stats | Aggregated click analytics |
| GET | /api/urls | List the caller's links, paginated |
Create:
{
"longUrl": "https://example.com/a/very/long/path?with=query&and=more",
"expiresAt": "2026-12-31T00:00:00Z"
}Response 201 Created:
{
"shortCode": "bp7Kx",
"shortUrl": "https://url.sml/bp7Kx",
"longUrl": "https://example.com/a/very/long/path?with=query&and=more",
"createdAt": "2026-08-19T10:31:00Z",
"expiresAt": "2026-12-31T00:00:00Z"
}Redirect:
GET /bp7Kx
302 Found
Location: https://example.com/a/very/long/path?with=query&and=more
Cache-Control: private, max-age=0Stats:
{
"shortCode": "bp7Kx",
"totalClicks": 18422,
"uniqueVisitors": 12103,
"byDay": [{ "date": "2026-08-18", "clicks": 3120 }],
"byCountry": [{ "country": "IN", "clicks": 9021 }],
"byDevice": [{ "device": "mobile", "clicks": 14002 }],
"byReferrer": [{ "referrer": "twitter.com", "clicks": 5510 }]
}8.2 Internal
| Method | Path | Purpose |
|---|---|---|
| POST | /internal/tickets/next | Ticket server hands out one unique integer |
| GET | /internal/tickets/ranges | Which ranges this server owns |
| POST | /internal/tickets/ranges | Assign a range to this server |
{ "id": 8241194001, "rangeId": 7, "ticketServer": "ts-3" }This endpoint is internal only. Exposing it publicly would let anyone drain the id space.
9. Data Model
Three stores, because there are three completely different access patterns: key value lookups, transactional counters, and analytics aggregation.
9.1 URL Store, Sharded Key Value
urls
| Column | Type | Notes |
|---|---|---|
short_code | varchar(11) | Primary key, shard key |
long_url | text | Destination |
owner_id | uuid | Foreign key idea to users.id, null for anonymous |
id_int | bigint | The integer the code was encoded from, kept for debugging |
created_at | timestamptz | |
expires_at | timestamptz | Null means never |
is_active | boolean | Soft delete |
Only ever queried by short_code, which is why this can be DynamoDB,
Cassandra, or a sharded relational table without anyone noticing the difference.
9.2 Ticket Store, Relational and Transactional
ranges
| Column | Type | Notes |
|---|---|---|
id | integer | Primary key, the range id |
range_start | bigint | Inclusive |
range_end | bigint | Exclusive |
current | bigint | Next value to hand out |
server_id | text | Which ticket server owns this range |
is_exhausted | boolean | Set when current reaches range_end |
This table must be relational. It needs row level locking and real transactions, which is exactly the guarantee that makes collisions impossible. It is also tiny, a few hundred thousand rows.
users
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key |
email | text | Unique |
plan | enum | FREE, PRO |
created_at | timestamptz |
9.3 Analytics Store, Document or Search
clicks (MongoDB or Elasticsearch)
| Field | Type | Notes |
|---|---|---|
shortCode | string | |
ownerId | uuid | Shard key, because aggregations are per creator |
clickedAt | date | |
ip | string | Truncated or hashed for privacy |
country, city | string | Derived from IP |
userAgent | string | Raw |
os, browser, deviceType | string | Parsed from the user agent |
referrer | string |
Unstructured and append only, and every query on it is an aggregation. Any store that is good at aggregation over semi structured data works here.
Two important choices:
- The stats store is sharded by the user who created the URL, not by short code, because the dashboards a user opens are aggregations across all of their links.
- Aggregation results are rendered through Grafana or Kibana rather than a custom reporting service, which is free functionality.
9.4 Relationships
In words:
- One user creates many URLs, one URL has at most one owner. Anonymous links have none.
- One URL receives many clicks, one click belongs to one URL.
- One range issues ids for many URLs, one URL's id came from exactly one range. This is a logical relationship, not an enforced foreign key, because the two live in different stores.
- One user owns the analytics for many click records, which is why the click store is sharded by owner.
Note there is no foreign key from clicks back to urls. They are in separate
databases, and the join is done at query time by short code. Cross store foreign
keys are a wish, not a constraint.
10. Key Flows
10.1 Creating a Short URL
10.2 Following a Short URL
The click event is emitted before the response is written but never waited on. If Kafka is unavailable we lose a click record, and losing a click record is much better than failing a redirect.
11. Deep Dives
11.1 Why Not Just Check for Collisions
The obvious alternative to ticket servers is to generate a random code and check whether it exists. That turns every write into a read plus a write, and as the table fills the retry rate climbs. Under concurrency two requests can both read "free" and both insert, so you need a unique constraint and a retry loop anyway.
Ticket servers move the uniqueness guarantee to a single row lock on a tiny table, and the write path never checks for existence at all.
11.2 Why the Encoding Map Is Secret
The secret six bit map is not real security, it is friction. Anyone who can create links and observe the codes can start reconstructing it. What actually protects the keyspace is the random range selection, because the ids handed out are spread across a space of 10^14. Enumerating that space is not practical whatever the encoding is.
Treat the secret map as defense in depth and never as the reason a private link is private.
11.3 Expiry and Cleanup
Expiry is checked at read time, before the redirect, so an expired link returns 404 immediately even if a cleanup job has not run. A daily job then deletes rows past their expiry and purges the matching cache keys. Read time check for correctness, background job for space.
11.4 Analytics Volume
Clicks are the highest volume data in this system by far. At a 50 to 1 read to write ratio, 100 million links a month means around 5 billion click events a month. Two consequences:
- Never write clicks synchronously in the redirect path.
- Roll up aggressively. Keep raw events for a few weeks for drill down, keep per day per country per device rollups forever, and archive raw events to object storage after that.
12. Failure Handling
| Failure | Effect | Handling |
|---|---|---|
| Cache miss storm | Load lands on shards | Consistent hashing across cache nodes, and warm the cache on write |
| Redis down | Every read goes to the shard | Shards can absorb it briefly, redirect stays correct just slower |
| One URL shard down | Codes on that shard fail | Replicas per shard with automatic failover. Other shards are unaffected |
| Ticket server down | Cannot mint new ids | API server picks another ticket server. Redirects are completely unaffected |
| All ticket servers down | Creation fails | Return 503 on create. Reads keep working, which is the traffic that matters |
| Kafka down | Click events lost | Fire and forget with a small local buffer, and never block the redirect |
| Range exhausted | Range stops serving | Mark it exhausted, drop it from the in memory list, and pre create new ranges well ahead of need |
| Duplicate id from a bug | Two URLs collide | Unique constraint on short_code is the last line of defense, and the insert fails rather than overwriting |
Notice the split: creation can fail loudly, redirects must not. Existing links are the product, new links are a feature.
13. Tradeoffs
| Decision | Chosen | Alternative | Reason |
|---|---|---|---|
| Code source | Ticket server integer plus encoding | Hash of the URL | No collisions, and different users get different codes for the same link |
| Id order | Random range selection | Sequential ids | Sequential ids can be enumerated |
| Character map | Secret random 6 bit map | Standard base62 | Extra friction, though not real security |
| URL store | Sharded key value | Single relational node | Sharding is for request rate, storage is only 12.8 GB a month |
| Ticket store | Relational with transactions | Key value counter | Needs row locks for exactly once increments |
| Redirect status | 302 by default | 301 | Complete analytics and an editable destination, at the cost of repeat lookups |
| Analytics path | Async through Kafka | Synchronous write | A click write must never delay a redirect |
| Stats shard key | Creator id | Short code | Dashboards aggregate per creator |
| Cache scope | New and popular codes | Cache everything | The access pattern is heavily skewed toward recent links |
14. Exercise
From the board, three things worth building:
- Implement the encoding scheme, integer to string, with a custom map, both encode and decode.
- Explore which analytics can be captured during a short URL hit, and how.
- Explore exactly how analytics data gets shared back to the link owner.
15. Interview Talking Points
- Walk all three shortening approaches and reject the first two with concrete reasons. The reason hashing fails is not only length, it is that two users shortening the same URL would share analytics.
- Say the key sentence plainly: unguessability comes from which integers we hand out, not from the encoding.
- Explain ticket servers with the small 0 to 1000 table. It makes the idea obvious in about thirty seconds.
- Show the transaction. Row level locking is the whole reason collisions are impossible.
- Do the range arithmetic out loud. Bounding code length to 3 through 8 characters, then sizing ranges to last a year, is what turns a vague idea into a table you can create.
- Do the storage math and then say storage is not the concern, throughput is.
- State the 301 versus 302 tradeoff explicitly. It is the fastest way to show you thought about analytics rather than just latency.