Track 1 · Foundations
easyURL Shortener
Turn a long URL into a short code and redirect on lookup. The classic warm-up: tiny write path, enormous read path, and a key-generation problem hiding underneath.
Suggested architecture
Scenario
Two paths that have nothing in common. Reads outnumber writes 100:1, so the question is how much of the read path the cache absorbs — and what the store sees when it does not.
Links followed per second. This is the hot path; everything else is sized from it.
Links created per second. Small, but every one is a durable write.
Share of redirects the cache answers. Popularity is skewed, so a small cache gets a high number — until it restarts.
Stateless, so this is the knob you turn first.
Each partition of the key-value store handles a fixed rate before you shard again.
Click a component for its role, common technology choices and tradeoffs, and what it is carrying at this scale. Hover a connection to see what flows along it. Drag to rearrange — layout changes are local and reset on reload.
Every figure here is a rough estimate from simple capacity arithmetic, not a benchmark. Each part carries its own assumption about what one copy can do — real numbers depend on your hardware, payloads and access pattern. The point is which component moves first as you turn the dials, not the digits themselves.
In plain words
A URL shortener turns a long address into a short one and sends anyone who opens the short one to the long one. Making a link happens once; opening it happens millions of times. Nearly everything in this design comes from treating those two actions differently — and the lesson (find the busy path, then make it as small as possible) is one you will reuse in almost every system.
The numbers
Assume the interview numbers: 100 M new links per day and a 100:1 read/write ratio.
- writes, average (100M ÷ 86,400)
- 1,200 /s
- reads, average — 350 K at peak
- 120 K /s
- storage over 5 years at ~500 B a link
- 90 TB
- codes available at 7 base-62 chars
- 3.5 T
Worked the way back-of-envelope estimation describes: a few facts, a few multiplications, and the big number tells you the shape of the problem.
The write number is small enough that one well-tuned database handles it. The read number is not — and that is where the design goes.
How a redirect flows
Decision 1: how to make the short code
Simple and stateless. But two different URLs will produce the same 7 characters eventually (a collision), so every write needs a read-before-write to check — and the collision path is fiddly.
1, 2, 3… encoded as a–z A–Z 0–9. Guaranteed unique, no check needed. But one counter is one thing every server must talk to, and sequential codes are guessable — anyone can walk your whole link database by counting up.
Each service instance grabs a block — say [1,000,000, 1,001,000) — from a small coordination service, then hands out codes locally with no network call. Unique by construction, unlimited throughput, codes no longer densely sequential.
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
function toBase62(n: number): string {
let s = "";
do { s = ALPHABET[n % 62] + s; n = Math.floor(n / 62); } while (n > 0);
return s.padStart(7, "0");
}
class CodeSource {
private next = 0; private end = 0;
async take(): Promise<string> {
if (this.next >= this.end) {
// One coordination call per 1,000 codes — a crash wastes the rest of a block. Who cares.
const start = await coordinator.reserveBlock();
this.next = start; this.end = start + ;
}
return toBase62(this.next++);
}
}Decision 2: keep the read path empty
The redirect service does one lookup and returns a redirect. That is the entire hot path. Everything tempting to add — click analytics, spam checks, per-user counters — is deliberately kept off it. Analytics goes out fire-and-forget (sent without waiting for a reply) or onto a queue; the user's redirect never waits for it.
Link popularity is very uneven: a few links get most of the clicks. That is exactly the shape a cache loves. And because a code's mapping never changes once written, the usual hard part of caching — knowing when a copy has gone stale — does not exist here. Cache forever.
app.get("/:code", async (req, res) => {
const url = (await cache.get(req.params.code)) ?? (await store.lookupAndCache(req.params.code));
if (!url) return res.status(404).end();
clicks.publish({ code: req.params.code, at: Date.now(), ua: req.headers["user-agent"] }); // no await
res.redirect(302, url);
});Decision 3: 301 or 302
The browser caches the mapping, so repeat visits never reach you at all — a large, free traffic reduction. But you lose click analytics for those visits, and you can never re-point or revoke the link for people who already have it.
Every visit comes to you. Full analytics, links can be changed or disabled. You pay the traffic. Most commercial shorteners pick this — analytics is the product.
Where this design breaks
- Cache cold start. Restart the cache fleet and 120 K reads/s hit the store directly. Stagger restarts, or warm from a list of recent hot keys.
- One link going viral. A single key can exceed what one cache node can serve. Replicate that key across nodes, or put a CDN in front (the redirect is cacheable).
- Custom aliases.
short.ly/my-campaignbreaks the collision-free property of leased ranges — now you need a real uniqueness check, on a separate path from generated codes. - Expiry and reuse. Nothing here reclaims codes. If links expire, sweep them by TTL — and never reissue an expired code: old copies in the wild would silently point somewhere new.
Take this with you
- The one idea: separate the rare write path from the constant read path, and keep the read path down to a single lookup.
- In an interview, lead with the numbers, pick range-leasing for codes, and be ready to defend 301 vs 302.
- At work, look for the same shape in your own system: a hot path with extras bolted on. Every one of them is latency you are paying on every request.