Why this problem is asked so often
A URL shortener turns a long address such as https://shop.example.com/products/shoes?color=red&size=9&ref=summer-campaign into a short one such as https://sho.rt/4c92xQ. When someone opens the short link, the service looks up the original address and sends the browser there. Bit.ly and TinyURL are the well-known examples. Pastebin is a close cousin: instead of storing a URL behind the short code, it stores a block of text (a "paste") and shows it when the code is opened.
This is usually the first system design problem a student meets, and it is commonly asked in interviews for freshers and engineers with a few years of experience. It looks easy, which is exactly why it is useful to an interviewer. Anyone can draw "app server plus database". The interviewer is checking whether you can:
- turn a vague prompt into numbers (how many links, how many clicks, how much storage),
- generate short codes that never collide, without a single machine becoming a bottleneck,
- make a read-heavy system fast with caching,
- explain the 301 versus 302 redirect trade-off,
- handle expiry, analytics and abuse without hurting the redirect path.
This lesson walks through the full design in the order you would present it in an interview. A shorter version appears in Real-world designs; this is the complete version with every step explained. If the building blocks (load balancer, cache, key-value store) are new to you, skim Building blocks explained first.
Step 1: Problem and scope
State the problem in one sentence before you design anything:
Users submit a long URL (or a block of text) and get back a short, unique link. Anyone who opens the short link is redirected to the long URL (or shown the text) quickly and reliably.
Then draw the boundary. What is in scope for a 45-minute interview?
In scope
- Create a short link for a long URL.
- Redirect from the short link to the long URL.
- Optional custom alias (the user picks
sho.rt/diwali-sale). - Optional expiry time.
- Click analytics (count of clicks, by day, by country, by referrer).
- Pastebin variant: store text, return it by code.
Out of scope unless asked
- User account management details (assume an auth service exists).
- Editing the destination after creation (we will mention it as an extension).
- A full admin dashboard UI.
Interview tip
Say your scope out loud and ask the interviewer to confirm it. "I'll cover creation, redirect, custom aliases, expiry and basic analytics, and treat user accounts as an existing service. Does that match what you want?" This takes thirty seconds and prevents you from designing the wrong system.
Step 2: Clarifying questions
Good clarifying questions change the design. Each question below is followed by the answer we will assume and why it matters.
| Question | Assumed answer | Why it matters |
|---|---|---|
| How many new links per month? | 100 million | Drives write rate and storage |
| Ratio of clicks to new links? | 100 to 1 | Read-heavy, so caching dominates |
| How short must codes be? | As short as possible, 7 characters is fine | Sets the size of the code space |
| Can users choose their own alias? | Yes, optional | Needs uniqueness checks on user input |
| Do links expire? | Optional expiry; default never | Needs a cleanup process |
| Do we need analytics? | Yes, per-link click counts and breakdowns | Needs an event pipeline off the hot path |
| Can a link be edited after creation? | No (extension) | Affects 301 vs 302 and cache invalidation |
| Must codes be hard to guess? | Preferably yes | Rules out plain sequential codes |
| Pastebin: maximum paste size? | 1 MB of text | Pastes go to object storage, not the database |
| How long do we keep data? | 5 years for capacity planning | Total storage |
The ratio of reads to writes is the most important answer here. A 100 to 1 ratio tells you that redirects are the product and creation is a side door.
Step 3: Requirements
Functional requirements
Functional requirements describe what the system does.
createLink(longUrl, customAlias?, expiresAt?)returns a short code.GET /{code}redirects to the long URL, or returns 404 if unknown and 410 if expired.- Users can delete their own links.
- Users can see click statistics for their links.
- Pastebin:
createPaste(text, expiresAt?)returns a code;GET /p/{code}returns the text.
Non-functional requirements
Non-functional requirements describe how well it must do it.
- High availability for redirects. A broken short link inside a printed poster or an SMS cannot be fixed later. Redirects should keep working even if link creation or analytics is down.
- Low latency. A redirect adds a network round trip before the user sees the real page, so the service's own processing should be a few milliseconds, with the p99 (the latency that 99% of requests beat) well under 100 ms.
- Uniqueness. Two different long URLs must never get the same code. A collision would send people to the wrong website, which is a security problem, not just a bug.
- Durability. Once a link is created, it must not be lost.
- Eventual consistency for analytics. Click counts may lag by a minute; nobody needs them to the millisecond.
- Abuse resistance. The service must not become a free disguise for phishing and malware links.
Step 4: Back-of-the-envelope estimates
Back-of-the-envelope estimation means rough calculations, rounded generously, to find out which parts of the system need special care. One useful constant: a month has about 30 × 86,400 = 2,592,000 seconds, roughly 2.6 million.
Traffic
- New links: 100 million per month ÷ 2.592 million seconds ≈ 39 writes per second on average.
- Redirects: 100 × 100 million = 10 billion per month ≈ 3,858 reads per second on average.
- Peaks: traffic is not flat. Assume peak is 3 times the average: about 116 writes per second and 11,600 reads per second.
Notice how small the write rate is. A single relational database handles 116 inserts per second comfortably. The read rate is what needs design work, and even that is very manageable with a cache.
Storage
Each link row holds:
| Field | Approx. size |
|---|---|
| code (7 chars) | 7 bytes |
| long URL (average) | 200 bytes, up to 2 KB |
| owner user id | 8 bytes |
| created at, expires at | 16 bytes |
| flags, indexes, row overhead | the rest |
Round the whole row up to 500 bytes to cover indexes and storage-engine overhead.
- Links over 5 years: 100 million × 12 months × 5 years = 6 billion links.
- Storage: 6 billion × 500 bytes = 3 TB.
Three terabytes is far beyond a comfortable single machine for a hot database, but it is small for a sharded key-value store. Pastebin is different: if the average paste is 10 KB, the same 6 billion items would be 60 TB, which is why paste bodies belong in object storage (such as Amazon S3) with only metadata in the database.
Code length
Base62 uses 62 characters: 0-9, a-z, A-Z. The number of possible codes of length L is 62 to the power L.
| Length | Possible codes |
|---|---|
| 6 | 56,800,235,584 (about 56.8 billion) |
| 7 | 3,521,614,606,208 (about 3.5 trillion) |
| 8 | 218,340,105,584,896 (about 218 trillion) |
We need 6 billion codes. Six characters (56.8 billion) would technically fit, but seven characters gives about 587 times more room than we need, which matters for random codes (see the collision math below) and for growth. At 1.2 billion new links per year, the seven-character space would take about 2,900 years to use up.
Cache memory
Clicks follow a skewed pattern: a small fraction of links receive most of the clicks (a link in a viral post gets millions; most links get a handful). Suppose about 10 million distinct links are "hot" on a given day. Caching them all costs 10 million × 500 bytes = 5 GB, which fits in the memory of one cache node. We will still run several cache nodes for availability and throughput.
Bandwidth
A redirect response is tiny: a status line, a Location header and a few other headers, about 500 bytes. At 3,858 reads per second that is about 1.9 MB/s of outgoing traffic. Bandwidth is not a concern for the URL shortener. For Pastebin, reads return the whole paste, so bandwidth is real and a CDN (content delivery network, a set of servers near users that cache content) helps.
Analytics volume
Every redirect produces a click event. If an event is about 100 bytes, 10 billion clicks per month is about 1 TB of raw events per month. That is too much to write into the main link table one row at a time, which tells us analytics needs its own pipeline.
What the numbers told us
Writes are tiny, reads are moderate and very skewed, link storage is a few terabytes, and analytics is the largest data stream. So: a cache in front of a key-value store for redirects, a simple code generator for writes, and an asynchronous pipeline for clicks.
Step 5: API design
Use a small REST API. The write side needs authentication (an API key or a logged-in session) so you can apply per-user rate limits; the redirect side is public.
POST /api/v1/links
Headers: Authorization: Bearer <token>
Body: { "longUrl": "https://...",
"customAlias": "diwali-sale", (optional)
"expiresAt": "2027-01-01T00:00:00Z" } (optional)
201 Created
Body: { "code": "4c92xQ", "shortUrl": "https://sho.rt/4c92xQ",
"expiresAt": "2027-01-01T00:00:00Z" }
409 Conflict alias already taken
422 Unprocessable invalid or blocked URL
GET /{code}
301 or 302, Location: https://... link exists
404 Not Found unknown code
410 Gone expired or deleted
DELETE /api/v1/links/{code} owner only
204 No Content
GET /api/v1/links/{code}/stats?from=2026-10-01&to=2026-10-07
200 { "total": 18234, "byDay": [...], "byCountry": [...] }
POST /api/v1/pastes { "text": "...", "expiresAt": "..." }
GET /p/{code} returns the paste text
A few design choices are worth saying out loud:
- Idempotency key. If a client retries
POST /linksafter a timeout, it may create two links. Accept anIdempotency-Keyheader and return the first result on a retry. (See API design.) - Same long URL twice. Should two users shortening the same URL get the same code? Usually no: each user wants their own analytics and their own delete button. Return a new code per request, unless the same user asks again, in which case you may return their existing code.
- 410 versus 404. "Gone" tells the client the link existed but expired, which is more helpful and cacheable.
Step 6: Data model and storage choice
Tables
links
code varchar(16) primary key -- short code or custom alias
long_url text not null
owner_id bigint -- null for anonymous links
created_at timestamp not null
expires_at timestamp -- null = never
status smallint not null -- active, deleted, blocked
pastes (Pastebin)
code varchar(16) primary key
object_key text not null -- path in object storage
size_bytes int not null
owner_id bigint
created_at timestamp not null
expires_at timestamp
links_by_owner (secondary index or separate table)
owner_id, created_at desc, code -- "my links" page
click_counts (analytics store, written by the pipeline)
code, day, country, count
Which database?
The redirect path needs exactly one access pattern: given a code, return the row. There are no joins, no range scans, and no multi-row transactions. That is the textbook use case for a key-value store (a database that stores values by a unique key and is very fast at lookups by that key), such as DynamoDB or Cassandra. These stores partition data across many machines automatically, which suits 3 TB growing steadily.
A relational database such as PostgreSQL or MySQL is also a perfectly good answer, especially at the start: 116 writes per second and a cached read path are well within its reach, and it gives you unique constraints and the "links by owner" query for free. When it outgrows one machine, you shard it by code.
| Option | Strengths | Weaknesses |
|---|---|---|
| Relational (PostgreSQL, MySQL) | Unique constraint, easy secondary queries, familiar | Manual sharding later |
| Key-value (DynamoDB, Cassandra) | Built-in partitioning, predictable latency at any size | Secondary queries need extra tables; uniqueness via conditional writes |
| Object storage (S3) for paste bodies | Cheap, durable, large objects | Higher latency; not for metadata |
Interview tip
Do not pick a database by brand. Say: "The hot access pattern is a point lookup by code, so I want a store that partitions by key. I'd start with PostgreSQL because the write rate is tiny, and move to a partitioned key-value store, or shard PostgreSQL by code, when storage grows past a few terabytes." That shows you chose based on the access pattern.
Step 7: High-level design
+-----------+
browser/app ------>| CDN | (optional, caches 301s
+-----+-----+ and Pastebin bodies)
|
+-----v-----+
| Load |
| balancer |
+--+-----+--+
| |
create path | | redirect path
+----------v+ +v-----------+
| Write API | | Redirect |
| service | | service |
+--+-----+--+ +--+-----+---+
| | | |
+----------v+ | +----v--+ | click events
| Key-gen | | | Cache | +-------------+
| service | | | Redis | |
+-----------+ | +---+---+ +-----v-----+
| | miss | Queue / |
+----v--------v---+ | log |
| Link store | +-----+-----+
| (sharded KV) | |
+--------+--------+ +-----v-----+
| | Analytics |
+--------v--------+ | workers |
| Object storage | +-----+-----+
| (paste bodies) | |
+-----------------+ +-----v-----+
| Analytics |
| store |
+-----------+
The design separates two services because they have very different needs:
- The write API is low-volume, authenticated, does validation and abuse checks, and talks to the key generator.
- The redirect service is high-volume, public, must be extremely fast, and should depend on as few things as possible. If the write API or analytics is down, redirects must still work.
Step 8: Request flows
Write path: creating a link
- The client sends
POST /api/v1/linkswith the long URL. - The load balancer forwards it to a write API instance.
- The write API authenticates the user and checks their rate limit (for example 100 links per hour for free users).
- It validates the URL: correct syntax,
httporhttpsonly, length at most 2 KB, and not pointing back at the shortener itself (which would create redirect loops). - It checks the URL against a blocklist and a malware-reputation service. Suspicious links are rejected or queued for review.
- If the request has a custom alias, it validates the alias (allowed characters, length, not a reserved word like
apiorlogin) and tries to insert it. A unique-key conflict means "taken": return 409. - Otherwise it takes a code from the key generator (described in the deep dive).
- It writes the row
(code, long_url, owner_id, created_at, expires_at, status=active)to the link store. - It returns
201with the short URL.
The cache is not touched on writes. A new link has no clicks yet, and the first redirect will load it into the cache. This keeps the write path simple.
Read path: following a link
- The browser requests
GET https://sho.rt/4c92xQ. - If a CDN is used and has a cached redirect for this code, it answers directly. Otherwise the request reaches a redirect service instance.
- The service looks up
4c92xQin the cache. - Cache hit: it gets the long URL and expiry.
- Cache miss: it reads the row from the link store, then writes it to the cache with a time-to-live (TTL), say 24 hours. If the code does not exist, it caches a short "not found" marker (negative caching, for example for 60 seconds) so that repeated requests for junk codes do not hammer the database.
- If the link is expired or deleted, return 410.
- It returns
301or302withLocation: <long URL>. - In parallel, without waiting, it emits a click event
(code, timestamp, country from IP, referrer, user agent)to a queue. If the queue is unavailable, it drops the event (or buffers a little locally) rather than slowing the redirect.
Common mistake
Do not increment a counter in the link row on every click. With a viral link, thousands of requests per second would update the same row, causing lock contention on one database shard. Record clicks as events and aggregate them later.
Step 9: Deep dives
Deep dive 1: Generating unique short codes
This is the heart of the problem. There are four common approaches. You should be able to explain all of them and choose one with reasons.
Approach A: Counter plus base62 encoding
Keep a counter that gives every new link a unique integer: 1, 2, 3, and so on. Convert the integer to base62 to get the code. Base62 conversion works like converting to binary, just with 62 digits instead of 2.
Worked example: encode 1,000,000 with alphabet 0-9a-zA-Z (so 0=0, a=10, c=12, A=36).
1,000,000 / 62 = 16,129 remainder 2 -> '2'
16,129 / 62 = 260 remainder 9 -> '9'
260 / 62 = 4 remainder 12 -> 'c'
4 / 62 = 0 remainder 4 -> '4'
Read remainders bottom to top: "4c92"
Check: 4*62^3 + 12*62^2 + 9*62 + 2
= 953,312 + 46,128 + 558 + 2 = 1,000,000
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def encode(n: int) -> str:
if n == 0:
return ALPHABET[0]
digits = []
while n > 0:
n, rem = divmod(n, 62)
digits.append(ALPHABET[rem])
return "".join(reversed(digits))
def decode(code: str) -> int:
n = 0
for ch in code:
n = n * 62 + ALPHABET.index(ch)
return n
print(encode(125)) # 21
print(encode(1_000_000)) # 4c92
print(decode("4c92")) # 1000000
Strengths: no collisions ever, because every integer is unique. Codes are as short as possible.
Weaknesses:
- A single counter is a bottleneck and a single point of failure. Fix it with range allocation: a coordinator (a small database table, or ZooKeeper, a coordination service) hands each write server a block of, say, 10,000 numbers. The server uses them from memory and asks for a new block when it runs out. The coordinator is contacted once per 10,000 links. If a server crashes, the unused part of its block is simply skipped; gaps are harmless.
- Codes are predictable. Code
4c92is followed by4c93. Anyone can walk through all links, which leaks private links. Fix it by scrambling the integer with a reversible permutation before encoding (for example, multiply by a large number that has no common factor with 62 to the power 7 and take the remainder, or use a small block cipher). The code still maps one-to-one to the integer, so there are still no collisions, but consecutive integers no longer give neighboring codes.
Approach B: Hash the long URL, then handle collisions
Compute a hash such as SHA-256 of the long URL, take some of its bits and encode them in base62, keeping 7 characters.
The problem: 7 base62 characters hold only about 42 bits of information (62 to the power 7 is about 2 to the power 41.7), so two different URLs can produce the same 7 characters. You must check for a collision and resolve it. A deterministic hash also gives the same code every time for the same URL, so simply "try again" does not help; you add something that changes, such as an attempt number.
import hashlib
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def hash_code(long_url: str, attempt: int = 0, length: int = 7) -> str:
# Salt with the attempt number so a retry gives a different code.
digest = hashlib.sha256(f"{long_url}#{attempt}".encode()).digest()
n = int.from_bytes(digest[:8], "big")
chars = []
for _ in range(length):
n, rem = divmod(n, 62)
chars.append(ALPHABET[rem])
return "".join(chars)
taken = set()
def shorten(long_url: str) -> str:
for attempt in range(5):
code = hash_code(long_url, attempt)
if code not in taken: # in production: INSERT with a unique key
taken.add(code)
return code
raise RuntimeError("too many collisions")
a = shorten("https://example.com/a")
b = shorten("https://example.com/a")
print(a, b, a != b) # two different codes for two requests
The check-then-insert in the sketch is only for illustration. In a real system, two servers could check at the same moment and both see "free". The correct pattern is to let the database enforce uniqueness: insert with a unique key (or a conditional "put if absent" in DynamoDB) and retry with the next attempt number only if the insert fails.
How often will retries happen? With 6 billion codes already used out of 3.52 trillion, a new random-looking code collides with probability 6,000,000,000 ÷ 3,521,614,606,208 ≈ 0.17%. That is about one retry per 590 creations, which is fine. With 6-character codes the same calculation gives 6 billion ÷ 56.8 billion ≈ 10.6%, which is a lot of wasted round trips and gets worse as the table fills. This is the concrete reason to choose 7 characters.
Approach C: Random codes
Generate 7 random base62 characters using a cryptographically secure random generator and insert with a unique key, retrying on conflict. The math is the same as Approach B (0.17% collision chance at 6 billion links), but codes are unguessable and there is nothing to coordinate. This is simple and good.
Approach D: Key-generation service with pre-generated keys
A key-generation service (KGS) creates random unique codes ahead of time and stores them in an "unused keys" table. When a write server needs codes, it asks the KGS for a batch (say 1,000), and the KGS moves them to a "used" table in the same step, so no code is handed out twice. Write servers keep their batch in memory.
+--------------------+ batch of 1,000 +-------------+
| KGS |----------------------->| Write API 1 |
| unused_keys table | +-------------+
| used_keys table |----------------------->| Write API 2 |
+--------------------+ +-------------+
background job fills unused_keys with random codes
and checks each against existing codes
Strengths: creation never waits on a collision retry; codes are random. Weaknesses: one more service to run and replicate; if a write server crashes, its in-memory batch is lost (acceptable, the space is huge); and the KGS needs a standby copy so it is not a single point of failure. Storage for pre-generated keys is small: one million spare 7-byte keys is about 7 MB.
Which one to choose
| Approach | Collisions | Guessable | Coordination | Best when |
|---|---|---|---|---|
| Counter + base62 | None | Yes, unless scrambled | Range allocator | Shortest codes, simple ops |
| Hash + retry | Possible, retry | No | None | You want the same URL to map to the same code |
| Random + retry | Possible, retry | No | None | Simplest correct design |
| Pre-generated (KGS) | None at request time | No | KGS service | Strict latency on creation |
A strong answer: "At 116 writes per second, random 7-character codes with a unique-key insert and retry are simplest. The collision rate stays under 0.2% for our five-year volume. If we wanted the shortest possible codes, I'd use range-allocated counters with a reversible scramble so codes aren't sequential."
Deep dive 2: 301 versus 302 redirects
Both status codes tell the browser to go to the Location header. The difference is caching.
- 301 Moved Permanently. Browsers (and many proxies) may cache it for a long time. The second time the same browser opens the link, it may go straight to the destination without asking you. Load drops, but you miss that click in analytics, and if you later change or block the destination, browsers that cached it keep going to the old one.
- 302 Found (or 307 Temporary Redirect, which also preserves the request method). Browsers do not cache it by default, so every click reaches your service. You see every click, and you can change, disable or block the link at any time. It costs more server load.
| 301 | 302 / 307 | |
|---|---|---|
| Browser caching | Yes, possibly long | No by default |
| Analytics accuracy | Undercounts repeat clicks | Counts every click |
| Can disable a malicious link later | Not for browsers that cached it | Yes, immediately |
| Server load | Lower | Higher |
You can also send a 301 with a Cache-Control: max-age=... header to bound how long browsers keep it. Most commercial shorteners favor temporary redirects because analytics and the ability to block abusive links are core features. A personal shortener with no analytics may prefer 301.
Interview tip
Tie the choice to requirements: "Because we promised click analytics and want to disable malicious links quickly, I'll return 302. If the interviewer drops analytics, 301 reduces load, and I'd set a bounded max-age so we can still change things."
Deep dive 3: Caching hot links
Clicks are highly skewed, so a cache works very well. The cache is a key-value store in memory (such as Redis or Memcached) mapping code to (long_url, expires_at, status).
- Pattern: cache-aside (also called lazy loading). The redirect service checks the cache first, reads the database on a miss, then fills the cache. See Caching.
- Eviction: LRU (least recently used). When memory is full, drop the entries that have not been read for the longest time. Hot links stay; cold ones fall out.
- TTL: set a TTL such as 24 hours, but never beyond the link's own expiry time. Otherwise the cache would keep serving a link after it expired.
- Negative caching: cache "this code does not exist" for a short time to protect the database from bots trying random codes.
- Invalidation: when a link is deleted or blocked, delete the cache key in the same request that updates the database. If the delete fails, the TTL limits how long the stale entry survives.
- Hot keys: a single viral link can receive tens of thousands of requests per second, all hitting the same cache node. Add a tiny in-process cache (a few thousand entries, a few seconds TTL) inside each redirect server so the hottest links never leave the machine, or put the redirect behind a CDN with a short max-age.
With 5 GB of hot data, the database sees only cache misses. If the hit ratio is 95%, the database serves 5% of 11,600 peak reads, about 580 reads per second, which one shard handles easily.
Deep dive 4: The analytics pipeline
Analytics must never slow down a redirect. So the redirect service only emits an event and moves on.
redirect svc --> [ queue / log, e.g. Kafka ] --> stream workers
(partitioned by code) |
| every minute:
| add up counts
v
[ analytics store ]
code, day, country, count
|
GET /stats <---------------------------------------+
- Each click is published to a durable log such as Kafka, partitioned by code so all events for one link go to the same partition. (See Kafka and event streaming.)
- Stream workers read events and keep running totals in memory for each
(code, minute, country). - Every minute they write the totals into an analytics store (a column store such as ClickHouse, or a wide-column store like Cassandra) by adding to existing counts.
- Raw events are also copied to cheap object storage for reprocessing and deeper reports.
Exactness: events can be delivered twice after a failure. If click counts must be exact, give each event an id and deduplicate; for most dashboards, being off by a few clicks out of thousands is acceptable, and you should say so. Bots inflate counts, so filter known crawler user agents and obvious automated traffic before counting.
Deep dive 5: Expiry and cleanup
Expired links must stop working at the expiry time, but the data can be deleted later.
- On read (lazy): the redirect service checks
expires_aton every lookup and returns 410 if it has passed. This alone makes expiry correct. - In the background: a cleanup job scans for expired rows in small batches (using an index on
expires_at, or the store's built-in TTL feature, as DynamoDB and Cassandra offer) and deletes them, plus their paste objects. - Reusing codes: do not recycle an expired code for a new link soon. Someone may still have the old link in an email and would land on a stranger's content. The space is huge, so never reusing codes is the safe default.
Step 10: Scaling and bottlenecks
Go through each layer and ask, "What breaks first as traffic grows ten times?"
- Redirect servers are stateless, so you add more behind the load balancer.
- Cache: shard by code using consistent hashing (a way of spreading keys across nodes so that adding a node moves only a small share of keys; see Design a search query cache). Add replicas for availability.
- Link store: shard by code. Hash-based sharding spreads both data and load evenly because codes are random. Range-based sharding by code would also work for random codes, but would create a "hot" newest shard with sequential counter codes, another reason to scramble.
- "My links" query: sharding by code scatters a user's links across all shards. Keep a separate
links_by_ownertable sharded byowner_id, written at creation time. - Key generation: range allocation or KGS batches mean the coordinator is touched rarely.
- Analytics: add Kafka partitions and workers.
- Global users: run redirect servers and cache replicas in several regions, with read replicas of the link store. Creation can stay in one region because it is rare and a few hundred milliseconds of extra latency is acceptable there. See Multi-region design.
Step 11: Failure handling
| Failure | Effect | Mitigation |
|---|---|---|
| Cache node down | More misses, higher DB load | Replicas; consistent hashing limits reshuffle; DB sized for a cold-cache burst |
| Database shard down | Links on that shard fail on cache miss | Replicas with automatic failover; serve cached entries meanwhile |
| Key generator down | Creation fails | Servers hold a local batch of codes; standby KGS |
| Analytics queue down | Clicks lost or delayed | Redirect continues; small local buffer; accept loss |
| Region outage | Users in that region fail | DNS or anycast failover to another region |
| Thundering herd on a viral link | Many misses at once for the same code | Request coalescing: only one request loads from DB, others wait for it |
The important principle is dependency isolation: the redirect path depends only on the cache and the link store. Everything else (abuse scanning, analytics, key generation) can fail without breaking existing links.
Step 12: Abuse prevention
Short links hide their destination, which makes them attractive for phishing, malware and spam. A real service spends a lot of effort here.
- At creation: rate-limit per user and per IP address; require sign-in for high volumes; check destinations against malware and phishing reputation lists; block known bad domains; reject links that point to another shortener (chains are used to hide the final destination).
- After creation: re-scan destinations periodically, because a clean page can turn malicious later. Let users report links. When a link is blocked, set its status, invalidate the cache, and show a warning page instead of redirecting. This is easy only if you used 302 redirects.
- Preview pages: offer
sho.rt/4c92xQ+(or a similar convention) to show the destination before visiting. - Enumeration: random or scrambled codes stop people from listing all links. Also rate-limit 404s per IP.
- Custom aliases: reserve brand names and offensive words, and prevent aliases that impersonate system paths.
Step 13: Pastebin differences
Pastebin reuses almost everything: the same code generation, the same metadata table, the same expiry logic. The differences come from the payload being larger.
- Store text in object storage, keyed by the code, and keep only the object key and size in the database. The database stays small and fast.
- Serve reads through a CDN. Paste bodies are immutable, so they cache perfectly.
- Limit size (1 MB) and rate-limit creation, because storage cost grows with abuse.
- Optional syntax highlighting happens in the browser, not the server.
- Private pastes need unguessable codes (random, longer) and should not be cached publicly.
Step 14: Trade-offs and alternatives
| Decision | Choice here | Alternative | When the alternative wins |
|---|---|---|---|
| Code generation | Random 7 chars + unique insert | Counter + base62, KGS | Need shortest codes, or zero retries |
| Redirect status | 302 | 301 with max-age | No analytics, want lowest load |
| Link store | Sharded KV by code | Single PostgreSQL | Early stage, under a few TB |
| Click counting | Event stream + aggregation | Counter column in DB | Tiny scale, low traffic |
| Expiry | Check on read + background delete | Only background delete | Never: expired links would work until the job runs |
| Paste bodies | Object storage + CDN | Database blob column | Very small pastes, small scale |
| Same URL twice | New code per request | Deduplicate globally | Storage is costly and per-user analytics are not needed |
What interviewers probe
"What if two servers generate the same random code at the same time?" Both try to insert; the database's unique key lets exactly one succeed. The other gets a conflict error and retries with a new code. Never rely on "check then insert" without a unique constraint.
"Your counter service is a single point of failure." Use range allocation: each server leases a block of IDs and can keep working for a while without the coordinator. Run the coordinator with a replica, or base it on a strongly consistent store such as ZooKeeper or a database row updated in a transaction.
"How do you stop someone from guessing all links?" Random codes, or counters passed through a reversible scramble. Rate-limit 404s. For private content, use longer codes and require authentication.
"A celebrity posts your link and you get 100,000 clicks per second on one code." Per-server in-process cache, CDN caching with short max-age, request coalescing on misses, and partitioned analytics so the event stream for that code is aggregated in memory before writing.
"How do you change a link's destination?" Update the row, delete the cache key, and accept that browsers that cached a 301 will not see the change, which is why editable links should use 302.
"How would you count unique visitors?" Exact unique counts per link need a set of visitor ids, which is expensive. A HyperLogLog sketch (a small probabilistic structure that estimates distinct counts with about 1% error in a few kilobytes) is the standard answer.
"What happens when a region fails?" Redirect servers and caches in other regions take over through DNS or anycast routing; the link store has cross-region replicas. Creation may be briefly unavailable if it is single-region, which is acceptable because redirects matter more.
Interview questions
Q1. Why is a URL shortener considered read-heavy, and how does that shape the design?
Each link is created once but clicked many times, often 100 or more times. So the design optimizes the redirect path: a cache in front of a store optimized for point lookups, stateless redirect servers, and no heavy work (analytics, scanning) on that path. The write path can be simpler and slower.
Q2. How many characters should a short code have?
Work it out from volume. With 6 billion links over 5 years, 6 base62 characters (56.8 billion codes) technically fit but give a 10.6% collision chance per random code by the end. Seven characters (3.5 trillion) give about 0.17%, so 7 is the usual answer.
Q3. Explain base62 encoding with an example.
Repeatedly divide the number by 62 and map each remainder to a character in 0-9a-zA-Z, then read the remainders in reverse. For 125: 125 divided by 62 is 2 remainder 1, and 2 divided by 62 is 0 remainder 2, so the code is "21". It is like converting to binary but with 62 symbols.
Q4. Compare counter-based codes and hash-based codes.
Counters never collide and produce the shortest codes, but need coordination (range allocation) and are sequential unless scrambled. Hashes need no coordination and are not sequential, but truncating them to 7 characters means collisions are possible, so you need a unique constraint and a retry that changes the input. Random codes behave like hashes and are often simpler.
Q5. What is a key-generation service and what are its risks?
A KGS pre-generates unique random codes and hands them out in batches, marking each as used when handed out. Creation then never waits on collision retries. Its risks are that it is another component that must be highly available (run a standby) and that batches held by crashed servers are wasted, which is fine given the size of the code space.
Q6. 301 or 302, and why?
A 301 lets browsers cache the redirect, reducing load but hiding repeat clicks from analytics and making it impossible to redirect cached browsers elsewhere later. A 302 sends every click to the service, giving accurate analytics and the ability to block bad links instantly. Services with analytics usually choose 302 (or 307).
Q7. How do you implement link expiry?
Check expires_at on every redirect and return 410 when it has passed, so expiry is exact. A background job, or the database's TTL feature, deletes expired rows later. Make sure cache TTLs never exceed the link's remaining lifetime.
Q8. How would you record click analytics without slowing redirects?
Publish a small event to a durable log asynchronously and return the redirect immediately. Stream workers aggregate counts per link per minute and write them to an analytics store. Accept slight delay and possible small inaccuracies, and filter bots.
Q9. How do you shard the link store?
Shard by a hash of the code, because every redirect looks up exactly one code, so each request touches one shard. Random codes spread evenly. Queries by owner need a second table sharded by owner id.
Q10. How do you handle custom aliases?
Validate characters and length, block reserved and offensive words, then insert with the alias as the key. The unique constraint makes concurrent requests for the same alias safe: one wins, the other gets 409. Custom aliases live in the same key space as generated codes, so generated codes should avoid patterns users are likely to pick, or you simply let the unique key catch the rare clash.
Q11. How would you prevent abuse?
Rate-limit creation per user and IP, check destinations against malware and phishing lists, rescan periodically, let users report links, and replace blocked links with a warning page. Use random codes so links cannot be enumerated.
Q12. What changes for Pastebin?
The payload is larger, so paste bodies go to object storage and are served through a CDN, while metadata stays in the database. Size limits and creation rate limits matter more because storage cost grows with content. Private pastes need unguessable codes and no public caching.
Q13. What happens if the cache cluster goes down completely?
Every redirect becomes a database read, which may overload the store. Size the database to survive a cold-cache period, use replicas, shed low-priority traffic, and warm the cache gradually. Request coalescing prevents thousands of identical misses for the same viral link.
Q14. Should the same long URL always get the same short code?
Usually not, because different users want separate analytics and control over their own links. Deduplicating globally saves a little storage but couples users together. A reasonable middle ground is to return the existing code when the same user shortens the same URL again.
Key takeaways
- Start with numbers: about 39 writes per second, about 3,900 reads per second on average, 6 billion links and 3 TB over 5 years under our assumptions.
- The product is the redirect path; keep it to a cache lookup and a key-value read, with nothing else blocking it.
- Seven base62 characters give 3.5 trillion codes; the collision math (about 0.17% at 6 billion links) justifies the length.
- Counter plus base62, hash plus retry, random plus retry, and pre-generated keys all work; always let a unique constraint be the final judge.
- 302 keeps analytics accurate and lets you block bad links; 301 saves load at the cost of control.
- Record clicks as events and aggregate them asynchronously; never update a counter row per click.
- Expiry is enforced on read and cleaned up in the background; never recycle codes.
- Abuse prevention is a core feature: rate limits, reputation checks, rescans and warning pages.
Next lesson
Continue with Design a Twitter timeline.

