Design a URL Shortener
The classic system design case study, end to end: requirements, a capacity estimate with the arithmetic shown, five ways to mint short unique codes (and what each costs you), 301 vs 302, the cache that carries a read-heavy system, sharding by code, and what happens when a link goes viral or the database dies.
The 30-second version
You paste https://shop.example.com/products/winter-sale?utm_source=newsletter&utm_campaign=2026-11 and get back https://sho.rt/x7Kp2Qa. The part after the slash, x7Kp2Qa, is the short code. When someone clicks the short link, the service finds the long URL for that code and answers with an HTTP redirect: a tiny response that tells the browser "go over there instead".
The whole design on one screen
- Two operations, very lopsided. Creating links is rare; following them is about 100 times more common. A system like this is called Read-heavy workload, and we design for the redirect first.
- Short, unique codes. Seven characters from
0–9 a–z A–Zgive about 3.5 trillion codes (§05). Picking them so that two links never get the same one is the most interesting decision on this page (§06–§07). - The redirect is a single lookup by key. That is the easiest database query there is, and it caches beautifully: a small cache in front of the database answers most clicks (§09).
- The status code is a product decision. A 301 lets browsers remember the answer and skip you next time; a 302 brings every click back so you can count it (§08).
Here's the design we'll build up to, one box at a time:
The plan for this page
Every case study on Software Atlas follows the same arc: requirements → rough numbers → the simplest design that works → what breaks as traffic grows → fixes and their trade-offs → failure modes → what to monitor. Each step builds on the one before, so read in order the first time.
Requirements: what are we building?
Functional: what the system does
| # | Requirement | Notes |
|---|---|---|
| 1 | Shorten: given a long URL, return a short link | The core feature. |
| 2 | Redirect: visiting the short link takes you to the long URL | The core feature, and 100× more frequent than #1. |
| 3 | Custom alias (optional) | The user picks the code: sho.rt/spring-sale. This is a Custom alias. |
| 4 | Expiry (optional) | A link can stop working after a date the user chooses. |
| 5 | Analytics (stretch goal) | Click counts per link, by day, country and referrer. We'll design it last (§12) so it never slows down #2. |
Things we deliberately leave out: user accounts beyond an API key, editing the long URL after creation, and link previews. Saying what's out of scope is part of the answer; it keeps the design small.
The API
Two endpoints cover the core:
POST /api/links ← create
{ "url": "https://shop.example.com/products/winter-sale?…",
"alias": "spring-sale", (optional)
"expires_at": "2027-01-01T00:00:00Z" } (optional)
→ 201 Created
{ "code": "x7Kp2Qa", "short_url": "https://sho.rt/x7Kp2Qa" }
GET /x7Kp2Qa ← redirect
→ 302 Found
Location: https://shop.example.com/products/winter-sale?…The redirect endpoint lives at the root of the domain (/x7Kp2Qa, not /api/links/x7Kp2Qa) because shortness is the whole point. If HTTP status codes and headers are hazy, HTTP → messages covers them.
Non-functional: how well it must work
These are the requirements that actually decide the architecture:
- Redirects are fast. The redirect sits between a click and the page the user wanted, so every millisecond is felt. Target: the server answers in under 10 ms for 99% of requests (the p99), not counting the network.
- Redirects are always available. A broken short link printed on 100,000 flyers can't be fixed. Target: 99.99% of redirects succeed, about 4 minutes of failure a month. Creating links can be a little less available; a user can retry. (A blind retry of a create could make two links for one URL; an idempotency key on the request prevents that, see Idempotency → keys.)
- Links never collide. Two different long URLs must never share a code. Sending someone's click to the wrong site is the worst bug this system can have.
- Links last. Once created, a link keeps working until it expires. We'll keep links for 5 years.
- Codes aren't easy to guess — or we decide they may be. If codes are sequential (
…/abc1,…/abc2), anyone can list every link, including ones people meant to share privately. We'll treat "not trivially guessable" as a requirement and see what it costs (§06).
Rough numbers: how big is it?
This kind of quick calculation is called a Back-of-the-envelope estimate. Every number below is approximate and rounded on purpose. Our assumptions:
- 100 million new links a month.
- 100 redirects per new link (a read:write ratio of 100:1).
- About 500 bytes to store one link (§04 shows the row).
- Keep links for 5 years.
Step 1: writes per second
One month ≈ 30 days × 86,400 seconds a day = 2,592,000 s, call it 2.6 million.
100,000,000 links ÷ 2,600,000 s ≈ 40 creates per second on average.
Step 2: reads per second
40 creates/s × 100 = 4,000 redirects per second on average.
Traffic isn't flat: it follows the day and the news. The busiest minutes are typically 2–5× the average; this Peak factor is a guess you should say out loud. With 3×:
peak ≈ 4,000 × 3 = 12,000 redirects/s and ~120 creates/s.
In QPS terms: tens of creates a second (easy for any database) and thousands to tens of thousands of reads (too many for one database machine to serve comfortably; §09 fixes that).
Step 3: storage
100 million/month × 12 months × 5 years = 6 billion links
6,000,000,000 × 500 bytes = 3,000,000,000,000 bytes ≈ 3 TB
3 TB fits on one large disk, but it's uncomfortable for one database machine (backups, rebuilds and index size all grow with it). It's a hint that we'll split the data later (§10).
Step 4: how long must the code be?
Each character can be one of 62 symbols (0–9, a–z, A–Z; §05 explains why 62). So:
| Length | Possible codes | Room for 6 billion links? |
|---|---|---|
| 6 | 62⁶ ≈ 57 billion | Yes, but 10% full by year 5 |
| 7 | 62⁷ ≈ 3.5 trillion | Yes: only 0.17% full |
| 8 | 62⁸ ≈ 218 trillion | Plenty, but every link is one character longer |
Our rule: keep the space at most 1% full after 5 years. That gives 7. Six would technically fit, but a nearly empty space matters if codes are random: §07 shows that the fuller the space, the more often a new random code is already taken.
Step 5: bandwidth and cache
A redirect response is tiny, a few hundred bytes of headers. At the peak:
12,000/s × ~500 bytes ≈ 6 MB/s. Negligible.
For the cache, a common rule of thumb is the 80/20 rule: roughly 20% of items get roughly 80% of the traffic (§09 explains why links are like this). It's an assumption, not a law. Suppose we cache enough rows to cover 20% of a day's requests:
4,000/s × 86,400 s ≈ 350 million redirects a day
350,000,000 × 20% × 500 bytes ≈ 35 GB
That's an upper bound: popular links are requested over and over, so the number of different links among those requests is far smaller. Either way it fits in the memory of one or two cache servers.
Try your own numbers
The simplest design that works
The app server keeps nothing in memory between requests (it is stateless), so you can run two or twenty identical copies behind a Load balancer (see Load balancing) and any copy can answer any request.
The table
CREATE TABLE links (
code varchar(16) PRIMARY KEY, -- ← the lookup key; PRIMARY KEY also forbids duplicates
long_url text NOT NULL, -- up to a few KB; most are ~100 bytes
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz, -- NULL = never expires
owner_id bigint -- who created it (for abuse handling and analytics)
);That's roughly where the "~500 bytes per link" in §03 comes from: a 100–300 byte URL, a few small columns, plus the index and the database's own overhead per row.
The Primary key does two jobs at once: it makes WHERE code = $1 a fast index lookup, and it makes the database refuse a second row with the same code. Keep that second job in mind; §07 relies on it.
Creating a link
- Validate the URL (it parses, the scheme is
httporhttps, its domain isn't on a Blocklist of known-bad sites; more in §11). - Pick a code (the whole of §06 is about how).
INSERTthe row. If the code is already taken, pick another and try again.- Return
https://sho.rt/+ code.
Following a link
Look the code up and answer with a redirect. Here's a complete, runnable server. A map stands in for the database and a second map for a cache (in production the cache is usually Redis, an in-memory key–value server), so you can run it without installing anything:
package main
import (
"log"
"net/http"
"sync"
)
var (
db = map[string]string{"x7Kp2Qa": "https://go.dev/doc/"} // stands in for the links table
mu sync.Mutex
cache = map[string]string{} // stands in for Redis
)
// lookup is cache-aside: try the cache, fall back to the DB, then fill the cache.
func lookup(code string) (string, bool) {
mu.Lock()
defer mu.Unlock()
if url, ok := cache[code]; ok {
return url, true // ← hit: the DB never hears about it
}
url, ok := db[code] // miss: ask the database
if ok {
cache[code] = url // remember it for next time
}
return url, ok
}
func main() {
http.HandleFunc("GET /{code}", func(w http.ResponseWriter, r *http.Request) {
url, ok := lookup(r.PathValue("code"))
if !ok {
http.NotFound(w, r) // unknown code → 404
return
}
http.Redirect(w, r, url, http.StatusFound) // ← 302 + Location header
})
log.Fatal(http.ListenAndServe(":8080", nil))
}Running the Go version and asking for the short link with curl -i (which prints the response headers):
$ curl -i localhost:8080/x7Kp2Qa
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: https://go.dev/doc/
Date: Sat, 31 Oct 2026 08:48:16 GMT
Content-Length: 42
<a href="https://go.dev/doc/">Found</a>.
$ curl -i localhost:8080/nope
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Sat, 31 Oct 2026 08:48:16 GMT
Content-Length: 19
404 page not foundThe whole redirect is the Location header. A browser receiving this response immediately requests https://go.dev/doc/ on its own. (Go also adds a tiny HTML body with a link, for very old clients.)
Where this stops working
At ~4,000 redirects a second on average (§03), every one of them a database query, a single database machine is already near its comfortable limit, and at the 12,000/s peak it's past it. There's also only one database: if it dies, every link on the internet that points at us breaks. The rest of the page fixes those two problems. First, though, the codes.
Base62: short codes from numbers
You already know two bases. In base 10 we count 0…9, then roll over to 10. In base 2 (binary) we count 0, 1, then roll over to 10. Base62 uses 62 symbols: 0–9 are worth 0–9, a–z are worth 10–35, and A–Z are worth 36–61. After Z (61) comes 10, which means 1 × 62 + 0 = 62.
Why 62? Letters and digits are safe anywhere in a URL without escaping and survive copy-paste, email and chat apps. (Base64 adds + and /, which have special meanings in URLs. A few symbols like - and _ are also URL-safe, but they look odd at the end of a link and some apps cut links off there.) Base62 is not great to read aloud, though: case matters (a ≠ A), and 0/O and 1/l/I look alike. Services that expect codes to be typed or spoken often drop those look-alikes and use a smaller alphabet.
The worked example
To write 125 in base 62, divide by 62 repeatedly and keep the remainders:
- 125 ÷ 62 = 2, remainder 1 → character
1(the rightmost digit) - 2 ÷ 62 = 0, remainder 2 → character
2 - The quotient is 0, so stop. Read the characters from last to first:
21.
Check: 21 in base 62 = 2 × 62 + 1 = 125. ✓ Try other numbers:
| number | ÷ 62 = | rem. | char |
|---|---|---|---|
| 1,000,000,000 | 16,129,032 | 16 | g |
| 16,129,032 | 260,145 | 42 | G |
| 260,145 | 4,195 | 55 | T |
| 4,195 | 67 | 41 | F |
| 67 | 1 | 5 | 5 |
| 1 | 0 | 1 | 1 |
The code
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// encode writes n in base62, most significant digit first.
func encode(n uint64) string {
if n == 0 {
return "0"
}
var out []byte
for n > 0 {
out = append(out, alphabet[n%62]) // ← the remainder is the next digit (from the right)
n /= 62
}
slices.Reverse(out) // digits came out right-to-left
return string(out)
}
// decode turns a base62 code back into its number.
func decode(s string) uint64 {
var n uint64
for _, c := range s {
n = n*62 + uint64(strings.IndexRune(alphabet, c)) // shift left one digit, add this one
}
return n
}
func main() {
for _, n := range []uint64{61, 62, 125, 1_000_000_000, 3_521_614_606_207} {
fmt.Printf("%d → %s\n", n, encode(n))
}
fmt.Println(decode("15FTGg"))
}Both print exactly the same (the Go file also needs package main and imports of fmt, slices and strings):
61 → Z
62 → 10
125 → 21
1000000000 → 15FTGg
3521614606207 → ZZZZZZZ
1000000000The fifth line of output is the important one: ZZZZZZZ, the largest 7-character code, is 3,521,614,606,207, about 3.5 trillion. Our 6 billion links (§03) use under 0.2% of that. A billion-link service needs only six characters (15FTGg).
Fixed length or not?
encode(125) is only 21. If you want every code to look the same, pad on the left with 0 up to 7 characters: 0000021. It decodes to the same number, because leading zeros add nothing. Padding is purely cosmetic: it doesn't change how many codes exist.
Five ways to pick a code
We need a code that is unique, short (7 characters, §03) and, by our own requirement (§02), not trivially guessable. There are five common ways to get one. Read the descriptions, then compare them in the lab below.
1. Hash the URL and truncate
Run the long URL through a hash function (a function that turns any input into a fixed-size, random-looking number; the same input always gives the same output) and keep only enough of the result for 7 characters. This is Hash + truncate.
const space = 3_521_614_606_208 // 62^7: how many 7-character codes exist
// hashCode: hash the URL, keep the first 8 bytes, squeeze into 7 base62 characters.
func hashCode(url string) string {
sum := sha256.Sum256([]byte(url))
n := binary.BigEndian.Uint64(sum[:8]) % space // ← throws most of the hash away
out := make([]byte, 7)
for i := 6; i >= 0; i-- { // fill from the right; leading zeros stay as "0"
out[i] = alphabet[n%62]
n /= 62
}
return string(out)
}
func main() {
fmt.Println(hashCode("https://go.dev/doc/"))
fmt.Println(hashCode("https://go.dev/doc/")) // same input → same code
fmt.Println(hashCode("https://go.dev/doc")) // one character less → unrelated code
}Both reuse the alphabet constant from §05, and both print the same:
XWqS8CG
XWqS8CG
HOHHWg3- Good: no coordination at all; any server computes the same code. The same URL always gets the same code, so shortening a popular URL twice doesn't create a second row.
- Bad: a full SHA-256 hash practically never collides, but we kept only ~42 bits of its 256. Two different URLs will eventually land on the same 7 characters (§07 shows how soon). The fix is to check, and on a collision (two URLs, same code) append something (
url + "#1") and hash again. Also, "same URL → same code" is often wrong: two customers shortening the same URL want separate click counts and separate expiry dates. - The hash doesn't need to be secure (an old one like MD5 works; see MD5 / SHA-1), but anyone can compute the code for a URL they know, so codes are only as secret as the URLs.
2. A single counter + base62
Keep one counter. Each new link takes the next number and encodes it: 1 → 1, 1,000,000,000 → 15FTGg. A database sequence (a built-in counter object that hands out the next number atomically) or Redis's INCR command (an atomic "add one and return the result") does this.
- Good: never collides, so no check or retry. Codes are as short as possible for as long as possible.
- Bad: every create asks the counter. It's a Single point of failure and a bottleneck (fine at 40 creates a second, less fine across regions). And codes are predictable:
…/4c92is followed by…/4c93. A script can walk every code and collect every link anyone has ever shared, private documents included. That's Enumeration, and it breaks our "not guessable" requirement.
3. Pre-allocated ranges per server
The counter's cost is one round trip per ID. Fix that with Range allocation: each app server asks the central counter for a block of IDs, say 1,000 at a time, and hands them out from memory.
UPDATE id_blocks SET next = next + 1000 RETURNING next - 1000 AS block_start;
-- this server now owns block_start … block_start + 999- Good: the coordinator is asked once per 1,000 links. If it is down, servers keep working from the block they hold. How long that lasts is arithmetic: 40 creates/s over 4 servers is 10/s each, so a 1,000-ID block lasts ~100 s. Want to ride out a 30-minute outage? Use blocks of ~20,000. If a server crashes, the rest of its block is simply never used: we lose a few hundred codes out of 3.5 trillion. Nobody cares.
- Bad: still predictable inside a block. You still run and protect a coordinator (often just a row in the database, or a Coordination service like ZooKeeper or etcd).
4. Random codes + a uniqueness check
Pick 7 random characters using a cryptographically secure random generator (a CSPRNG). Try to insert; if the code is taken, draw again. §07 has the code and the math.
- Good: no coordinator, no shared state between servers, and nothing to predict: knowing one code tells you nothing about the next. Works the same in every region.
- Bad: collisions happen, so every insert must be insert-if-absent, plus a retry loop. Each create is one extra possible round trip, rarely needed.
5. Snowflake-style IDs
A Snowflake ID packs three parts into one 64-bit number: the current time in milliseconds (41 bits), a machine number (10 bits) and a per-millisecond counter (12 bits). Each machine makes unique IDs on its own, as long as every machine has a different number.
- Good: unique without any check; no coordinator per ID; IDs sort by creation time.
- Bad: a 64-bit number is up to 11 base62 characters, not 7. And it's partly guessable: the time is public, there are few machines, and the counter usually starts at 0. Great for database primary keys; a poor fit for short codes.
"Can't predict" is not "can't guess"
Random codes stop an attacker from working out the next code, but not from guessing at random. At year 5 the space is 0.17% full (§03), so about 1 random guess in 590 lands on a real link. A script making a million requests would find about 1,700 links. Defenses:
- Rate-limit and alert on 404s per IP address. Real users almost never hit unknown codes; scanners hit nothing else. This is the "404 rate" row in §12.
- Longer codes for links meant to stay private: 10 random characters (62¹⁰ ≈ 8 × 10¹⁷) make a hit about 240,000 times rarer than with 7 (62³ ≈ 238,000).
- A short link is not a secret. Anything truly private needs real access control behind the link (a login, or an unguessable token in the destination URL), not just an obscure code.
Try them
Set the code length to 2 characters (only 62² = 3,844 codes) to see collisions and running out of space within a few clicks. Then switch to 7 to see real codes. Try Same URL again with hash versus the others.
Side by side
| Strategy | Collisions? | Coordination | Guessable? | Length |
|---|---|---|---|---|
| Hash + truncate | Yes, check + retry | None | Only if you know the URL | 7 |
| Counter | Never | Every create | Yes, sequential | grows slowly, ≤ 7 |
| Ranges per server | Never | Once per block | Mostly, inside a block | ≤ 7 |
| Random + check | Yes, check + retry | None | Can't predict; random guessing hits ~1 in 590 at year 5 | 7 |
| Snowflake | Never | Machine IDs, once | Partly | ~11 |
Collisions: sooner than you think
The birthday bound, worked out
In a room of just 23 people, there's a better than even chance that two share a birthday, even though there are 365 days. That surprise is the Birthday paradox. The reason: with 23 people there are 23 × 22 ÷ 2 = 253 pairs of people, and each pair is a chance for a match.
The general rule: drawing random values from N possibilities, a collision becomes more likely than not after about
1.18 × √N draws (often rounded to 1.2 × √N).
For 7-character codes:
- N = 62⁷ ≈ 3,520,000,000,000 (3.5 trillion).
- √N ≈ 1,880,000.
- 1.18 × 1,880,000 ≈ 2.2 million links.
At 100 million links a month (≈ 3.3 million a day), the first collision is likely on day one. A system that assumed "3.5 trillion codes, we'll never collide" and skipped the check would send somebody's click to a stranger's link within hours of launch.
How often does a check fail?
The other question is the cost of checking. When the table already holds k codes, a fresh random code is taken with probability k ÷ N:
- After year 1 (1.2 billion links): 1.2 billion ÷ 3.5 trillion ≈ 0.03%, about 1 create in 3,000 needs a retry.
- After year 5 (6 billion links): ≈ 0.17%, about 1 in 590.
- Two failures in a row at year 5: 0.0017² ≈ 3 in a million.
So collisions are certain but cheap: the check almost always passes the first time. Explore it:
Insert-if-absent
The check must be part of the insert itself. "First SELECT to see if the code is free, then INSERT" has a gap: two servers can both see the code as free and both insert. That's a check-then-act race; see Race conditions → logical races. Let the database decide in one statement instead, using the primary key from §04. This is a Conditional write:
INSERT INTO links (code, long_url) VALUES ($1, $2)
ON CONFLICT (code) DO NOTHING; -- ← reports "0 rows inserted" if the code was takenIf the result says 0 rows, draw a new code and try again. (In a key–value database the equivalent is a "put if not exists" option, e.g. a condition on the write in DynamoDB, or SET key value NX in Redis.)
Random codes in code
Here is the whole loop. A map stands in for the table, and we use 2-character codes (only 3,844 possibilities) so collisions actually show up:
// randomCode picks n characters uniformly at random from the alphabet.
func randomCode(n int) string {
b := make([]byte, n)
for i := range b {
x, err := rand.Int(rand.Reader, big.NewInt(62)) // ← crypto-strength and unbiased
if err != nil {
panic(err) // crypto/rand doesn't fail on supported systems (Go 1.24+)
}
b[i] = alphabet[x.Int64()]
}
return string(b)
}
var links = map[string]string{} // stands in for the database table
// shorten stores url under a fresh random code, retrying if the code is taken.
func shorten(url string, length int) (code string, retries int, err error) {
for retries = 0; retries < 5; retries++ {
code = randomCode(length)
if _, taken := links[code]; !taken { // ← real DB: INSERT … ON CONFLICT DO NOTHING
links[code] = url
return code, retries, nil
}
}
return "", retries, errors.New("no free code after 5 tries")
}
func main() {
// Only 2 characters = 62² = 3,844 possible codes, so collisions show up fast.
total := 0
for i := range 1000 {
_, r, err := shorten(fmt.Sprintf("https://example.com/%d", i), 2)
if err != nil {
fmt.Println(err)
continue
}
total += r
}
fmt.Println("stored:", len(links), "retries:", total)
code, _, _ := shorten("https://go.dev/doc", 7)
fmt.Println("a real 7-char code:", code)
}The output is random, so every run differs. Two real runs of the Go version (both files also need the alphabet constant from §05, and the Go file adds package main and the imports listed below):
$ go run random.go
stored: 1000 retries: 151
a real 7-char code: H1Ucx5P
$ go run random.go
no free code after 5 tries
stored: 999 retries: 144
a real 7-char code: hjiPYQcWith 1,000 of 3,844 codes used (26% full), about one insert in seven needed a retry, and now and then one fails five times in a row. That's the tiny space exaggerating what happens in the real one, where the table stays under 0.2% full and retries are rare.
The redirect: 301 or 302?
A quick vocabulary note first. A CDN is a network of servers in many cities that store copies of responses close to users; your own servers, which the CDN forwards to when it has no copy, are called the origin (Origin). Browsers and CDNs are both caches here: the browser is a private cache (one user), the CDN a shared one (everyone nearby). §09 covers the CDN properly.
Both codes do the same thing on the first click: the browser reads the Location header and goes there. The difference is what the browser (and any cache in between) is allowed to do next time. That's 301 vs 302 in one line.
| 301 Moved Permanently | 302 Found | |
|---|---|---|
| Browser may store it? | Yes, by default, often for a very long time | Only if you send caching headers |
| Next click by the same user | Usually skips your server entirely | Comes back to your server |
| Click counts | Undercounted: repeat clicks are invisible | Every click is seen |
| Changing the destination later | Users who stored the 301 keep the old one | Takes effect on the next click |
| Load on your servers | Lower | Higher |
There are also 307 and 308: the same "temporary" and "permanent" meanings, but they also promise the browser keeps the request method (a POST stays a POST). For short links, which are always GET, 302 and 307 behave the same, as do 301 and 308. If 3xx codes are new, see the table in HTTP → messages.
See it
Step through one click. Then switch to 301 and the 2nd click: the request never leaves the browser, and the click counter doesn't move.
GET /x7Kp2Qa. DNS (the internet's name → address lookup) points sho.rt at the nearest CDN server.Tuning it with Cache-Control
You don't have to accept either default. The Cache-Control response header says exactly who may store the redirect and for how long (HTTP → caching and Caching → HTTP go deeper):
| You send | Effect |
|---|---|
302 (no Cache-Control) | Nobody stores it. Every click reaches you. Most accurate, most load. |
302 + Cache-Control: private, max-age=300 | The user's own browser reuses it for 5 minutes; shared caches (CDNs) don't. A user double-clicking costs one request. |
302 + Cache-Control: public, max-age=0, s-maxage=60 | The CDN reuses it for 60 s; browsers don't (max-age=0 tells them it's stale at once, and s-maxage applies only to shared caches). Your servers see roughly one request per CDN location per minute for a hot link. |
301 + Cache-Control: max-age=86400 | Browsers and CDNs reuse it for a day, then ask again. Caps how long a changed or removed link keeps working from caches. |
301 (no Cache-Control) | Browsers may keep it indefinitely. You can't take it back for that user until their browser cache is cleared or the entry is evicted. |
When the code isn't a normal link
| Situation | Answer |
|---|---|
| Code never existed | 404 Not Found |
| Link expired or was deleted | 410 Gone (better than 404: "it existed, it's gone on purpose") |
| Link disabled for abuse | 410, or a 200 warning page explaining why |
| The redirect server is overloaded | 503 Service Unavailable, with a Retry-After header |
Cache these "no" answers briefly too (a few seconds to minutes). Otherwise a bot hammering non-existent codes sends every one of its requests to the database. That's called Negative caching. Two rules keep it safe: cache a 404 only after the primary database confirms the code doesn't exist (a lagging replica can wrongly say "not found" for a brand-new link, §09), and when a link is created, delete any cached 404 for its code.
Scaling the read path
Version 1 (§04) sends every redirect to the database. Let's walk the traffic up and fix whatever breaks first. Throughout, assume rough, round capacities for simple lookups: one database machine handles about 5,000 reads/s or 5,000 writes/s comfortably, one cache node about 100,000/s, and one app server about 2,000 redirects/s. When we add a CDN, assume it answers about 60% of redirects itself (the hot links), so 40% reach us; that's also why losing the CDN multiplies origin traffic by about 2.5 (100% ÷ 40%). (Real numbers depend on hardware and tuning; these are for reasoning, not for a purchase order.)
1×: ~10,000 redirects/s at peak → add a cache
At our estimated peak (§03), 12,000 reads a second would need 2–3 database machines just for lookups. But link traffic has a Long tail: a few links get most of the clicks, and most links are clicked a handful of times, ever. So a cache holding only the popular links answers most requests.
The pattern is Cache-aside, exactly the lookup function from §04: check the cache; on a miss, read the database and store the answer with a time-to-live. (Caching → cache-aside covers it fully, including the race on updates.) Short links almost never change, which makes them the easiest data there is to cache: no invalidation headaches, just a TTL and a delete when a link is disabled or expires.
The Hit rate decides how much the database still sees:
database reads = redirects × (1 − hit rate)
12,000/s × (1 − 0.90) = 1,200/s: one database machine is fine again.
12,000/s × (1 − 0.99) = 120/s: the database barely notices.
Going from 90% to 99% cuts database load by 10× again; 99.9% would cut it another 10×. That's why cache size and TTL are worth tuning: keeping more of the long tail in memory raises the hit rate.
10×: ~100,000/s → add read replicas and a CDN
Ten times the traffic: even at 90% hits, the database gets 10,000 reads/s. Two fixes:
- Read replicas. Copies of the database that receive every change from the main one (the primary) and answer reads. Each Read replica adds about 5,000 reads/s of capacity, and they also mean a dead machine doesn't take reads down with it. Replication → routing reads explains how the app chooses which copy to ask.
- A CDN. A CDN runs servers in many cities; DNS (the internet's name → address lookup, see DNS) sends each user to a nearby one. With the short
s-maxagefrom §08, a CDN server stores a hot link's redirect for a minute and answers everyone near it, so your origin sees about one request per minute per CDN location for that link instead of thousands a second. That's an Edge cache, and it also makes redirects faster for faraway users.
100×: ~1,000,000/s → shard
At a million redirects a second, creates are 10,000/s. Every write still goes to one primary, and that primary is now the bottleneck, while the data itself (3 TB and growing) is large for one machine. The fix is to split the table across several databases by code: sharding, §10. Cache nodes multiply too: no single cache server takes a million operations a second, so the cache itself is split by key.
Try it
Drag the traffic up. At each level, find the smallest set of additions that turns everything green, then press Suggested (with headroom) to compare. The suggestions add a little more than the minimum on purpose (a cache at 1,000/s, replicas at 10,000/s), so that one dead machine or a traffic spike doesn't turn things red.
Storage: a key–value table that shards itself
It's a key–value problem
Look at what the system actually does with the links table from §04:
- Redirect (99% of traffic): get the row where
code = ?. - Create: insert a row if that code is free.
- Expire / disable: delete or mark the row with that code.
Every access names exactly one code. No joins, no ranges, no "all links between X and Y" on the hot path. That's a Key–value store access pattern, whatever database you put it in.
Sharding by code
Sharding means splitting one big table across several databases (shards), each holding a slice of the rows. The hard part is always choosing the Shard key; see Sharding → schemes for the full menu. Here the choice makes itself:
- Shard key = the code. The redirect has the code in hand, so it can compute the shard with no extra lookup:
shard = hash(code) % number_of_shards. That's Hash partitioning. - Even spread. Codes are random (or hashed), so each shard gets an equal share of rows and of writes.
- Each request touches one shard. No query ever needs to ask all of them.
Compare the wrong keys: sharding by creation month puts all new links, which get most of the clicks, on one shard. Sharding by customer makes one big customer's shard a hotspot, and a redirect wouldn't even know which customer's shard to ask.
In practice, use many more logical shards than machines (say 1,024 shards on 8 machines), or Consistent hashing (Load balancing → hashing), so you can add machines later by moving a few shards instead of rehashing every row. Sharding → resharding covers how.
Which database?
Any of these works, because the access pattern is so simple:
| Choice | Why it fits | Watch out for |
|---|---|---|
| Postgres / MySQL, sharded by your app or a proxy | Familiar; ON CONFLICT DO NOTHING gives insert-if-absent; replicas built in | You run the sharding and resharding yourself |
| A managed key–value store (e.g. DynamoDB) | Shards by partition key automatically; conditional writes give insert-if-absent; built-in TTL deletion | Per-request pricing model; fewer query options later |
| A wide-column store (e.g. Cassandra) | Spreads by key, scales writes linearly, many replicas | Insert-if-absent needs its slower lightweight-transaction mode |
The design matters more than the brand: key = code, insert-if-absent, replicas for reads and survival.
Expired links
Two separate jobs:
- Stop redirecting on time. Check
expires_aton every lookup, including cache hits (storeexpires_atin the cache entry, or cap the cache TTL at the expiry time). Answer410 Gone(§08). This must be exact. - Delete the rows eventually. A background job deletes expired rows in small batches during quiet hours, or the database's own TTL feature (TTL) does it. Built-in TTL deletion is usually best-effort and can run hours to days late; DynamoDB's documentation, for example, says expired items are typically deleted within a few days (as of October 2026). Fine here, because step 1 already stopped the redirects.
Should an expired code be reused? Usually no. Old copies of the short link live on in emails, PDFs and printed posters; reusing the code sends those readers to a stranger's page. With 3.5 trillion codes there's no shortage to justify it.
Failure modes and abuse
That asymmetry comes straight from the requirements (§02): redirects need 99.99%, creates can be retried. So we protect the read path first.
Kill things
Toggle components off, alone and in combination. Notice which user actions survive. (The ID coordinator only exists if you chose counter ranges in §06; with random codes it isn't there to fail.)
A viral link: the stampede
A celebrity posts a short link; it gets 20,000 clicks a second. The cache absorbs them, until the cache entry's TTL runs out. In that instant every request misses together and all of them query the database for the same row. That's a Cache stampede, and it can knock over a database that was idling a second earlier. The standard fixes, in Caching → stampede:
- Coalesce the misses. Only one request per app server rebuilds the entry; the rest wait for its answer (singleflight in Go, a shared promise in JS).
- Refresh early. Popular entries are reloaded shortly before they expire, so readers never see a miss.
- Add jitter. Randomize TTLs (say 24 h ± 10%) so entries filled at the same moment don't all expire at the same moment.
- The CDN layer from §09 means the origin sees a trickle for the hottest links anyway.
The database is down
Because links almost never change, a stale answer is a correct answer. So:
- Serve from cache. Popular links keep working with no database at all. Links not in the cache fail with
503, so a large cache literally increases your availability. - Fail over. Replicas keep serving reads; one is promoted to primary for writes, usually within tens of seconds. See Replication → failover.
- Don't turn a database outage into a cache outage. If misses retry aggressively, they pile onto the recovering database. Use timeouts, a few retries with Exponential backoff (wait longer after each failure; see Idempotency → retries), and fail fast.
The opposite case, the cache dying, is worse than it sounds: the database suddenly gets 10× or 100× its usual reads (§09's hit-rate math, in reverse). If the database can't survive a cold cache, the cache has become load-bearing. Run the cache as a replicated cluster, and plan how to warm a new node with the top links before giving it traffic.
Abuse: the shortener as a weapon
A short link hides where it goes. That makes it a favourite tool for Phishing, malware and spam, and if your domain carries enough of it, email providers and browsers start blocking every link on your domain, including the legitimate ones. Defenses, cheapest first:
- Rate-limit creation per API key, per account and per IP address, e.g. 100 links per hour for free accounts. Return
429 Too Many Requests. Rate limiting covers the algorithms; Rate limiting → keys covers what to count by. - Check destinations at creation: refuse URLs whose domain is on a Blocklist of known phishing and malware sites, URLs pointing at your own domain (redirect loops), and non-
http(s)schemes likejavascript:. - Re-check later. Bad sites are often clean at creation and turn malicious next week. Re-scan popular and recent links in the background.
- Be able to disable a link instantly. Mark it disabled in the database and delete it from the cache; this is where a 302 pays off (§08), because nobody holds a stored copy for long.
- A preview option:
sho.rt/x7Kp2Qa+shows the destination instead of redirecting, so careful users can check before clicking.
Expired and deleted links
- Expiry is checked on every redirect, cache hits included, and answered with
410 Gone(§10). - Deletion must also remove the cache entry, and the CDN copy if one exists (a CDN purge call), or the link keeps working for up to one TTL.
- With a stored 301 in someone's browser, neither helps. One more reason for 302 by default.
Analytics and what to monitor
Analytics without touching the hot path
Each redirect produces a Click event: code, time, country (from the IP address), referrer, device. Writing it to the database synchronously would turn every read into a write and add a write round trip to every redirect. Instead:
- The redirect handler sends the 302 first, then hands the event to a small in-memory buffer, without waiting.
- A background goroutine (Go) or a timer (Node) flushes the buffer every second, in batches, to a queue or log such as Kafka. A Message broker absorbs bursts; see Queues.
- A consumer aggregates events into counts per code per minute and writes those to an analytics store.
The trade-off is explicit: if an app server crashes, the events still in its buffer are lost. For click counts, losing a fraction of a percent is fine; if it isn't (say, you bill per click), use the CDN or load-balancer access logs as the source of truth and rebuild counts from them.
Remember the hits you don't see: CDN hits (§09) and stored 301s (§08) never reach your app. CDN logs fill the first gap. Nothing fills the second.
What to monitor
Short list, highest value first:
| Metric | Why | Alert when |
|---|---|---|
| Redirect success rate (non-5xx share) | The 99.99% requirement, as a SLO | Burning the monthly Error budget too fast (99.99% allows 0.01% failures, about 4 minutes a month) |
| Redirect latency, p99 | "Redirects are fast" (§02) | p99 above 10 ms server time for 5+ minutes |
| Cache hit rate | The database's safety margin (§09) | Drops sharply, e.g. 95% → 80% |
| Database read and write rate, replication lag | Catches a stampede or a cold cache early | Lag above a few seconds; reads near capacity |
| 404 rate | A spike means scanners walking codes, or a broken deploy | Several times its normal level |
| Create errors and insert retries | Retries should be well under 1% (§07); a jump means a bug in the generator | Retry share above 1% |
| Rejected creates (blocklist, rate limits) | Abuse waves (§11) | Sudden spikes |
Observability → SLOs and → alerting explain how to turn these into alerts that wake someone only when users are actually hurt; Reliability → burn rate explains how fast is "too fast" for an error budget.
Check yourself
Answer before you click. Q1 is from §03, Q2 from §08, Q3 from §07, Q4 from §06, Q5 from §11 and Q6 from §10.
A shortener creates 30 million links a month and sees 100 redirects per new link. Roughly how many redirects per second, on average?
Marketing wants accurate click counts for every campaign link, and wants to be able to change where a link points after it's printed on posters.
code := randomCode(7)
db.Exec("INSERT INTO links (code, long_url) VALUES ($1, $2)", code, url)
return "https://sho.rt/" + codeCodes come from one global counter: …/4c91, …/4c92, …/4c93. What is the main risk besides the counter being a single point of failure?
A celebrity posts a short link. Its cache entry expires at 12:00:00 while 20,000 clicks a second are arriving. What happens without protection?
You need 8 database shards. Which shard key fits the redirect path best?
Cheat sheet
ON CONFLICT DO NOTHING + retry.| Strategy | Collisions | Coordination | Guessable | Length |
|---|---|---|---|---|
| Hash + truncate | check + retry | none | if URL known | 7 |
| Counter | never | every create | yes | ≤ 7 |
| Ranges per server | never | per block | mostly | ≤ 7 |
| Random + check | check + retry | none | no | 7 |
| Snowflake | never | machine IDs | partly | ~11 |
| Traffic (peak redirects) | Add |
|---|---|
| ~1,000/s | One DB, two app servers. A cache is cheap insurance. |
| ~10,000/s | Cache (90%+ hits). Replicas for survival. |
| ~100,000/s | CDN with short s-maxage, more replicas. |
| ~1,000,000/s | Shard the DB and the cache by code. |