Skip to content
Episode 38 · Short links before the summer sale

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.

30 min intermediateShort unique ID generationSeparating read and write pathsCachingScalabilityFinding the bottleneckIdempotency
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Trade-offs

    301 vs 302 (speed vs analytics), counters vs random IDs (simplicity vs predictability), exact vs approximate click counts.

01

The problem

2 min
Monday, 11:20. Maya, with the summer-sale SMS draft.
  1. Maya · Product manager
    The text is 214 characters because of the hotel URL: 2 SMS each for 400,000 guests. About €16,000 extra.
  2. Maya · Product manager
    I want links like stay.hb/x7Kp2. In three weeks.
  3. Lina · Senior engineer, your mentor
    You lead this one. Start with a guess, not a box.
02

The big idea

4 min
Two paths, very different loads
The big idea

Two 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.

03

How it works

5 min

The 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").

One click, end to end
  1. 1
    Tap

    The guest taps stay.hb/x7Kp2 in an SMS.

  2. 2
    Edge

    The CDN answers directly if it kept this redirect in the last 10 seconds.

  3. 3
    Redis

    Otherwise : look in the cache first, link:x7Kp2, ~0.5 ms.

  4. 4
    Database on a miss

    A primary-key lookup in PostgreSQL (~1–2 ms), then the answer goes into Redis.

  5. 5
    Count later

    The app drops a click event on a queue and doesn't wait.

  6. 6
    Redirect

    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.

04

Watch a decision being made

4 min
Worked example

You 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.

  1. 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.

    text
    create:  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 fine

    Why: "Only StayHub pages" removes most abuse work. The viral number exceeds our servers: the one fact that forces new design.

  2. 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.

  3. 3Make the redirect read-only and cached

    Redis cache-aside, 1-hour TTL; unknown codes cached 60 s so scanners don't reach PostgreSQL. 302 with a 10 s CDN cache.

    Why: Redis absorbs sale peaks; the CDN absorbs viral ones: each edge asks us once every 10 seconds.

  4. 4Move counting off the hot path

    The redirect publishes a click event; a worker writes link_clicks_hourly every 10 seconds.

    Why: One write per link every 10 s instead of thousands per second on one row. Counts arrive seconds late; nobody notices.

The whole thing in one line: A 1-per-second write path, a skewed read path of up to 5,000 clicks/s on one code: collision-free counter codes, a cached read-only redirect behind a CDN, and counting after the answer.
05

In real life

3 min
Open to everyone, or only for our own links

Bitly: 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.

06

What goes wrong

3 min
Checking for a free code with SELECT, then inserting

Two servers both see "free" and both insert.

Instead

A unique constraint, or counter codes that can't collide.

Using 301 and promising click counts

Browsers keep permanent redirects; repeat clicks vanish.

Instead

302 with a short cache time.

Starting with Kafka, sharding and multi-region

1 write/s and a cached read path fit what we already run.

Instead

Let the estimates justify each component.

07

Trade-offs

3 min
Code schemeFits whenYou pay
Counter ranges + base62Public pages; short codes matterGuessable codes; a range allocator
Random codesSemi-private links; mostly empty code spaceUniqueness checks; retries as it fills
Hash of the URLDeduplication matters, counts don'tCampaigns share one link and its counts
Tuesday, 15:30. The redirect status, again.
  1. Karim · CTO
    Use 301. Browsers remember it, so most repeat clicks never reach us. Smaller bill.
  2. Maya · Product manager
    Then I can't see who clicked the sale text twice before booking. That's the number I report.
  3. Maya · Product manager
    And when a hotel closes, a 301 is stuck in their browser.
08

Your job as tech lead

4 min

Claude 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.

Decisions you own
  • 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.
Tell Claude Code
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.
Check before you accept
  • The redirect handler only reads and publishes an event. Any UPDATE there is the beta's hot-row bug.
  • A 302 with short Cache-Control, and a UNIQUE constraint on code in the migration.
  • A staging load test of one code at ~1,000 req/s: p99 under 50 ms server-side.
Red flags
  • UPDATE links SET clicks = clicks + 1 inside the redirect.
  • 301 "for performance".
  • Random codes checked with a SELECT before inserting, without a unique constraint.
09

Check yourself

2 min
Q1/3Reasoning
100M new links per month, read:write ratio 100:1. Roughly how many redirects per second on average?
req/s
How confident are you?

Next 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.

Finished?

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.
10

Technical deep dive

Codes, caches and a viral link
Worked example

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.

  1. 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.

  2. 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.

    text
    create:  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 fine

    Why: The viral number is larger than our four servers can serve, which is the one fact that forces a new piece of design.

  3. 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.

  4. 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. Answer 302 with a short Cache-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.

  5. 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.

The whole thing in one line: You split a 1-per-second write path from a skewed read path of up to 5,000 clicks/s on one code, gave it collision-free counter codes, a cached read-only redirect with a CDN in front for viral links, and counting that happens after the answer.
ts
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);
});
The redirect handler: cache-aside, negative caching for unknown codes, counting fired and forgotten.
DecisionOption AOption BWhat decides
Redirect status301: browsers cache it, fewer requests302: every click reaches you or your CDNDo you need click counts or the ability to change a link? Then 302.
ID schemeCounter ranges + base62: short, no collisionsRandom: unguessable, needs insert-if-absentAre links sensitive? How full will the code space get?
Click countingUPDATE clicks = clicks + 1 per clickEvents → batched countersHot links create lock contention; async wins past a few hundred clicks/s.
StoreRelational (PostgreSQL)Key-value storeBoth fine for primary-key lookups; pick by team skill and size.
The four decisions that shape most URL shortener designs.
11

Interactive simulation

12

Visual diagram

Click a component

Two paths: a thin write path (left to the database) and a fat, cached read path. Analytics leaves the request path through a queue.

13

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.

14

Check your understanding

Q1/3Debugging
Two app servers generate random codes. Each runs 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?
How confident are you?
15

Practice ladder

Step 1 · Trace

Trace a click on a cold link

Scenario Warm-up

Using 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:

  1. 09:00:00. A guest taps stay.hb/q3Rt8, a link created last year and not clicked for months.
  2. 09:00:04. A second guest, near the same CDN edge, taps stay.hb/q3Rt8.
  3. 09:00:05. A bot tries stay.hb/zzzzz, a code that doesn't exist, then tries it again at 09:00:30.
Step 2 · Complete

Finish the range allocator

Code Practice

Lina'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++;
}
Step 3 · Solve

Gift vouchers with codes

Design Practice

Maya'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.

Step 4 · Decide

Open stay.hb to partners' own websites?

Decision Stretch

The 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.

16

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.

0 words