Design a URL Shortener
Design a URL shortener for a system design interview: short codes, fast countable redirects at 100,000,000 new links a day, with every number shown.
Problem statement
The prompt is short and exact: design a URL shortener. A URL shortener is a service that stores a long link under a short code and sends each visitor from the code to the long link. The short string that stands for one long link is the short code, and the response that sends a visitor from a short code to its long link is a redirect. Two calls carry the whole product: a shorten call stores a long link and returns a short code, and a redirect call looks up a short code and answers with a redirect.
The core loop runs in one direction far more often than the other. A link is created once, when its owner shortens it, and the same link is then visited again and again by everyone the owner shares it with. Reads therefore dwarf writes, and the redirect path, not the creation path, decides whether the design feels fast. A design that treats the two calls as equals spends its effort in the wrong place.
Three clarifying questions shape everything that follows, and each one is settled as a decision rather than left as a guess. Custom codes are allowed: a visitor may choose a code instead of an assigned one, when the code is free. Whether a link may end after a set time is asked at the start and settled as part of the design, with an end day stored in the record. The working choice for redirects is a temporary redirect, because visits must stay countable, and the permanent alternative is weighed on its merits before the design closes.
Questions become decisions
decisions = [
("reads or writes more?", "reads"),
("custom codes allowed?", "yes, when the code is free"),
("links that end allowed?", "asked now, settled later"),
("redirect status?", "302 as the working choice"),
]
for question, answer in decisions:
print(question, "->", answer)The demo records the four opening decisions in the form the design carries them: reads dominate, custom codes are allowed when free, ending links are asked about early and settled in the design, and the temporary redirect status is the working choice. None of the four is a guess, because each one changes a later component. Read dominance pushes effort into the redirect path, custom codes force a store check on creation, ending links put an end day in the record, and the redirect status decides whether visits can be counted at all.
In scope
- Shortening a long link: storing the link under an assigned short code and returning the code
- Custom codes: storing a link under a code the visitor chooses, when that code is still free
- Redirecting: answering a visit to a short code with a redirect to its long link
- Links that end: a link may carry an end, after which it no longer redirects
- Visit counting: every visit is seen by the service, so a link owner can count how a link performs
- Abuse control: a known bad code can be blocked, so it stops redirecting
Out of scope
- User accounts, sign-in and billing for link owners
- Editing a long link after its code has been handed out
- Previews, thumbnails and page summaries for long links
- Stopping one client from calling too often: that is the rate limiter design, a separate problem with its own mechanisms
Requirements
The requirements are the contract the rest of the design is judged against. Each functional item names a behaviour the service must show, together with the consequence that makes the behaviour matter. Each non-functional item names a bar the behaviour must clear, and those bars โ speed on the redirect path, a code that never collides, storage that grows for years โ force the interesting decisions later.
Functional requirements
- Shorten a link: a caller sends a long link and receives a short code, and the code redirects correctly from the moment it is returned, so a code is never handed out before its record exists
- Allow a custom code: a caller may choose the code, and the service accepts the choice only when the code is free, so two links never share one code
- Redirect a visit: a visit to a short code answers with a redirect to the stored long link, using status 302, so the browser asks the service again on the next visit and the visit stays countable
- Answer an unknown code honestly: a code with no record answers status 404, the not-found status, instead of a redirect, so broken and mistyped links fail visibly
- End a link: a link whose end has passed no longer redirects, so time-limited links stop working on schedule without manual cleanup
- Block a bad code: a code on the block list is refused before any redirect is sent, so a link found to hide a harmful destination stops reaching visitors
- Count visits: the service keeps a count of visits for each code, so a link owner can see how a link performs
Non-functional requirements
- Redirects stay fast at volume: redirects arrive at about 11,574 per second, so the lookup behind a redirect must be a single cheap step, never a search
- Reads dominate by design: the service serves 10 redirects for every 1 new link, so read scaling matters ten times as much as write scaling
- Codes never collide: two different long links never end up with the same short code, because a collision would send visitors to the wrong destination
- Storage lasts for years: records accumulate to 18.25 TB over 5 years, so the store must grow by adding capacity rather than by redesign
- A stored link is not lost: once a shorten call returns a code, the record behind that code survives ordinary machine failures
- Counting stays exact: a design that promises visit counts must see every visit, which rules out any redirect the browser can reuse silently
Scale estimates
Estimation is rough arithmetic that turns daily counts into per-second rates and storage sizes. The method uses one constant throughout: a day holds 86,400 seconds, and a daily count becomes a per-second rate by dividing by 86,400. The inputs are stated assumptions, not measurements, and every figure in the table is derived from those inputs in the open. Two of the derived figures drive the architecture: the redirect rate sizes the read path, and the storage growth sizes the store.
The scale of the problem
| Quantity | Value | How derived |
|---|---|---|
| New short links a day | 100,000,000 | Stated assumption |
| Seconds in a day | 86,400 | Stated convention |
| Writes per second | 1,157 | 100,000,000 รท 86,400 |
| Redirects per write | 10 | Stated assumption |
| Redirects a day | 1,000,000,000 | 100,000,000 ร 10 |
| Redirects per second | 11,574 | 1,000,000,000 รท 86,400 |
| Bytes per record | 100 | The code, the long link and the date, rounded |
| New storage a day | 10 GB | 100,000,000 records ร 100 bytes |
| New storage a year | 3.65 TB | 10 GB a day ร 365 days |
| New storage over five years | 18.25 TB | 3.65 TB a year ร 5 years |
The write rate comes first, because it is the smaller and calmer of the two streams. The shortener takes 100,000,000 new links a day, and 100,000,000 รท 86,400 = 1,157 writes per second. A rate of 1,157 writes per second is well within what a modest set of app servers and one well-organised store can absorb. Nothing on the write path needs exotic machinery, and that calm is worth stating plainly before the read side changes the picture.
From a day to a second
writes_per_day = 100000000
seconds_per_day = 86400
print("writes per day:", writes_per_day)
print("seconds per day:", seconds_per_day)
print("writes per second:", round(writes_per_day / seconds_per_day))The demo performs the opening division exactly as the table states it. A daily count of 100,000,000 divided by 86,400 seconds gives 1,157 writes per second after rounding to the nearest whole number. The rounding is deliberate: per-second rates are planning figures, and a fraction of a write per second names no real machine load. Every rate that follows uses the same division and the same rounding.
The read side is the write side multiplied by the shape of the product. Redirects run at 10 per write, so the shortener serves 100,000,000 ร 10 = 1,000,000,000 redirects a day. Dividing again, 1,000,000,000 รท 86,400 = 11,574 redirects per second, ten times the write rate at every moment of the day. Averages also hide peaks, so a read path built to survive exactly 11,574 per second would fail on the first busy morning.
Reads outnumber writes
writes_per_day = 100000000
reads_per_day = writes_per_day * 10
print("reads per write:", reads_per_day // writes_per_day)
print("reads per day:", reads_per_day)
print("reads per second:", round(reads_per_day / 86400))The demo multiplies before it divides, which keeps the two steps of the read estimate separate and checkable. The ratio of 10 reads per write is a stated assumption about how links are shared, and the billion-a-day redirect count follows from that ratio alone. If sharing behaviour changed and the ratio doubled, the redirect rate would double with it while the write rate stood still. That sensitivity is exactly why the read path, and not the write path, earns the deep dive on scaling.
Storage grows slowly and only in one direction. One stored record takes 100 bytes, for the code, the long link, and the date, so a day of records takes 100,000,000 ร 100 = 10,000,000,000 bytes, which is 10 GB a day. A year of growth is 10 GB ร 365 = 3.65 TB, and five years of growth is 3.65 TB ร 5 = 18.25 TB. Those totals are large in absolute terms and small for a store organised by key, so storage capacity is a planning exercise rather than a design crisis.
Storage adds up
records_per_day = 100000000
bytes_per_record = 100
bytes_per_day = records_per_day * bytes_per_record
print("bytes a day:", bytes_per_day)
print("GB a day:", bytes_per_day // 1000000000)
print("TB a year:", bytes_per_day * 365 / 1000000000000)
print("TB in five years:", bytes_per_day * 365 * 5 / 1000000000000)The demo grows one day of records into a year and into five years with the same multiplication the table uses. The byte total for a single day, 10,000,000,000 bytes, is the figure to hold onto, because every longer total is that number scaled by days. Records are never edited and rarely deleted, so the store only ever appends. A store that only appends and is only ever read by key is the simplest store there is, and the record shape earns that simplicity.
URL Shortener: Estimate the Scale
writes_per_day = 100000000
reads_per_day = writes_per_day * 10
bytes_per_day = writes_per_day * 100
print("writes per second:", round(writes_per_day / 86400))
print("reads per second:", round(reads_per_day / 86400))
print("reads per write:", reads_per_day // writes_per_day)
print("GB of new records a day:", bytes_per_day // 1000000000)
print("TB of new records a year:", bytes_per_day * 365 / 1000000000000)The closing calculation gathers the three chains into one place and states the design problem in numbers. The shortener takes 1,157 writes per second and 11,574 redirects per second, a ratio of 10 to 1 that no later decision is allowed to forget. Storage grows by 10 GB a day and reaches 3.65 TB a year, which the store absorbs by growing steadily. The verdict of the estimates is therefore asymmetric: build the write path for correctness, and build the read path for volume.
API design
Two operations carry the whole API, and the table lists them together with the custom-code variant of the first. A shorten call stores a long link and returns a short code, and a redirect call looks up a short code and answers with a redirect. The shapes are deliberately small: the shorten call needs nothing but the long link, and the redirect call needs nothing but the code. Everything the design adds later โ counting, expiry, blocking โ hangs off those two shapes without changing them.
Core operations
| Operation | Request | Response | Notes |
|---|---|---|---|
| Shorten a link | A long link | The new short code | A fresh code is generated, the record is stored, and the code is returned only after the store accepts the record |
| Shorten with a custom code | A long link and a chosen code | The chosen code, or a refusal when the code is taken | The store is checked first, so a taken code is never handed out a second time |
| Redirect to a long link | A short code | Status 302 with the long link, or status 404 when the code is unknown | Blocked codes and ended links are refused before any redirect is sent |
The smallest possible model of the two calls already shows the whole contract. Creation assigns the next code, stores the long link under it, and returns the code to the caller. Lookup takes a code and returns whatever long link the store holds for it, with a plain not-found answer for a code that was never created. The short code is the whole link between the two calls: nothing else passes from creation to redirect, and nothing else needs to.
The two calls
store = {}
def create(long_url):
code = "s" + str(len(store) + 1)
store[code] = long_url
return code
def redirect(code):
return store.get(code, "not found")
print("create:", create("https://example.com/a-very-long-article"))
print("create:", create("https://example.com/another-long-page"))
print("redirect s1:", redirect("s1"))
print("redirect s2:", redirect("s2"))The demo creates two links and then redirects both codes, and each redirect returns exactly the long link stored at creation time. That round trip is the product in miniature, and every later mechanism exists to keep the round trip correct and fast at scale. Code generation decides how the next code is chosen, and the data model decides how the store is organised. Neither changes what the two calls promise the caller.
A redirect is more than a destination: it is a status paired with a destination. Status 302 is a temporary redirect, which tells the browser to ask the service again next time rather than remembering the answer. Status 301 is a permanent redirect, which browsers may store and reuse without asking again. An unknown code answers status 404 instead of any redirect, because there is no destination to send the visitor to.
A redirect answers with a status
destinations = {"s1": "https://example.com/a-very-long-article", "s2": "https://example.com/another-long-page"}
def redirect(code):
if code in destinations:
return 302, destinations[code]
return 404, "not found"
print("s1:", redirect("s1"))
print("s9:", redirect("s9"))The demo pairs each answer with its status, so the contract is visible rather than implied. A known code returns status 302 together with its long link, and an unknown code returns status 404 together with a not-found message. The status is what makes counting possible later: because a 302 answer is asked for again on every visit, the service sees every visit. The full weighing of 301 against 302, with the counting arithmetic shown, sits in the deep dive on redirect status.
Creation and visiting interleave in real traffic, and the closing model shows the interleaving at the smallest useful scale. Two links are created first, and then three visits arrive, two of them for the first code. Each visit returns the long link stored under its code, so repeat visits cost the service one lookup each and return the same answer every time. The counts at the end โ 2 links stored and 3 redirects served โ already show reads overtaking writes, the pattern the estimates scale up.
Design a URL Shortener: The Ask
store = {}
def create(long_url):
code = "s" + str(len(store) + 1)
store[code] = long_url
return code
create("https://example.com/first-long-article")
create("https://example.com/second-long-page")
visits = ["s1", "s2", "s1"]
for code in visits:
print("visit", code, "->", store[code])
print("links stored:", len(store))
print("redirects served:", len(visits))Both operations are synchronous, and neither needs a queue. A shorten call finishes when its record is stored, because the caller is waiting for a code and the work is a single store write plus a code assignment. A redirect call finishes when its lookup answers, because the visitor is waiting and the work is a single read. Visit counting rides on the redirect call itself: the service sees each visit as it answers, and keeping the count is part of answering rather than a separate job that might lag or be lost.
Data model
The data model is the set of records a service stores and the fields each record holds. One stored item holding a short code, its long link, and its creation date is a record, and the record is the only substantial entity the design owns. The short code is the key: the value a lookup uses to find its record. Every read the service performs asks for one code and nothing else, which is what makes the model, and the store behind it, so simple.
Core entities
| Entity | Key fields | Notes |
|---|---|---|
| Link record | short code | The long link, the creation date and the end day; one record is about 100 bytes |
| Block list entry | short code | Marks a code that must never redirect; the list is checked before a redirect is sent |
| Visit count | short code | One running count of visits per code, so a link owner can see how a link performs |
One record holds exactly three fields, and the demo prints them one by one. The code identifies the record, the long link is the payload the redirect returns, and the creation date records when the link was made. The end day that expiry needs joins the same record, because the redirect path must be able to judge a link from its record alone. A record that answers every question the redirect path asks keeps that path down to a single lookup.
One record, three fields
record = {"code": "qi", "long link": "https://example.com/a-very-long-article", "creation date": "2026-01-01"}
for field in ["code", "long link", "creation date"]:
print(field, ":", record[field])
print("fields in one record:", len(record))The demo counts the fields as well as printing them, and the count of 3 is the point. Three small fields, fixed in shape, mean the record size estimate of 100 bytes holds for every link the service ever stores. No record grows with visits, with sharing, or with age, because counts and block state live in their own small entities keyed by the same code. Fixed shape is what lets the storage arithmetic stay a straight multiplication for years.
The store choice follows from the access pattern rather than from fashion. A store organised by key fits, because every read asks for one code and nothing else: there is no query by long link, no query by date, and no scan across records on any hot path. The short code is therefore both the partition key, which decides which machine holds a record as the store grows, and the index key, which finds the record on that machine. The record is small and fixed in shape, so the store stays simple as records grow from thousands to the full multi-year total.
High-level architecture
The components follow directly from the two calls and the read-heavy estimates. Clients talk to app servers, and a load balancer โ a component that spreads incoming requests across many app servers โ keeps any one server from becoming the whole service. Behind the app servers sit the cache and the store: the cache answers popular lookups from memory, and the store, with its read replicas, holds every record durably. The block list is checked on the redirect path before any answer is sent, so a blocked code is refused no matter which layer would have answered it.
โโโโโโโโโโโโ short link request โโโโโโโโโโโโโโโโโโ
โ Client โ โโโโโโโโโโโโโโโโโโโโโโถ โ Load balancer โ
โโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโ
โฒ โ
โ redirect answer โผ
โ โโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ App servers โ
โโโโโโโโโโโโโโโโโโ
โ โ โ
block check โผ โผ โผ lookup
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Block โโ Cache โโ Database โ
โ list โโโโโโโโโโโ + replicas โ
โโโโโโโโโโ โโโโโโโโโโโโโโThe shorten flow
- The client sends a long link to an app server through the load balancer
- The app server obtains the next short code, or checks the custom code the caller chose
- The app server stores the record, keyed by the short code, in the database
- The app server returns the short code, and the link redirects correctly from that moment
The redirect flow
- The client asks an app server for a short code through the load balancer
- The app server checks the block list and refuses a blocked code at once
- The app server looks the code up in the cache, and reads the database or a replica on a miss
- The app server checks the end day, counts the visit, and answers status 302 with the long link, or status 404 for an unknown code
The redirect path, walked slowly, is three steps in a fixed order. The request arrives carrying a code, the code is looked up in the store, and the answer goes back as status 302 with the long link the lookup found. Arrival, lookup, answer: no step can be reordered, because the answer depends on the lookup and the lookup depends on the code that arrived. The cache and the replicas change where the lookup is answered, never what the three steps are.
The redirect path, step by step
store = {"qi": "https://example.com/a-very-long-article"}
def redirect(code):
print("step 1: request arrives for", code)
print("step 2: look up", code, "in the store")
print("step 3: answer 302 to", store[code])
redirect("qi")The demo prints the three steps for one known code, and the order in the printout is the order the service performs. Step order matters for the checks that join the path later: the block check belongs before the lookup, and the end-day check belongs after it, because only the record knows its end day. A path this short has nowhere to hide wasted work, which is why it can be asked to run more than ten thousand times a second. Every added step would be paid at the full redirect rate.
Known codes and unknown codes take different exits from the same lookup. A known code answers with a redirect to its long link, carrying status 302 as the design requires. An unknown code answers status 404, because the store holds no record to redirect to and inventing a destination would be worse than failing. The distinction costs one membership check inside the lookup, and it keeps mistyped and invented codes from ever being mistaken for real links.
Known codes and unknown codes
store = {"qi": "https://example.com/a-very-long-article"}
def redirect(code):
if code in store:
return 302, store[code]
return 404, "not found"
print("qi:", redirect("qi"))
print("zz:", redirect("zz"))The demo answers one code of each kind, and the pair of answers is the complete behaviour of the path without a cache. A cache changes the speed of the known-code answer and changes nothing about the unknown-code answer, which still ends at the store. That separation is what makes the read-scaling dive safe to reason about: caching is layered onto a path whose answers are already correct. Speed is added around correctness, never instead of it.
The closing model serves a mixed stream of visits against a small store and tallies the outcomes. Three records are stored, five visits arrive, and the visits split into 4 redirects and 1 not-found answer. The split is the honest shape of real traffic: most codes are real, some are mistyped or invented, and both kinds arrive through the same door. A design that only rehearsed the happy path would meet the not-found case for the first time in production.
URL Shortener: Data and Serving
store = {
"qi": "https://example.com/a-very-long-article",
"qj": "https://example.com/another-long-page",
"qk": "https://example.com/a-third-long-page",
}
visits = ["qi", "qj", "qi", "zz", "qk"]
hits = 0
misses = 0
for code in visits:
if code in store:
hits = hits + 1
print("visit", code, "-> 302 to", store[code])
else:
misses = misses + 1
print("visit", code, "-> 404 not found")
print("records stored:", len(store))
print("redirects:", hits)
print("not found:", misses)Every functional requirement lands on a component in the diagram. Shortening lands on the app servers and the database, redirecting lands on the app servers with the cache and the replicas behind them, ending lands on the end day inside each record, blocking lands on the block list check, and counting lands on the redirect path itself, where every visit is seen. No requirement depends on a component the diagram does not show. No component is present without a requirement behind it.
Deep dive: generating short codes
Code generation is the heart of the design, because the code is the product. The generator must produce 100,000,000 codes a day, one for every new link, and it must never produce the same code twice for two different links. Two different long links ending up with the same short code is a code collision, and a collision is the one failure the service cannot paper over, because one of the two links would redirect to the wrong destination. The generator also decides how long codes must be, and length decides how many years the service can run before the code space runs out.
The alphabet comes first. Base62 is a way of writing codes with 62 symbols: the 26 lowercase letters, the 26 uppercase letters, and the 10 digits, and 26 + 26 + 10 = 62. Writing numbers in base62 works exactly like writing numbers in ordinary decimal, except each position holds one of 62 symbols instead of one of 10. Short codes stay short because each character carries more possibilities, and the symbols are all safe to type, copy and print inside a link.
Three ways to generate a code
| Option | How it works | Strengths | Weaknesses |
|---|---|---|---|
| Counter written in base62 | A counter counts up by one for each new link, and each count is written in base62 to form the code | Codes can never collide, because counts never repeat; generation is one increment and one conversion | The next code is easy to guess, because codes follow the counting order |
| Hash of the long link | A hash function turns the long link into a value of a fixed size, and part of that value becomes the code | The code derives from the link itself, so the same link can map to the same code | Two different links can hash to the same code, so collisions must be spotted and retried |
| Random code with a store check | A code is drawn at random from the alphabet, then checked against the store before it is kept | Codes are unpredictable, so the next code cannot be guessed from the last one | Every draw needs a store check, and a taken code means drawing again |
A hash is a function that turns any input into a value of a fixed size, and hashing a long link feels like the natural way to derive its code. The catch is that a fixed-size value has a limited number of buckets, and different links can land in the same bucket. A toy model makes the failure concrete: the toy hash sends each link to a bucket chosen from the length of the link, and two different links land in the same bucket at once. Real hash functions spread links far more evenly than the toy, but no fixed-size hash removes the possibility, so the possibility has to be handled rather than wished away.
Two links, one bucket
def toy_bucket(url):
return len(url) % 4
store = {}
urls = ["https://example.com/cats", "https://example.com/dogs"]
for url in urls:
bucket = toy_bucket(url)
print(url, "lands in bucket", bucket)
if bucket in store:
print("collision: bucket", bucket, "already holds", store[bucket])
else:
store[bucket] = urlThe demo shows the collision in the printout rather than describing it. The first link takes the bucket, and the second link, a different link with the same length, arrives at the same bucket and finds it occupied. Nothing in the hash warned either link in advance, because the bucket depends only on the input each link brought. Any design that derives codes from link content inherits exactly this moment, at production scale and without the tidy printout.
The standard repair is check and retry: a taken code is spotted in the store and the generator tries again. The generator proposes a code, looks the code up, and keeps it only when the store has no record under it. A taken code is skipped and the next candidate is tried, so every link still ends with a code of its own. The repair costs one store lookup per creation, which the write rate of 1,157 per second pays easily.
Check the store, then retry
def toy_bucket(url):
return len(url) % 4
store = {}
def add(url):
bucket = toy_bucket(url)
while bucket in store:
print(url, "finds bucket", bucket, "taken")
bucket = bucket + 1
store[bucket] = url
print(url, "kept in bucket", bucket)
add("https://example.com/cats")
add("https://example.com/dogs")
add("https://example.com/fish")The demo walks three links through the repair, and the printout shows each skip as it happens. The first link keeps the first bucket, the second link finds that bucket taken and moves on, and the third link skips past two occupied buckets before settling. Every link ends in a different bucket, which is the property the whole repair exists to protect. The number of retries grows as the space fills, one more reason to keep the code space far larger than the number of links stored.
Counter codes sidestep the entire problem by construction. A count becomes a code by writing the count in base62, and because counting never repeats a number, the codes built from counts never repeat either. The conversion divides by 62 again and again, taking the remainder as the next symbol, exactly as decimal conversion divides by 10. Small counts make short codes and larger counts make longer ones, so the counter also paces the length of the codes the service hands out.
A counter becomes a code
alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
def encode(number):
if number == 0:
return alphabet[0]
code = ""
while number > 0:
code = alphabet[number % 62] + code
number = number // 62
return code
for count in [0, 1, 25, 61, 62, 12345]:
print(count, "->", encode(count))The demo converts six counts, and the printout rewards a close reading. Counts below 62 become single symbols, running through the alphabet in order, and count 62 rolls over to a two-symbol code, exactly as decimal rolls over at 10. Count 12,345 becomes a three-symbol code, showing how quickly counting reaches compact codes. One code per count, with no repeats and no gaps in the logic, is the whole argument for the counter in one small table of conversions.
Length is a multiplication problem, and the arithmetic decides between two candidates. With 62 symbols, 6 characters give 62 ร 62 ร 62 ร 62 ร 62 ร 62 = 56,800,235,584 codes, about 56.8 billion codes. At 100,000,000 new links a day, that space lasts 56,800,235,584 รท 100,000,000 = 568 days, which is short for a service whose links are expected to live for years. Seven characters give 62^7 = 3,521,614,606,208 codes, and 3,521,614,606,208 รท 100,000,000 รท 365 = about 96 years of headroom.
Generating Short Codes
alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
def encode(number):
if number == 0:
return alphabet[0]
code = ""
while number > 0:
code = alphabet[number % 62] + code
number = number // 62
return code
codes = []
for count in range(1000, 1005):
codes.append(encode(count))
for code in codes:
print("code:", code)
print("codes made:", len(codes))
print("different codes:", len(set(codes)))The closing model takes five consecutive counts and converts each one, then counts the distinct codes produced. Five counts become 5 different codes, and the equality of the two counts is the proof that counting cannot collide. No store check and no retry loop appears in the model, because none is needed on the happy path. Custom codes still need the store check, since a chosen code can clash with an assigned one, but assigned codes are collision-free by construction.
The verdict is a counter written in base62, handing out 7-character codes. Counting removes collisions entirely, which removes the retry loop, the failure mode and the argument about hash quality from the write path in one move. Seven characters buy about 96 years of code space for the price of one extra character in every link. The admitted cost is guessability: counter codes follow the counting order, so the next code is easy to guess, and anyone can walk recent codes by counting. That cost is accepted because a short code is a locator, not a secret, and harmful destinations are handled by the block list rather than by hiding codes.
Deep dive: scaling the redirects
The redirect path carries 11,574 lookups per second while the write path carries 1,157, and a design that sends every lookup to one database asks that database to be the whole service. The shortener is read-heavy: it serves far more redirects than new links, at 10 redirects per new link. Read scaling therefore means arranging for most lookups to be answered somewhere cheaper than the main store. Two mechanisms do the work, a cache in front of the database and read replicas behind it, and the dive takes them in that order.
A cache is a fast store that holds copies of popular records close to the app servers. A lookup the cache answers without a database read is a cache hit, and a lookup the cache cannot answer is a cache miss, on which the database is read and the answer is kept in the cache for next time. Caching helps most where requests repeat, and redirect requests repeat a great deal, because a shared link is visited by many people in a short span. The first visit to a code pays the database read, and every later visit is answered from memory-speed storage.
Three ways to serve the redirects
| Option | How it works | Strengths | Weaknesses |
|---|---|---|---|
| The database alone | Every lookup reads the database, and no copies are kept anywhere else | One source of truth and nothing to keep in step; the simplest possible read path | One store carries all 11,574 lookups per second, and popular codes are re-read from storage on every visit |
| A cache in front of the database | Lookups check the cache first; a miss reads the database and keeps the answer in the cache | Repeated codes are answered from the cache, so the database sees each popular code rarely | Cache memory is finite, so unpopular codes still reach the database, and the cache is one more component to run |
| Read replicas behind the database | Extra database copies share the read work, with lookups spread across the copies | Read capacity grows by adding copies, and each copy holds every record, so any lookup can go anywhere | Copies must be kept in step with new writes, and replicas alone still read storage for every lookup |
A cache in front of the database
store = {"qi": "https://example.com/a", "qj": "https://example.com/b", "qk": "https://example.com/c"}
cache = {}
database_reads = 0
cache_answers = 0
def lookup(code):
global database_reads, cache_answers
if code in cache:
cache_answers = cache_answers + 1
return "cache"
database_reads = database_reads + 1
cache[code] = store[code]
return "database"
requests = ["qi", "qj", "qi", "qi", "qj", "qk", "qi"]
for code in requests:
print("lookup", code, "answered by", lookup(code))
print("requests:", len(requests))
print("database reads:", database_reads)
print("cache answers:", cache_answers)The demo places a small cache in front of a three-record store and serves a stream of seven requests. The printout shows the pattern the whole mechanism rests on: the first request for each code reaches the database, and every repeat is answered by the cache. The totals state the saving exactly โ 7 requests, 3 database reads and 4 cache answers. More than half the load never reached the store, on a stream where codes repeat only modestly, and real sharing repeats codes far more aggressively.
Replicas attack the load that survives the cache. A read replica is an extra database copy that shares the read work with the main copy, holding the same records and answering the same lookups. Writes still land on the main copy and flow outward to the replicas, while reads spread across all copies. A lookup for any code can be answered by any copy, because every copy holds every record.
Replicas share the read work
replicas = ["replica 1", "replica 2", "replica 3"]
counts = {"replica 1": 0, "replica 2": 0, "replica 3": 0}
for i in range(9):
replica = replicas[i % len(replicas)]
counts[replica] = counts[replica] + 1
for replica in replicas:
print(replica, "handled", counts[replica], "lookups")The demo spreads 9 lookups across 3 replicas, and the printout shows the even split the load balancer aims for: each replica handled 3 lookups. Evenness matters because a replica left idle while another saturates wastes exactly the capacity replicas were added to provide. The split needs no knowledge of which codes are popular, since every replica holds the full store. Popularity is the cache's concern; replicas simply multiply the number of stores that can answer.
One code can also break the evenness assumption from the other direction. A hot code is one code asked for far more often than the rest, typically a link riding a sudden wave of sharing. Without a cache, every one of those requests reads the database, and one popular link becomes a denial-of-service attack the service launches on itself. With a cache, the wave is absorbed after the first read, because the answer is kept where later requests look first.
A hot code stays in the cache
store = {"qi": "https://example.com/a", "qj": "https://example.com/b", "qk": "https://example.com/c"}
cache = {}
reads_for_qi = 0
def lookup(code):
global reads_for_qi
if code in cache:
return "cache"
if code == "qi":
reads_for_qi = reads_for_qi + 1
cache[code] = store[code]
return "database"
requests = ["qi", "qi", "qj", "qi", "qk", "qi", "qi", "qj", "qi", "qi"]
for code in requests:
lookup(code)
print("requests for qi:", requests.count("qi"))
print("database reads for qi:", reads_for_qi)The demo serves a stream in which one code dominates, and the counts separate the two layers cleanly. Requests for the hot code number 7, while database reads for that code number 1: the first request read the database, and the six repeats were cache answers. The database cannot tell a viral link from an ordinary one, and with the cache in place it never needs to. Hot codes, the sharpest edge of the read load, become the cheapest requests to serve.
The two mechanisms compose rather than compete, and the closing model runs them together. Requests check the cache first, misses fall through to one of two replicas, and every miss fills the cache for the next request with the same code. The model serves 10 requests and produces 6 cache answers against only 4 database reads. Those 4 reads split evenly, 2 to the first replica and 2 to the second, so no single store carries the read load alone.
URL Shortener: Scaling the Reads
store = {"qi": "https://example.com/a", "qj": "https://example.com/b", "qk": "https://example.com/c", "ql": "https://example.com/d"}
cache = {}
replica_counts = {"replica 1": 0, "replica 2": 0}
database_reads = 0
cache_answers = 0
requests = ["qi", "qj", "qi", "qk", "qi", "qj", "ql", "qi", "qj", "qi"]
for code in requests:
if code in cache:
cache_answers = cache_answers + 1
print("lookup", code, "answered by cache")
else:
database_reads = database_reads + 1
replica = "replica 1" if database_reads % 2 == 1 else "replica 2"
replica_counts[replica] = replica_counts[replica] + 1
cache[code] = store[code]
print("lookup", code, "answered by", replica)
print("requests:", len(requests))
print("cache answers:", cache_answers)
print("database reads:", database_reads)
print("replica 1 reads:", replica_counts["replica 1"])
print("replica 2 reads:", replica_counts["replica 2"])The verdict is a cache in front of the database, with read replicas sharing whatever load the cache lets through. The cache removes the repeated majority, including every hot code, and the replicas multiply the capacity that remains. The admitted costs are a cache to size and run, and replicas that trail new writes by a short replication lag. The lag is tolerable because a record never changes after creation: the only facts that change around a record are its end day arriving and its code joining the block list, and both checks sit on the redirect path itself rather than inside the cached copy.
Deep dive: counting visits, ending links and blocking abuse
Three decisions were postponed during clarification, and each one is a genuine fork rather than a detail. The redirect status decides whether visits can be counted at all. Link expiry decides whether a link lives forever or carries an end. Abuse handling decides what happens when a short link turns out to hide a harmful destination. Each decision is taken here with its arithmetic or its mechanism shown, because each one changes behaviour that link owners and visitors can see.
Two redirect statuses
| Option | How it works | Strengths | Weaknesses |
|---|---|---|---|
| Status 301, permanent | The browser is told the redirect is permanent and may store the answer and reuse it | Repeat visits skip the service entirely, so the service carries less load | Skipped visits are invisible visits: the service cannot count what it never sees |
| Status 302, temporary | The browser is told the redirect is temporary and asks the service again on every visit | Every visit reaches the service, so visits stay countable and links can still be ended or blocked | The service carries every visit, including all the repeats a permanent redirect would have absorbed |
The counting difference is not a matter of degree; it is the difference between seeing a visit and not seeing it. A browser that stored a 301 answer reuses that answer silently, and the service learns nothing about the visit. A 302 answer leaves the browser with nothing to reuse, so the next visit arrives as a fresh request. Analytics โ a count of visits for each code, which link owners use to see how a link performs โ is only possible on the second behaviour.
Counting visits under each status
def counted_visits(status, visits):
service_calls = 0
browser_has_copy = False
for i in range(visits):
if status == 301 and browser_has_copy:
continue
service_calls = service_calls + 1
if status == 301:
browser_has_copy = True
return service_calls
print("visits made: 4")
print("status 301, visits the service sees:", counted_visits(301, 4))
print("status 302, visits the service sees:", counted_visits(302, 4))The demo runs four visits under each status and counts how many the service sees. Under status 301 the service sees 1 visit, because the first answer is stored and the three repeats never arrive. Under status 302 the service sees 4 visits, the full count, because every visit asks again. A link owner promised an exact count can be given one only under the second status, and the gap between 1 and 4 widens with every repeat visit a popular link receives.
Link expiry is a rule that ends a short link after a set time or on a set date, and a link past its end is an expired link, which no longer redirects. The mechanism is deliberately plain: each record carries an end day, and the redirect path compares the current day with that end day before answering. A link before its end day redirects as usual, and a link on or past its end day is refused. No separate cleanup job sweeps the store, because the judgement happens at the only moment it matters, the moment of the visit.
Links that end
records = {
"qi": ("https://example.com/a-very-long-article", 12),
"qj": ("https://example.com/another-long-page", 5),
"qk": ("https://example.com/a-third-long-page", 20),
}
today = 10
print("today is day", today)
for code in ["qi", "qj", "qk"]:
long_url, end_day = records[code]
if today < end_day:
print(code, "still works, ends on day", end_day)
else:
print(code, "has ended, ended on day", end_day)The demo stores three links with different end days and visits them on a single stated day. One link has already ended and refuses, while the other two still work because their end days lie ahead. The refusal needs no deletion: the record stays in the store, the code stays taken, and the answer changes from a redirect to a refusal. Keeping the record also keeps the code from being handed to a different link later, which would resurrect old printed copies of the short link pointing at a new destination.
Abuse is the third fork, and it is a safety property rather than a convenience. Short links can hide a harmful destination, because the visitor sees only the code and not where it leads. When a destination is reported and confirmed harmful, the code goes on a block list: a list of codes that must never redirect. The redirect path checks the block list before any redirect is sent, so blocking takes effect on the next visit rather than after a store cleanup.
Blocking bad codes
store = {"qi": "https://example.com/a-very-long-article", "qj": "https://example.com/another-long-page"}
blocked = {"bad1"}
def redirect(code):
if code in blocked:
return "blocked"
if code in store:
return "redirect to " + store[code]
return "not found"
for code in ["qi", "bad1", "qj"]:
print(code, "->", redirect(code))The demo checks three codes against a store and a block list holding one bad code. The normal codes redirect to their long links as usual, and the blocked code is refused with a plain blocked answer. The check is one membership test on a path that already performs a lookup, so the protection costs almost nothing per visit. A blocked code also stays blocked in the cache era: the block check sits ahead of the cache lookup in the flow, so no cached copy can resurrect a blocked link.
The verdict is status 302 for every redirect, an end day stored in each record, and a block list checked before redirecting. Status 302 keeps every visit visible, which is what makes counting, expiry enforcement and blocking all possible on the same path: a service that never sees a visit can neither count it, refuse it for age, nor refuse it for harm. The admitted cost is load, since the service carries all 11,574 redirects per second instead of shedding repeats to browsers. That cost is the one the read-scaling design was built to pay, and paying it is what the cache and the replicas are for.
Bottlenecks and failure modes
The first component to saturate is the redirect read path, and the estimates say why. Redirects arrive at 11,574 per second against 1,157 writes per second, so every shared component on the read side feels ten times the pressure of its write-side counterpart. The cache is the relief valve: while the cache answers the popular majority, the database and its replicas see only the miss stream. The number to watch is therefore the miss rate, because a falling hit share sends load downhill to the store faster than any growth in raw traffic.
Storage, by contrast, never saturates suddenly. Growth is a fixed 10 GB a day, every day, reaching 3.65 TB in a year and 18.25 TB over 5 years, with no spikes and no surprises. Capacity planning is a calendar exercise: add store capacity on a schedule derived from the same multiplication, long before the total demands it. The write path sits between the two extremes, calm at 1,157 writes per second, with the counter as its only coordination point.
- The cache is lost or emptied: every lookup becomes a miss at once, the database and its replicas absorb the full redirect rate until the cache refills, and answers stay correct throughout, only slower
- A read replica fails: the surviving copies share its lookups, reads continue at reduced capacity, and no record is lost because every copy holds the same records
- The main database fails: new links cannot be stored and uncached codes cannot be looked up until a replica is promoted, while cached codes keep redirecting
- A hot code goes viral: the cache absorbs the wave after the first read, so the failure mode of a popular link is a busy cache rather than a fallen database
- The block list update is delayed: a newly reported code keeps redirecting until the update lands, which is why block updates travel as urgent configuration rather than as ordinary data
- The counter is interrupted: creation pauses rather than risk issuing a count twice, because a duplicated code is a correctness failure while a paused creation is only a delay
The pattern across those failures is a deliberate ordering of what may degrade and what must not. Speed may degrade: a cold cache, a lost replica and a viral link all make answers slower before they make answers wrong. Creation may pause: a counter that cannot guarantee a fresh count stops handing out codes rather than guessing. What must never degrade is the binding between a code and its long link, because a redirect to the wrong destination is the one answer the service exists to never give.
Trade-offs
The decisions and their costs
| Decision | Chosen | Given up |
|---|---|---|
| Code source | A counter written in base62 | Unpredictable codes: the next code is easy to guess from the last one |
| Code length | 7 characters | The shortest possible link: 6 characters would run out in 568 days |
| Redirect status | Status 302 | The load saved by status 301: every repeat visit reaches the service |
| Read scaling | A cache in front of the database, with read replicas | A single simple store: the cache must be sized and the replicas kept in step |
| Link ends | An end day stored in each record | Links that live forever by default: an ended link refuses its visitors |
| Bad links | A block list checked before redirecting | A purely automatic service: harmful destinations need reports and review |
The closing model lists the six decisions of the whole design in one place, in the order the design took them. Code length and code source come first, because they fix the shape of every link the service will ever hand out. Redirect status, read scaling, link ends and bad links follow, each one a choice with a named gain and a named loss. A list of 6 decisions is the honest summary of the design: everything else in the system is mechanism in service of those choices.
URL Shortener: Trade-Offs and Wrap-Up
decisions = [
("code length", "7 characters"),
("code source", "a counter written in base62"),
("redirect status", "302, so visits stay countable"),
("reads", "a cache in front of the database"),
("link ends", "an end day stored in each record"),
("bad links", "a block list checked before redirecting"),
]
for area, choice in decisions:
print(area, ":", choice)
print("decisions listed:", len(decisions))Taken together, the design spends simplicity in exactly two places and nowhere else. It spends a cache and replicas to buy a redirect path that survives 11,574 lookups per second, and it spends the load of status 302 to buy visits that can be counted, ended and blocked. Everything else stays as plain as the problem allows: one record shape, one key, one counter, two calls. A smaller service with calmer traffic should make several of these choices the other way, and recognising which choices scale down is part of understanding why they were made.
Common questions
How long do short codes need to be?
Seven characters is the comfortable length, and the arithmetic is short. Six characters over a 62-symbol alphabet give 56,800,235,584 codes, which the write rate of 100,000,000 a day exhausts in 568 days. Seven characters give 3,521,614,606,208 codes, lasting about 96 years at the same rate. One extra character multiplies the space by 62, so the longer code costs almost nothing and removes code exhaustion from the list of problems the service will ever face.
Why status 302 instead of status 301 for redirects?
Because visits must stay countable, and only one of the two statuses keeps them visible. Status 301 is a permanent redirect that browsers may store and reuse, so repeat visits skip the service and are never seen. Status 302 is a temporary redirect that browsers ask about again, so the service sees every visit and can count it. The same visibility is what lets the service refuse an ended link or a blocked code on a later visit, since a visit the service never sees cannot be refused either.
How are redirects served at 11,574 per second?
By making sure most redirects never reach the database at all. A cache in front of the database holds the popular records, so the first visit to a code reads the database and later visits are answered by the cache. Read replicas share the lookups the cache lets through, spreading that smaller stream across several copies of the store. The combination matters because the load is uneven: a single hot code can draw thousands of visits, and the cache turns that flood into 1 database read followed by cache answers.
What happens when a link ends or its code is blocked?
The link stops redirecting, and the code stays taken. An ended link is refused when the redirect path compares the current day with the end day stored in the record, and a blocked code is refused when the block-list check runs before any redirect is sent. In both cases the record remains in the store, so the code is never handed to a different link. Old copies of the short link therefore fail honestly instead of silently pointing at a stranger's destination.
Related designs
The rate limiter design faces the same shape of problem from the other side: one small decision, made at very high volume, on every request. The key-value store design generalises the store at the centre of the shortener, where a key finds one record and nothing else is asked. The news feed design shares the read-heavy asymmetry, serving far more reads than writes, and answers it with copies prepared in advance rather than copies cached on demand.
The estimate at a glance
| Measure | Value |
|---|---|
| New short URLs a day | 100,000,000 |
| Writes per second | 1,157 |
| Redirects a day | 1,000,000,000 |
| Redirects per second | 11,574 |
| Reads per write | 10 |
| Bytes per record | 100 |
| New storage a day | 10 GB |
| New storage a year | 3.65 TB |
| New storage over five years | 18.25 TB |
| Code length | 7 characters |
| Codes from 7 characters | 3,521,614,606,208 |
Exercise
A link owner needs an exact count of every visit. Which redirect status fits?