System Design Primer · llms.txt
Beyond the primer: design questions
Written for this site, not part of The System Design Primer. The sources it draws on are listed under Further reading.
A URL shortener takes a long web address, such as https://en.wikipedia.org/wiki/Representational_state_transfer#Architectural_constraints, and gives back a short alias such as https://tiny.cc/r7k2x9. When someone opens the short link, the service redirects them to the original address.
It's a classic interview question because the product is simple and the design still makes you decide real things: how to generate short codes that never collide, which HTTP redirect status to send, how to shard a table with billions of rows, how big the cache needs to be, and how to count clicks without slowing down redirects.
It builds on the primer's database, cache and asynchronism chapters, and on the extra chapters on unique IDs and rate limiting.
https://tiny.cc/my-portfolio.404 Not Found or 410 Gone.Click-fraud detection, marketing campaign tools, billing tiers and link categorization.
These are assumptions for a popular public service, not figures from any real one:
See estimation for more on this kind of arithmetic.
Creating a link:
POST /api/v1/urls
Content-Type: application/json
{
"long_url": "https://example.com/very/long/article?id=49281",
"custom_alias": "cool-article",
"expires_at": 1735689600
}
HTTP/1.1 201 Created
Content-Type: application/json
{
"short_code": "cool-article",
"short_url": "https://tiny.cc/cool-article",
"created_at": 1704067200
}
custom_alias and expires_at are optional. expires_at is a Unix timestamp in seconds.
Following a link:
GET /cool-article HTTP/1.1
Host: tiny.cc
HTTP/1.1 302 Found
Location: https://example.com/very/long/article?id=49281
Reading stats:
GET /api/v1/urls/cool-article/stats HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
{
"short_code": "cool-article",
"clicks": 14205,
"created_at": 1704067200
}
The status code you redirect with decides whether browsers come back to you on the next click.
| 301 Moved Permanently | 302 Found / 307 Temporary Redirect | |
|---|---|---|
| Browser caching | Browsers may cache it, and without a Cache-Control header they often keep it for a long time. Later clicks can go straight to the target. | Not cached unless you add caching headers. Every click comes back to the shortener. |
| Load on your servers | Lower: repeat visits from the same browser skip you. | Higher: every click is a request. |
| Click counts | Undercounted: cached repeat visits are invisible. | Accurate: every click can be logged. |
| Changing or disabling a link | Slow: browsers that cached the redirect keep using it. | Immediate. |
307 and 308 are the strict versions of 302 and 301: they require the browser to repeat the request with the same method and body. For links people click, which are always GET, 302 and 307 behave the same, and so do 301 and 308.
A reasonable answer: use 302 if the product sells click analytics or needs to disable bad links quickly, which is the case for most commercial shorteners. Use 301 if you mainly want to reduce load and don't need per-click data. With a 301 you can still send Cache-Control: max-age=… to limit how long browsers keep it.
Short codes use the characters 0-9, a-z and A-Z: 62 symbols, so this is called Base62. How long do codes need to be?
We expect 6 billion links in five years. Six characters would technically be enough, but seven gives about 590 times the needed space. That headroom matters because it also makes codes hard to guess: a random 7-character code hits an existing link only about once in 590 tries.
There are two main ways to produce a code.
MD5("https://example.com") = c984d06aafbecf6bc55569f964148ea3.The upside is that the same long URL always maps to the same code, so you can deduplicate if you want to. The downside is that every create is a check-and-retry against the database, and the retries grow as the table fills.
Split the problem in two: first produce an integer that's guaranteed to be unique, then write that integer in Base62.
CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def id_to_base62(num: int) -> str:
if num == 0:
return CHARS[0]
result = []
while num > 0:
result.append(CHARS[num % 62])
num //= 62
return "".join(reversed(result))
With this alphabet, ID 11,157,117,118 encodes to cb47PU and ID 100,000,000,000 to 1L9zO9O. Different integers always produce different strings, and you can decode a string back to its integer, so there are no collisions to check for.
The integers should come from a counter, not from a Snowflake-style 64-bit ID. A Snowflake ID packs a timestamp into the high bits, so the numbers are huge from day one and encode to 11 characters. A counter starting at 1 stays within 7 characters until it passes 62⁷ − 1, about 3.52 trillion.
Custom aliases share the same namespace, so a user could claim a code the counter will produce later. Either check generated codes against the unique constraint too, or keep the two apart, for example by requiring custom aliases to be at least 8 characters or to contain a -.
A single database sequence would work at 120 writes a second, but it's a single point of failure. Two common ways to spread the work:
The unique IDs chapter compares these and other schemes in more detail.
An interactive version of this diagram lets you pan, zoom and trace the request paths.
GET /r7k2x9.r7k2x9 in Redis.302 Found with Location: <long_url>. Inside the data center this takes a millisecond or two.404 (or 410 for an expired link). It's worth caching "not found" briefly too, so a burst of requests for a bad code doesn't hit the database each time.{short_code, timestamp, ip, user_agent, referrer} to a Kafka topic without waiting for analytics to process it. The redirect never blocks on analytics.POST /api/v1/urls.short_code rejects a taken alias, and the server returns 409 Conflict.201 Created with the short URL.CREATE TABLE urls (
short_code VARCHAR(16) NOT NULL PRIMARY KEY,
id BIGINT UNIQUE,
long_url VARCHAR(2048) NOT NULL,
user_id BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NULL,
INDEX idx_user (user_id),
INDEX idx_expires (expires_at)
);
The redirect path only ever looks up by short_code, so that's the primary key. id is null for custom aliases. idx_user serves "list my links", and idx_expires serves the cleanup job.
There are no joins and no multi-row transactions on the hot path, so a key-value or wide-column store (DynamoDB, Cassandra) works as well as a relational one. Pick whichever your team runs well.
3 TB fits on one machine's disks, but splitting the table across shards spreads the cache-miss reads and writes, keeps each shard's indexes small enough to stay in memory, and limits what a single failure takes out.
shard = hash(short_code) mod N. Every redirect knows the code, so it goes straight to one shard, and hashing spreads codes evenly. Use consistent hashing or a fixed number of logical shards mapped to machines, so adding a machine moves only a fraction of the data.Each shard has replicas. Reads that miss the cache can go to replicas; a link created a moment ago might not be on a replica yet, so fall back to the primary on a miss for very new codes.
allkeys-lru) when memory is full. Links nobody clicks fall out; popular ones stay.expires_at with the current time. If it has passed, return 410 Gone and remove it from the cache. This alone is enough for correctness.expires_at index and deletes in small batches, such as DELETE FROM urls WHERE expires_at < NOW() LIMIT 5000, repeated with short pauses. Big deletes hold locks for a long time and cause replication lag.Decide whether an expired code can ever be reused. Reusing it means an old printed link silently points somewhere new, which is usually a bad surprise. With 3.5 trillion codes available, it's simplest never to reuse them.
r7k2x9 is followed by r7k2xa, and anyone can crawl every link in order, including ones their owners thought were private. Pass the counter through a reversible scrambling function before encoding it, such as a small Feistel network over the range 0 to 62⁷ − 1. Each input still maps to a distinct output in the same range, so codes stay unique and 7 characters long, but consecutive IDs come out looking random. Combined with a mostly empty code space, guessing becomes expensive. Links that must stay private still need access control, not just an obscure code.Truncating the hash throws away most of its bits, so different URLs collide, and the birthday problem means it starts early: with 62⁷ possible codes, a collision is more likely than not after only a couple of million links. Each create then needs an insert that might fail and a retry loop, and retries get more frequent as the table grows.
Encoding a unique counter can't collide, because different integers always have different Base62 strings. The create is a single insert with no retry loop. The cost is that you need a way to hand out unique integers across servers, such as range allocation, and you lose the property that the same URL always gets the same code.
It would turn every redirect, about 11,600 a second at peak, into a database write. Worse, a viral link sends thousands of requests a second to update the same row, and those updates queue up behind the row lock. Redirect latency climbs and database connections run out.
Instead, the redirect path only reads, mostly from cache, and publishes a click event to Kafka without waiting. Workers consume the events in batches and write aggregated counts to an analytics store such as ClickHouse. The stats page shows numbers that are a few seconds behind, which is fine.
They're never issued. Say the server held 1,000,001 to 2,000,000 and had used up to 1,050,000: the remaining 950,000 are skipped, and the next block the coordinator hands out starts at 2,000,001. Nothing is issued twice, so uniqueness holds. Losing 950,000 IDs out of about 3.5 trillion 7-character codes is negligible. The only visible effect is a gap in the sequence, which nobody depends on.
With 302, every click comes back to the shortener, so marking the link disabled in the database takes effect as soon as the cache stops serving the old entry. Delete the key from Redis (and from any in-process caches) when you disable it, and keep TTLs short enough to bound the delay if a delete is missed. With 301, browsers that already cached the redirect keep following it, and there's nothing the server can do about that.
Previous: Batch processing and distributed computing · Next: Design a social media news feed (like Facebook, Instagram, or Twitter)