Design a URL shortener: from one API to a billion redirects
Design a URL shortener with real numbers: 40 writes and 4,000 redirects a second, base62 keys, a cache-aside redirect path, and the 301 vs 302 analytics trade.
"Design a URL shortener" is the interview prompt everyone has seen and almost nobody has built. The candidate draws a box labeled API, a box labeled database, says the word "hash," and waits. The interviewer waits too, because the prompt was never about the boxes. A shortener is a dictionary with a public read endpoint, and the entire design lives in three questions: how many reads, how skewed are they, and what has to happen on the redirect path before the browser is allowed to leave.
The same three questions decide whether a real service survives a link going viral. So do the arithmetic first.
Put numbers on it before drawing anything
Assume the product takes 100 million new links a month, and that a link is read far more often than it is written: say 100 redirects for every create.
writes: 100,000,000 links / (30 days x 86,400 s) = 38.6/s, call it 40/s
reads: 40/s x 100 = 4,000/s on average
peak: plan for 10x = 40,000 redirects/s
storage: 100,000,000 links x ~500 bytes = 50 GB/month, 600 GB/year
Forty writes per second is nothing. A laptop running PostgreSQL does that with a unique index and no tuning. Fifty gigabytes a month is a disk purchase, not an architecture: three terabytes over five years, most of it cold. What remains is 4,000 redirects a second on an ordinary afternoon and ten times that when a link lands on the front page of something. Every decision that follows exists to keep that read path short.
Two paths, one product
The redirect is the hot path; everything else can wait
Redirect (read)
4,000/s average, 40,000/s at peak- Client clicks the short link 100% of clicks
- Edge / CDN 60 s max-age on the hottest links serves 30%
- API one route, one lookup sees 70%
- Cache cache-aside, one-hour TTL hits 90% of those
- Database unique index on code sees 7%
Analytics branch
peels off at the API, never awaiteda full channel drops the click, not the redirect
Create (write)
40/s, boring on purpose-
Client
POST /links
-
API
validate the target, rate limit the caller
-
Key generator
base62 of a sequence, or a random code
-
Database
insert; the unique constraint is the referee
7 base62 characters: about 3.5 trillion codes
The write path can be boring. The read path has to be fast, and it has to stay fast for the few links that receive most of the clicks. Back-of-the-envelope estimation is the whole first hour of this design, and skipping it is how people end up sharding a 40-write-per-second database.
Generating the key
Seven base62 characters give 62^7, about 3.5 trillion distinct codes. Six give 56.8 billion,
which at 1.2 billion links a year would last 47 years. Length is not the constraint; how a code is
chosen is.
Base62 of a database sequence. The insert returns a bigint identity, you encode it, and the
code is unique by construction with no collision check. The cost is predictability: /abc123 is
followed by /abc124, so anyone can enumerate every link ever created. Mixing the sequence through
a keyed permutation, or starting it at a large random offset, restores some opacity without giving
up uniqueness.
Random codes with a collision check. Generate seven random characters, insert, and retry on a unique-constraint violation. With 1.2 billion codes already in a 3.5 trillion space, the chance a fresh random code collides is about 0.03 percent, so retries are rare and cheap. Random codes are not guessable, which is the property most people actually wanted from the word "hash."
A key generation service. A separate process pre-generates unused codes into a table and hands them to API instances in blocks. No collision check on the request path and no shared sequence, at the price of one more component to operate.
The encoder is twenty lines and worth writing rather than importing:
public static class Base62
{
private const string Alphabet =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
public static string Encode(long value)
{
ArgumentOutOfRangeException.ThrowIfNegative(value);
if (value == 0)
{
return "0";
}
Span<char> buffer = stackalloc char[11]; // long.MaxValue needs 11 base62 digits
var index = buffer.Length;
while (value > 0)
{
buffer[--index] = Alphabet[(int)(value % 62)];
value /= 62;
}
return new string(buffer[index..]);
}
}
Custom aliases share the same column and the same unique constraint. Decide up front whether codes
are case-sensitive (base62 says yes; users pasting links into email say no), and reserve the words
your own routes use, or someone will register /api.
CREATE TABLE links (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code text NOT NULL UNIQUE,
target_url text NOT NULL,
owner_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz
);
The redirect is the whole product
Traffic to a shortener is brutally skewed: a handful of links are on a billboard, and the long tail was clicked twice in 2024. That skew is what makes a cache-aside layer pay for itself. Suppose the cache answers 90 percent of redirects. The database then sees 400 lookups a second on average and 4,000 at peak, every one of them a primary-key-shaped read against a unique index. One PostgreSQL primary does that without noticing.
Memory is cheap here too. If the hot set is 20 percent of a month's links, that is 20 million entries at roughly 150 bytes each, about 3 GB in a cache node. The caching strategies guide covers invalidation and stampedes; for a shortener both are mild, because a link's destination almost never changes.
app.MapGet("/{code}", async (
string code,
HybridCache cache,
ILinkStore links,
ChannelWriter<ClickEvent> clicks,
HttpContext http,
CancellationToken cancellationToken) =>
{
var target = await cache.GetOrCreateAsync(
$"link:{code}",
async token => await links.FindTargetAsync(code, token),
new HybridCacheEntryOptions { Expiration = TimeSpan.FromHours(1) },
cancellationToken: cancellationToken);
if (target is null)
{
return Results.NotFound();
}
// Never await analytics here. A full channel drops the click, not the redirect.
clicks.TryWrite(new ClickEvent(
code,
DateTimeOffset.UtcNow,
http.Request.Headers.Referer.ToString()));
return Results.Redirect(target, permanent: false);
});
HybridCache gives you
an in-process tier, a distributed tier, and single-flight protection, so a thousand concurrent
misses on one hot key produce one database read. Cache the negative result too, briefly: scanners
probing random seven-character codes should hit memory, not the database.
Then put a CDN in front. A Cache-Control: public, max-age=60 on the hottest redirects lets the
edge answer repeat clicks for a minute without asking the origin. If the edge absorbs 30 percent of
clicks, 2,800 requests a second reach the API and 280 reach the database.
Edge caching is the difference between a viral link being an incident and
being a graph.
301 or 302 is an analytics decision
A 301 Moved Permanently tells the browser the answer will never change, and browsers believe it.
The second click on that link never reaches your server, and neither does any click after it.
Latency is perfect. Analytics are zero, expiry is impossible, and a customer who edits a destination
discovers that half their audience is cached on the old one forever.
A 302 Found (or 307 when the method must survive) sends every click back through your service.
That is the redirect you want when clicks are the product.
RFC 9110 spells out the cacheability rules;
the short version is that permanence is a promise, and a shortener can rarely keep it.
A permanent redirect is fast because it removes your server from the loop. It also removes your ability to count, change, or expire anything.
Analytics never touches the redirect path
Four thousand clicks a second is also four thousand analytics events a second, and the temptation is to write each one to the database before returning. Do not. The redirect returns as soon as the destination is known; the click event goes into a bounded in-process channel, a background service drains it into a queue in batches, and a worker aggregates counts per link per hour into a table the dashboard reads. If the broker is slow, the channel fills and clicks are dropped. Losing a few clicks is a bug in a report. Adding 30 ms to the redirect is a bug in the product.
Abuse is a feature list
A shortener is a free open redirect unless you treat it as one. Validate the scheme and host at
creation, check destinations against blocklists then and again later (a clean domain today is
phishing tomorrow), and give links an expires_at that a sweeper honors. Rate limit creation per
API key and per IP; a token bucket forgives a burst of ten links
and stops a script creating ten thousand. Custom aliases get the unique constraint, a length floor,
and the reserved-word list.
Where to practice the shape
The whiteboard version ends at the boxes. The interesting part is what happens when the boxes carry the numbers above and one of them saturates. Katabench's System Design Studio has a URL Shortener build course that walks this exact progression: shorten the first link, survive the viral link, cache the redirect, count the clicks, redirect from the edge, then observe the whole thing. Each step checks the design against authored rules (no client talking straight to the database, analytics off the redirect path) and runs a deterministic capacity simulation that reports p99 latency, throughput, and which component saturates first under a traffic scenario. It is a model built for learning, not a load test, and there is no single correct diagram. The tracks overview shows where system design sits beside the code puzzles.