Design a URL shortener
Monday, 11:20: Maya's summer-sale SMS is 214 characters because of one hotel URL, so each of 400,000 texts is billed twice, about €16,000 extra. She wants links like stay.hb/x7Kp2 before the sale in three weeks.
Turning a long link into a short one looks easy. Designing it well teaches you the whole system design method.
- Problem
Turn a long URL into a short link (a 5-character code) that redirects in milliseconds, for years, even when one link goes viral.
- Why the simple system fails
One server with one table works on day one, then a viral link melts the database, counters lock rows, and spammers turn your domain into a phishing tool.
- Engineering concept
Split the tiny write path from the huge read path; generate collision-free IDs without coordination; cache the skewed hot set; move analytics off the request path.
- Technology
Base62 codes from a counter or random bits, a key-value store, Redis + CDN for hot links, a queue for click events, rate limits and URL scanning.
- Trade-offs
301 vs 302 (speed vs analytics), counters vs random IDs (simplicity vs predictability), exact vs approximate click counts.
The problem
2 min- Maya · Product managerThe text is 214 characters because of the hotel URL: 2 SMS each for 400,000 guests. About €16,000 extra.
- Maya · Product managerI want links like stay.hb/x7Kp2. In three weeks.
- Lina · Senior engineer, your mentorYou lead this one. Start with a guess, not a box.
The big idea
4 minTwo paths, very different loads: a slow, rare create and a skewed, bursty redirect that must stay read-only and cached.
A cloakroom hands out tickets slowly all evening, then everyone wants their coat at closing time. The attendant keeps the most-asked-for coats by the counter, and tickets come off a numbered roll so no two match.
How it works
5 minThe short code is the few characters after the domain, in base62 (0–9, a–z, A–Z). Create (POST /links) runs ~1/s and may take 50 ms. Redirect runs ~10/s on average, ~1,000/s in a sale's first minute, up to ~5,000/s if an influencer posts one: a hot key, one key getting a huge share of traffic. It answers with an (301 or 302: "go to this URL instead").
- 1Tap
The guest taps stay.hb/x7Kp2 in an SMS.
- 2Edge
The CDN answers directly if it kept this redirect in the last 10 seconds.
- 3Redis
Otherwise : look in the cache first,
link:x7Kp2, ~0.5 ms. - 4Database on a miss
A primary-key lookup in PostgreSQL (~1–2 ms), then the answer goes into Redis.
- 5Count later
The app drops a click event on a queue and doesn't wait.
- 6Redirect
302 to the hotel page.
5,000 clicks/s is more than the ~1,600/s our four servers handle together, so the CDN must absorb viral links by keeping the redirect a few seconds.
Watch a decision being made
4 minYou design stay.hb; Lina checks
Short links for booking SMS, guest shares and partner newsletters at the 10× plan: ~100,000 links/day, sale peaks ~1,000 clicks/s, viral ~5,000/s. 5-character codes.
- 1Pin the requirements and estimate both paths
Links only to StayHub pages; click counts per campaign; redirect p99 < 50 ms server-side; links work 5 years.
textcreate: 100,000/day ÷ 10⁵ s ≈ 1/s → one table viral: ≈ 5,000/s, one code > 4 × 400 = 1,600/s → CDN needed storage: 180M × 500 B ≈ 90 GB → PostgreSQL is fineWhy: "Only StayHub pages" removes most abuse work. The viral number exceeds our servers: the one fact that forces new design.
- 2Choose how codes are made
62⁵ ≈ 916M codes; 5 years uses ~20%. A counter in base62 never collides; each server reserves 10,000 ids at a time.
Why: Random codes would collide 1 time in 5 by year five. The counter's cost, guessable codes, is harmless for public hotel pages.
- 3Make the redirect read-only and cached
Redis cache-aside, 1-hour TTL; unknown codes cached 60 s so scanners don't reach PostgreSQL.
302with a 10 s CDN cache.Why: Redis absorbs sale peaks; the CDN absorbs viral ones: each edge asks us once every 10 seconds.
- 4Move counting off the hot path
The redirect publishes a click event; a worker writes
link_clicks_hourlyevery 10 seconds.Why: One write per link every 10 s instead of thousands per second on one row. Counts arrive seconds late; nobody notices.
In real life
3 minBitly: anyone can shorten anything
- A public product; click analytics are much of what customers pay for
- Being open means detecting and taking down spam and phishing links
Fits when shortening is the product itself.
X's t.co: only the platform makes links
- Links in posts are publicly known to be wrapped in t.co automatically
- Users can't create t.co links; the platform checks and counts them
Fits when short links serve your own product.
What goes wrong
3 minTwo servers both see "free" and both insert.
A unique constraint, or counter codes that can't collide.
Browsers keep permanent redirects; repeat clicks vanish.
302 with a short cache time.
1 write/s and a cached read path fit what we already run.
Let the estimates justify each component.
Trade-offs
3 min| Code scheme | Fits when | You pay |
|---|---|---|
| Counter ranges + base62 | Public pages; short codes matter | Guessable codes; a range allocator |
| Random codes | Semi-private links; mostly empty code space | Uniqueness checks; retries as it fills |
| Hash of the URL | Deduplication matters, counts don't | Campaigns share one link and its counts |
- Karim · CTOUse 301. Browsers remember it, so most repeat clicks never reach us. Smaller bill.
- Maya · Product managerThen I can't see who clicked the sale text twice before booking. That's the number I report.
- Maya · Product managerAnd when a hotel closes, a 301 is stuck in their browser.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- Scope: links only to StayHub pages. Claude won't assume it unless you say so.
- How codes are made (base62 counter, ranges of 10,000) and the redirect type (
302, short cache), because Maya needs counts and links must be fixable. - Cache times (CDN 10 s, Redis 1 hour), and that counting never happens in the redirect request.
Build stay.hb short links as a module in our app. Plan first, wait for approval. Decided: links only to StayHub pages; 5-character base62 codes from a counter, ranges of 10,000 per server; UNIQUE on code; redirect is 302 with max-age=10; Redis cache-aside, 1-hour TTL, unknown codes cached 60 s; the redirect publishes a click event and never writes; a worker aggregates clicks every 10 s. Show the handler and table first. Test: unknown code, cache miss, two servers creating links at once.
- The redirect handler only reads and publishes an event. Any
UPDATEthere is the beta's hot-row bug. - A
302with shortCache-Control, and aUNIQUEconstraint oncodein the migration. - A staging load test of one code at ~1,000 req/s: p99 under 50 ms server-side.
UPDATE links SET clicks = clicks + 1inside the redirect.301"for performance".- Random codes checked with a
SELECTbefore inserting, without a unique constraint.
Check yourself
2 minNext episode: Maya wants guests to chat with their hotel before arrival, and messages must show up instantly, even when the hotel's phone is offline.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You design stay.hb; Lina checks
Design short links for StayHub: booking SMS, guest shares and partner newsletters, at the 10× plan (~100,000 links/day, ~1M clicks/day, sale peaks of ~1,000 clicks/s and viral peaks of ~5,000/s). Codes must be 5 characters.
- 1Pin the requirements
Functional: create a link to a StayHub page, redirect, click counts per campaign. Non-functional: redirect p99 < 50 ms server-side, links work for 5 years, reads highly available (a printed or texted link can't be fixed), creation can be slower. Out of scope: links to other sites.
Why: "Only StayHub pages" is the requirement that removes most of the abuse work. "Click counts" decides the redirect type.
- 2Estimate both paths
Writes: 100,000/day ≈ 1/s. Reads: ~10/s average, ~1,000/s at a sale, ~5,000/s viral. Storage: 180M links in 5 years × 500 B ≈ 90 GB.
textcreate: 100,000/day ÷ 10⁵ s ≈ 1/s → one table, no special store click: 1M/day ÷ 10⁵ s ≈ 10/s avg sale: 400k SMS × 20% in 10 min, front-loaded ≈ 1,000/s, one code viral: ≈ 5,000/s, one code > 4 servers × 400 = 1,600/s → CDN needed storage: 180M × 500 B ≈ 90 GB → PostgreSQL is fineWhy: The viral number is larger than our four servers can serve, which is the one fact that forces a new piece of design.
- 3Choose how codes are made
62⁵ ≈ 916M codes; 5 years uses ~180M (20%). A counter, encoded in base62, never collides. To avoid a database call per link, each server reserves a range of 10,000 ids at a time.
Why: Random codes would collide 1 time in 5 by year five. The counter's cost is guessable codes, harmless for links to public hotel pages.
- 4Make the redirect read-only and cached
Cache-aside in Redis (
link:{code}, 1-hour TTL); unknown codes cached for 60 s too, so scanners guessing codes don't reach PostgreSQL. Answer302with a shortCache-Control, and let the CDN keep it for 10 s.Why: Redis absorbs sale peaks; the CDN absorbs viral ones: at 5,000 clicks/s each edge asks us once every 10 seconds.
- 5Move counting off the hot path
The redirect publishes a click event and returns. A worker adds them into
link_clicks_hourly(code, hour, n)every 10 seconds. Clicks the CDN answered come from its request logs.Why: One write per link every 10 s instead of thousands per second on one row. Counts arrive seconds late, which nobody notices.
app.get("/:code", async (req, res) => {
const { code } = req.params;
let url = await redis.get(`link:${code}`);
if (url === null) {
const row = await db.oneOrNone("SELECT url FROM links WHERE code = $1", [code]);
url = row ? row.url : "__missing__";
// Cache misses too, so scanners guessing codes don't reach the database.
await redis.set(`link:${code}`, url, { EX: row ? 3600 : 60 });
}
if (url === "__missing__") return res.status(404).send("Link not found");
queue.publish("clicks", { code, at: Date.now() }); // no await: count later
res.set("Cache-Control", "public, max-age=10"); // the CDN may keep it 10 s
return res.redirect(302, url);
});| Decision | Option A | Option B | What decides |
|---|---|---|---|
| Redirect status | 301: browsers cache it, fewer requests | 302: every click reaches you or your CDN | Do you need click counts or the ability to change a link? Then 302. |
| ID scheme | Counter ranges + base62: short, no collisions | Random: unguessable, needs insert-if-absent | Are links sensitive? How full will the code space get? |
| Click counting | UPDATE clicks = clicks + 1 per click | Events → batched counters | Hot links create lock contention; async wins past a few hundred clicks/s. |
| Store | Relational (PostgreSQL) | Key-value store | Both fine for primary-key lookups; pick by team skill and size. |
Interactive simulation
Visual diagram
Two paths: a thin write path (left to the database) and a fat, cached read path. Analytics leaves the request path through a queue.
Analogy
- A coat checkYour coatA URL shortenerThe long link
- A coat checkA small numbered ticketA URL shortenerThe short code
- A coat checkTickets from a numbered rollA URL shortenerUnique ID generation
- A coat checkPopular coats kept near the counterA URL shortenerCaching hot links
- A coat checkA guard refusing suspicious packagesA URL shortenerAbuse filtering
Where the analogy breaks: a coat goes to one person. A short link can be opened by millions of people at the same moment.
Check your understanding
SELECT 1 FROM links WHERE code = $1, sees no row, then inserts. Once a month, a link redirects to the wrong customer's URL. What is the bug?Practice ladder
Trace a click on a cold link
Scenario Warm-upUsing the stay.hb design (CDN keeps a 302 for 10 s; Redis keeps found codes 1 hour and unknown codes 60 s; clicks go to a queue), trace these three requests in order and say, for each, which components it touches and roughly how long the server side takes:
- 09:00:00. A guest taps
stay.hb/q3Rt8, a link created last year and not clicked for months. - 09:00:04. A second guest, near the same CDN edge, taps
stay.hb/q3Rt8. - 09:00:05. A bot tries
stay.hb/zzzzz, a code that doesn't exist, then tries it again at 09:00:30.
Finish the range allocator
Code PracticeLina's id allocator reserves ranges so servers don't hit the database for every link. Two lines are missing. Fill them in, then explain what happens to unused ids when a server restarts.
let next = 0n, end = 0n;
async function nextId(db: Db): Promise<bigint> {
if (next >= end) {
// Reserve 10,000 ids for this server in ONE atomic statement.
const { rows } = await db.query(
/* ___ missing SQL ___ */
);
next = BigInt(rows[0].start);
end = /* ___ missing ___ */;
}
return next++;
}Gift vouchers with codes
Design PracticeMaya's next idea: gift vouchers. A guest buys a €100 voucher and receives a code to type at checkout, like H7K2-9QXM.
- ~2,000 vouchers sold a day at the 10× plan; vouchers last 2 years.
- Checkout looks up the code; each voucher is redeemed once (partially used balances allowed).
- A voucher is money: whoever knows the code can spend it.
Design the code scheme, the redeem path and the protections. Explain where this design must differ from stay.hb links, and why.
Open stay.hb to partners' own websites?
Decision StretchThe Hotel Partners team asks: "Let partner hotels shorten any URL with stay.hb (their own website, their restaurant menu), not just StayHub pages. It's a nice perk." 1,800 partners today, 18,000 in the 10× plan; ~5% of partner accounts have been compromised at some point in the past (phished passwords).
Write your decision: "We chose X over Y because…, accepting the risk of…", and say what would have to be true to change your mind.
Explain it yourself
Explain to a colleague how you would design StayHub's short links (~1M clicks a day, up to ~5,000 clicks/s on one viral code), and which parts you would deliberately not build yet.