~learn.

Problem 0111 minIntro+60 XP

Design a URL Shortener.

Design a URL shortener

7 phases2 code

Intro · 1 min

The interviewer slides a marker across the table. "Design bit.ly." It sounds like a ten-minute problem: hash the URL, store it, redirect. Then come the follow-ups. How do two servers avoid handing out the same code? What happens when one link goes viral? Why did you pick 302 and not 301? The question is small on purpose, so every decision shows.

The brief

Build a service like bit.ly or TinyURL. A user pastes a long URL and gets a short one, for example https://sho.rt/aZ3kL9q. Anyone who opens the short link is redirected to the original.

In scope
  • Create a short link, optionally with a custom alias and an expiry
  • Redirect, fast and highly available
  • Click counts per link
ExplainerThe whole problem: URL shortener

At an assumed 100M new links a month and 100 redirects per link, a shortener handles about 40 creates and 4,000 redirects a second and stores 6 billion links (about 3 TB) over five years, using 0.17% of the 62^7 code space. Shorten servers lease blocks of 10,000 IDs from one counter row, scramble and base-62 encode them, and write with put-if-absent; redirects answer 302 from Redis cache-aside over a sharded URL store and publish clicks to Kafka. Failure modes: Redis down falls through to the store, an allocator outage is covered by leased blocks, and abuse and expiry are checked on create and read.

Phase 1 · Stage 1 of 7 · 3 min budget · 1 min read

Phase 1: Requirements (3 minutes)#

Transcript
You

"Is this mostly reads? Can people choose their own alias? Do links expire? Do we need analytics in real time?"

Interviewer

"Overwhelmingly reads. Custom aliases yes, optional. Default links live five years. Click counts can lag by a minute."

Functional requirements#

Functional requirements
  • shorten(long_url, alias?, expires_at?) returns a unique short code
  • GET /{code} redirects to the long URL, or returns 404 if the code is unknown or expired
  • Owners can delete a link
  • Click counts per link, eventually consistent

Non-functional requirements#

Non-functional requirements
  • Read heavy: about 100 redirects per creation
  • Low latency: redirect under 50 ms at p99 on the server
  • Highly available: a broken redirect breaks every page and message that embedded it, so redirects get the strictest availability target
  • Unique codes: two different links must never get the same code
  • Not guessable: codes should not be easy to enumerate one after another

Phase 2 · Stage 2 of 7 · 3 min budget · 1 min read

Phase 2: Estimation (3 minutes)#

Your turn · checkpoint 1not answered

Before you calculate: 6 characters or 7?

Over five years at 100M links a month you store about 6 billion links. With 62 symbols (a-z, A-Z, 0-9), is a 6-character code enough?

Think it through, or jot it down. Then compare.

One strong answer

62^6 is about 56.8 billion, so 6 billion links would use about 11% of the space. A counter-based scheme could live with that, but random codes would collide about one time in ten, and a dense space is easy to enumerate. 62^7 is about 3.5 trillion: the same 6 billion links use about 0.17%, so 7 characters buys headroom for one extra character of length.

WorksheetTraffic and storage
  • Writes: 100M per month ÷ ~2.6M seconds per month ≈ ~40 creates/s (assume 3x peaks: ~120/s)
  • Reads: 100 × 40 ≈ ~4,000 redirects/s average, ~12,000/s at a 3x peak
  • One record (code, long URL, owner, created, expires): assume ~500 bytes with index overhead
  • Five years: 100M × 60 months = 6 billion links × 500 B ≈ 3 TB, about 9 TB with 3 replicas
  • Cache: if the hottest 20M links take most of the clicks, 20M × 500 B ≈ 10 GB, which fits in one Redis node and its replicas
You

"Write traffic is tiny. The design is a read-path problem: keep redirects in memory and keep the write path correct."

Phase 3 · Stage 3 of 7 · 2 min budget · 1 min read

Phase 3: API (2 minutes)#

http12 lines
POST /api/v1/urls
Authorization: Bearer <api key>
{ "long_url": "https://example.com/very/long/path?utm=x", "alias": "spring-sale", "expires_at": "2031-01-01" }

201 Created
{ "code": "aZ3kL9q", "short_url": "https://sho.rt/aZ3kL9q" }

GET /aZ3kL9q
302 Found
Location: https://example.com/very/long/path?utm=x

DELETE /api/v1/urls/aZ3kL9q        → 204 (owner only)
  • An alias that is already taken returns 409 Conflict.
  • Unknown and expired codes return 404, and the 404 is cached too (more on that below).

Phase 4 · Stage 4 of 7 · 4 min budget · 1 min read

Phase 4: High-level design (4 minutes)#

You

"Two paths with very different needs: a small write path that must never duplicate a code, and a huge read path that must never be slow."

DiagramURL shortener: create path and redirect path
%% title: URL shortener: create path and redirect path
flowchart TD
  U@{ icon: "sd:users", label: "Users, browsers, apps" } --> LB@{ icon: "sd:load-balancer", label: "Load balancer" }
  LB -->|"POST /api/v1/urls"| WS["Shorten service<br/>holds a block of IDs"]:::core
  LB -->|"GET /{code}"| RS["Redirect service"]:::hot
  WS -->|"next block of 10,000"| KA@{ icon: "sd:key", label: "Range allocator<br/>(one counter row)" }
  WS -->|"put-if-absent"| DB@{ icon: "sd:database", label: "URL store<br/>key-value, sharded by code" }
  RS -->|"1 · cache-aside"| R@{ icon: "tech:redis", label: "Redis<br/>hot codes + 404s" }
  RS -->|"2 · on miss"| DB
  RS -->|"click event, async"| K@{ icon: "tech:kafka", label: "Kafka · clicks" }
  K --> AN@{ icon: "sd:chart", label: "Click counts" }
  class U client
  class LB edge
  class KA,DB store
  class R cache
  class K queue
  class AN service
  • Shorten service: validates the URL, takes the next ID from its local block, turns it into a 7-character code and writes code → long_url with put-if-absent.
  • Redirect service: stateless. Looks up the code in Redis, falls back to the URL store, answers with a 302, and publishes a click event without waiting for it.
  • URL store: a key-value lookup by code, so any store that shards by key works (a key-value database, or sharded SQL keyed on code).
  • Click counts: a consumer aggregates Kafka events into per-link counters; they lag by seconds to a minute, as agreed.

Phase 5 · Stage 5 of 7 · 5 min budget · 3 min read

Phase 5: Deep dives (5 minutes)#

Deep dive 1: Where do codes come from?#

Interviewer

"Two shorten servers get a request at the same millisecond. How do you make sure they never produce the same code?"

Three ways to make a code

Hash and truncate

  • base62(md5(long_url))[:7]
  • Same URL always gives the same code (handy for dedupe)
  • Truncation collides: you must check and re-hash, and two servers can race on the check

Random + check

  • Pick 7 random characters, insert if absent
  • Simple, unguessable, no coordination
  • Every insert needs a uniqueness check; retries grow as the space fills

Counter + base62

  • A unique integer per link, encoded in base 62
  • Never collides by construction
  • Needs a counter that many servers can share without a bottleneck
You

"I'd use the counter, but without a round trip per link. Each shorten server leases a block of 10,000 IDs from a range allocator, a single row it increments atomically. At 40 creates a second the allocator is touched a few times an hour, and a server that crashes just wastes the rest of its block."

From integer to code
  1. Lease a block: the server atomically moves the allocator's counter from N to N + 10,000 and owns that range.
  2. Take the next ID from local memory: no network call.
  3. Scramble it: a reversible permutation of the 62^7 space (for example multiply by a large number coprime to 62^7, modulo 62^7) so consecutive IDs don't give consecutive codes.
  4. Encode the result in base 62, left-padded to 7 characters.
  5. Write with put-if-absent: a custom alias goes through the same conditional write, which is what returns 409 when the alias is taken.
pythonto_code · 11 lines
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
SPACE = 62 ** 7            # ~3.5 trillion codes
MULT = 1_580_030_173       # any odd number not divisible by 31 is coprime to 62^7

def to_code(n: int) -> str:
    n = (n * MULT) % SPACE          # bijective scramble: unique in, unique out
    out = []
    for _ in range(7):
        n, r = divmod(n, 62)
        out.append(ALPHABET[r])
    return "".join(reversed(out))

The scramble only hides the order from casual eyes; it is not encryption. If enumeration is a real threat (private documents behind short links), use random codes with put-if-absent, or longer codes.

Deep dive 2: Making the redirect fast and cheap#

Interviewer

"Why 302? A 301 is cacheable by browsers, so you'd get less traffic."

301 or 302

301 Moved Permanently

  • Browsers may cache it and skip your server next time
  • Less load, but you stop seeing repeat clicks
  • Hard to take back: an edited or deleted link can keep redirecting from caches

302 Found

  • Every click comes back to you
  • Accurate click counts, and delete or expiry works at once
  • More traffic, which the cache is there to absorb
You

"Analytics and the ability to delete are requirements, so 302. Then I make the extra traffic cheap."

  • Cache-aside in Redis: read the code from Redis; on a miss, read the store and set it with a TTL. With a skewed click distribution, most redirects never touch the store.
  • Cache the misses too: scanners try random codes. Storing "not found" for a short TTL stops them from hammering the store.
  • Viral links: one code can get a burst far above its neighbours. Short-lived caching at a CDN edge, or a small in-process cache in each redirect server, absorbs it before it reaches a single Redis shard.
  • Clicks off the critical path: the redirect publishes a click event to Kafka and returns. If Kafka is slow, the redirect still works; counts catch up later.
  • Expiry on read: the cached value carries expires_at, and the redirect service checks it before answering, so an expired link stops at once even if a cache entry outlives it.
Quick check2 questions · +5 XP each

Codes and redirects

  1. Q1/2 Why does each shorten server lease a block of 10,000 IDs instead of asking the allocator for every link?

    Answer & explanation

    Answer: B

    The allocator is touched a few times an hour instead of on every create. A crash only wastes the unused part of a block. Guessability is handled by the scramble step, not by blocks.

  2. Q2/2 The product team wants accurate click counts and the ability to kill a link instantly. Which redirect status fits?

    Answer & explanation

    Answer: B

    A 301 can be cached by browsers, so repeat clicks bypass you and a deleted link may keep working. 302 keeps you in the loop; the cache absorbs the cost.

Phase 6 · Stage 6 of 7 · 2 min budget · 1 min read

Phase 6: Trade-offs and failure modes (2 minutes)#

What breaks, and what you do
Redis is down

Redirects fall through to the URL store. Size the store's read replicas for the peak (about 12,000 reads/s here) so a cache outage is slower, not fatal.

The range allocator is down

Servers keep issuing IDs from the block they already hold. At 40 creates a second across the fleet, a block lasts a long time, so creation survives a short outage untouched.

The same long URL is shortened twice

Simplest answer: give each request its own code (owners, expiry and stats differ). Deduplicate only per owner if the product asks for it, with a lookup index on (owner, long_url).

Abuse

Check new URLs against a malware and phishing list at creation, rate-limit creation per API key, and keep a blocklist the redirect service consults so a bad link can be killed in seconds.

Expired links pile up

Check expiry on read (correctness) and run a background sweeper that deletes expired rows in small batches (storage). The sweeper can lag without anyone noticing.

Phase 7 · Stage 7 of 7 · 1 min

Phase 7: Go deeper in the course#

Wrap-up · 1 min

Final check#

Quick check3 questions · +5 XP each

Final check · URL shortener

  1. Q1/3 At 100M new links a month and 100 redirects per link, roughly what average redirect rate do you design around?

    Answer & explanation

    Answer: B

    100M per month is about 40 creates per second (100M ÷ ~2.6M seconds). A 100:1 ratio gives about 4,000 redirects per second, and about 12,000 at a 3x peak.

  2. Q2/3 Why is a 6-character code a weak choice for about 6 billion links?

    Answer & explanation

    Answer: A

    62^6 is about 56.8 billion, so the links would fit, but densely. 62^7 (about 3.5 trillion) drops the fill to about 0.17%.

  3. Q3/3 A scanner hits millions of random codes that don't exist. What protects the URL store?

    Answer & explanation

    Answer: C

    Negative caching turns repeated misses into cache hits, and a per-client limit stops one caller from sweeping the space.

End of Problem 01 · Design a URL Shortener
Finish Problem 01Stages clear as you read them (0/7 so far). Skipping ahead doesn't count: marking complete unlocks after ~3 more min of reading.

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…