Lesson 4 of 8 · 55 min

Worked design — URL shortener

Full shortener: base62/IDs, write and redirect paths, cache, hot aliases, async analytics, and estimation that kills over-engineering or forces edge cache.

Lesson 4 · Worked design

URL shortener end to end

Entry prompt, senior execution

URL shortener is often labeled “easy,” which is why seniors fail it by being sloppy. The prompt still tests ID generation, read-heavy caching, redirect correctness, hot keys/custom aliases, and async analytics. Public Bitly-scale figures are often cited in secondary write-ups (treat as heuristic / reported-elsewhere, not something we measured): multi-billion redirects/day class, cache hit ratios in the mid-to-high 90s for hot links, base62(7) ≈ 3.5T codes. Treat this as a warm-up where you demonstrate process excellence. Idempotency store for creates can be Redis with TTL longer than client retry window, backed by the unique code constraint in the DB. On retry storms, the Idempotency-Key lookup must be cheaper than re-allocating IDs blindly.
Product. Users create a short link that redirects to a long URL; optional custom alias, expiry, and basic click analytics. MVP NFRs (example assumptions — state them): high read:write (often ~100:1 class on redirects vs creates), p99 redirect < 50–100ms, durable mapping, US region first, 99.99% availability class aspiration. Functional: shorten, redirect, analytics, custom alias, expiration.

Requirements drill

  1. 01Custom aliases? uniqueness scope?
  2. 02Expiry / one-time links?
  3. 03Auth for create? public create?
  4. 04Analytics real-time or daily?
  5. 05Update destination after create?
  6. 06Abuse: malware URLs, rate limits?
  7. 07301 vs 302 — caching implications?
  8. 08Multi-region redirects day one?

Capacity → decisions (label numbers as heuristics)

python Redirect correctness details seniors mention unprompted: normalize and validate long URLs on write (scheme allowlist), store canonical form, decide whether tracking query params create new codes, and how updates to destination invalidate caches (version the cache key or delete-on-write). For analytics privacy, hash remote IPs with a rotating salt and drop raw user agents after UA-family extraction. These details take one minute and read as production judgment rather than textbook regurgitation.
1# Scenario A — moderate product (interview default)2new_links_per_day = 100_000_000 / 365          # ~3e5 writes/day if 100M/year3write_qps = 3e5 / 86400                       # ~3–4 QPS avg; peak ~20 QPS4redirects_per_day = 50_000_000                # example5read_qps = redirects_per_day / 86400          # ~600 avg → peak ~2–3K6# ID space: 7-char base62 = 62^7 ≈ 3.5e12 — huge vs 1e8 links/year7# Storage: ~200B/row × 1e8/year ≈ tens of GB/year raw — single DB fine early8# Decision: ID space is not the bottleneck; read QPS + cache is.910# Scenario B — bit.ly-class (secondary-cited / heuristic — label it)11# ~10B redirects/day → ~115k RPS avg → ~500k+ peak with spike factor12# 99% cache hit → origin sees ~1% → still large; edge cache mandatory13# 1B links × 200B = 200GB working set class → multi-node Redis/Memcached14# Write 1B links/day → ~11.5k write QPS avg → sharded metadata store1516# Numbers → decisions: analytics never on 302 path; shard when write QPS17# exceeds one primary; CDN/edge for viral codes.

API & data model

code
1POST /api/v1/links2  Headers: Idempotency-Key, Authorization?3  Body: { long_url, custom_alias?, expires_at? }4  → 201 { code, short_url }56GET /{code} → 302 Location: long_url  (or 301 if permanent + cacheable intentionally)7DELETE /api/v1/links/{code}8GET /api/v1/links/{code}/stats → aggregates (async path)910links(11  code PK,              -- base62 or custom12  long_url,13  owner_id NULL,14  created_at,15  expires_at NULL,16  is_custom BOOL17)18clicks(short_code, ts, ip_hash, ua, country, referer)  -- partition by day/code; async only
ID generation strategies. 1) Counter + base62 with range allocator: workers get blocks of IDs — high throughput, no random collision, risk of hot counter if naive. 2) Hash long_url (MD5/sha truncated): collisions + identical URLs share codes (maybe desired). 3) Random base62 + insert-if-not-exists: simple, retry on collision. 4) Snowflake-style (timestamp + worker + sequence): distributed, time-sortable — Twitter’s historical Snowflake is the classic reference. Custom aliases: separate path with uniqueness constraint; higher abuse risk; rate limit + reserved words list.
python
1ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"23def base62_encode(n: int, width: int = 7) -> str:4	out = []5	while n:6		n, r = divmod(n, 62)7		out.append(ALPHABET[r])8	s = "".join(reversed(out)) or "0"9	return s.rjust(width, "0")[-width:]1011# Counter service hands out [start, end) ranges per instance — no central lock per request.1213ID STRATEGY TRADEOFF14Approach              Pros                         Cons                    Pick when15--------------------  ---------------------------  ---------------------  --------------------16MD5/SHA truncate      simple                        collisions, retries    internal tooling17Base62 counter        monotonic, compact            hot row if unsharded   low-write sortable18Snowflake             distributed, time order       NTP/config care        multi-DC high QPS19UUID v7               no coord, sortable            longer keys            modern internal IDs20Random base62+unique  simple ops                    retries under load     many interviews
Write path. Validate URL → check custom alias availability → allocate code → insert row (unique on code) → optionally warm cache → return short URL. Idempotency-Key maps to existing code on retries. Transactions: unique constraint is the collision backstop. For hash-based codes, decide whether same long_url returns same code (dedupe) or always new (tracking). Pre-generate ID ranges so the sequence is not a hot row on every insert. Read path (the product). GET /{code}: lookup mapping → 302. Optimize: cache-aside on code → long_url (high hit rate). Optional edge cache for public non-personalized redirects with short TTL. Handle expiry: treat as miss + 410. Do not put analytics on the critical path — emit click event to queue after (or parallel to) deciding the redirect. Choose 302 (not cached aggressively by clients) vs 301 (permanent; browsers/CDN may cache hard) intentionally.
python
1def redirect(code: str):2	row = cache.get(code)3	if row is None:4		row = db.get_link(code)  # primary or read replica OK if create→redirect lag rare5		if row is None:6			return 4047		cache.set(code, row, ttl=3600)8	if row.expired():9		return 41010	enqueue_click(code)       # async; never block on analytics DB11	return 302, row.long_url
Hot keys, stampede, custom aliases. A viral campaign code can take a huge fraction of QPS. Mitigations: local cache on every API node + Redis; CDN for truly public campaigns; replicate hot keys; rate limit scrapers; single-flight on miss so expiry does not stampede the DB. Custom aliases: prevent takeover of popular paths (login, admin); consider separate table or flag is_custom for policy. Celebrity tweet of your short link is a first-class load test.

Analytics path

Clicks → Kafka/SQS → workers → append store or minute rollups. Dashboard reads rollups, not raw scans. This keeps redirect p99 stable when a link goes viral (analytics lag is OK). Do not UPDATE click_count on the hot row inline for viral codes — that creates a write hotspot on the most popular keys.

Scale-up story & failure modes

Year 1: single primary + replicas + Redis. Year 3: shard links by code hash if storage/QPS demands; range-based ID service already multi-instance. Multi-region: replicate mappings (read-mostly) with home-region creates or global ID service. Failures: cache stampede → single-flight; counter service down → pre-warmed ID blocks; custom alias race → 409; malware URL → async scanner + disable flag; analytics lag → redirects still succeed; DB down → serve hot links from cache with extended TTL.
Senior close: “Redirect path is cache + thin service; writes allocate IDs from ranges; analytics is fully async; hot campaign codes are a first-class case.”

Interview answers — URL shortener

  1. 01Key length? → 7 base62 ≈ 3.5T; years of headroom at 1M creates/day class.
  2. 02Hash collisions? → unique constraint + retry, or Snowflake/counter to avoid.
  3. 03Globally fast redirects? → geo replicas + edge/CDN cache.
  4. 04Abuse? → CAPTCHA, rate limit, IP reputation, malware scan async.
  5. 05Custom aliases break? → uniqueness, reserved words, policy path.
  6. 06Expiry? → TTL cache + DB sweep/tombstone.
  7. 07Analytics without slowing 302? → fire-and-forget queue.
  8. 08Celebrity tweet? → pre-warm cache/CDN; protect origin.
  9. 09Why not UUID in the URL? → too long; fine as internal id.
  10. 10Scale writes 100k/s? → range IDs, shard metadata, batch inserts.
  11. 11301 vs 302? → 301 caches hard at clients; 302 keeps control.
  12. 12Idempotent create? → Idempotency-Key returns same code.
Design a URL Shortener / TinyURL (search Gaurav Sen)Gaurav SenarticleHelloInterview — Design Bitly / URL shortenerHello InterviewarticleByteByteGo — Design a URL ShortenerByteByteGoarticleSnowflake ID announcement (historical)Twitter Engineering (historical)docsStripe Idempotent requestsStripedocsMDN HTTP 302MDN

301 vs 302 and CDN caching (deep)

301 permanent tells browsers and intermediate caches the mapping can be stored hard — great for truly immutable short links, painful if you ever need to change destination or kill malware quickly. 302 temporary keeps control on your origin for policy checks, expiry, and kill switches, at the cost of more origin/edge hits. Many production shorteners prefer 302 (or 307) on the hot path for control. If you put a CDN in front, set short TTLs and know how to purge a viral bad link in seconds.
code
1REDIRECT CACHING MATRIX2Layer            What to cache              TTL mindset3---------------  -------------------------  --------------------------4Browser          optional for 301           long if permanent5CDN edge         public non-auth codes      short (30–300s) + purge API6App Redis        code → long_url            minutes–hours; delete on edit7Local process    ultra-hot codes            seconds; single-flight fill89Never cache personalized analytics pages the same way as 302 Location.

Abuse, malware, and reserved aliases

Public create endpoints attract spam, phishing, and brand hijacks. Controls: rate limit per IP/account, CAPTCHA after thresholds, URL reputation checks async (do not block 302 for known-good), reserved word list for aliases (login, admin, brand names), and a disable flag that edge can honor from a push config. Senior answers treat abuse as a design surface, not an afterthought bullet. Analytics productization: minute rollups for dashboards, raw events in cheap object storage for ad-hoc, privacy (hash IPs, careful with PII in query strings of long URLs). If the interviewer asks for “real-time counters,” offer approximate (Redis HyperLogLog / rolling counters) on the hot path and exact batch later.

Multi-region shortener evolution

v1 single region + Redis. v2 read replicas in EU/APAC for redirects. v3 edge cache for public codes. Creates can stay home-region if create QPS is modest; global unique custom aliases need a uniqueness service (global index or home-region check with latency). Conflict: two regions allocating overlapping counter ranges — prevent by partitioning the ID space by region bits in Snowflake-style IDs.
code
1# ID space with region nibble (sketch)2# [timestamp | region | worker | sequence] → base623# Guarantees no cross-region counter collision without a global lock4# Custom aliases still need a global uniqueness check (harder problem)

Checkpoint

Where should the cache live for redirects, and why?

AOnly in the browserBService-side cache-aside (Redis/local) on code→URL; optional short-TTL edge for public campaignsCOnly on the analytics database
Sign up free to answer and see why

Checkpoint

Two create requests with the same Idempotency-Key arrive. Correct behavior?

ACreate two different short codesBReturn the same code/result as the first successful createCDelete the old code and make a new one
Sign up free to answer and see why

Checkpoint

Why pre-allocate Snowflake/counter ranges instead of hitting a global sequence on every write?

AMakes the short code prettierBAvoids a write-path hot row on the sequence; enables batch/distributed allocationCShrinks storage of long_url
Sign up free to answer and see why

Checkpoint

A celebrity posts one short link; redirect QPS spikes 100×. What do you cut/change first?

AMove analytics to synchronous on the requestBEnsure analytics stays async; scale cache/CDN for that key; protect DB with cache hit ratioCDisable the link
Sign up free to answer and see why

Checkpoint

Why might estimation show a single DB is enough for metadata yet you still introduce Redis?

ABecause every design needs RedisBRead QPS/latency — not storage bytes — dominates; cache buys p99 and shields the primaryCRedis replaces the need for unique constraints
Sign up free to answer and see why

End-to-end narration script (12 minutes)

Say this structure in a mock until it is muscle memory. 0–2 min: “URL shortener: create short codes, redirect, optional alias/expiry/analytics. MVP US-only, p99 redirect under 100ms, durable mapping, ~100:1 read:write.” 2–4 min: capacity — creates modest, redirects dominate; 7-char base62 space is huge; storage is not the bottleneck; cache is. 4–6 min: API + links table + async clicks. 6–9 min: ID strategy tradeoff table; pick counter ranges or Snowflake; custom aliases separate. 9–11 min: redirect path cache-aside + single-flight; analytics Kafka. 11–12 min: hot viral code, abuse, multi-region evolution, cost of Redis vs over-sharding. Close with what you rejected: Cassandra day one, UUID in the URL, sync click counters.
code
1SHORTENER DECISION LOG (write on board)2Assumption              Number (heuristic)     Decision3---------------------  ---------------------  --------------------------4Creates/day            3e5                    single primary OK early5Redirects/day          5e7                    cache-aside mandatory6Peak redirect RPS      ~3k (×5)               few app nodes + Redis7Key length             base62×7               decades of headroom8Viral code share       10–50% of QPS          CDN + local cache9Analytics volume       = redirects            async only; never 302 path10Custom alias rate      low but abusive        policy + unique + reserved1112Rejected: UUID short links; sync malware scan on redirect; global lock IDs.
When the interviewer escalates to bit.ly-class traffic (secondary-cited multi-billion redirects/day — label heuristic/reported-elsewhere), recompute: average RPS jumps to 1e5 class, peak higher, metadata shards appear, edge becomes non-optional, and click events land in a warehouse path (Kafka → object store / OLAP). The framework is identical; only the THEREFORE clauses change. That adaptability is the senior skill.

URL shortener math + decision card

code
1MODERATE PRODUCT (heuristic)2creates/day 3e5 → write QPS ~3–4 avg, ~20 peak3redirects/day 5e7 → read QPS ~600 avg, ~3k peak4row size ~200B → storage tens of GB/year — single DB OK early5base62 len 7 → 62^7 ≈ 3.5e12 codes — space not the bottleneck6THEREFORE: cache redirects; keep metadata SQL/primary simple; async clicks78BITLY-CLASS ESCALATION (secondary/heuristic — label it)9redirects/day 1e10 → ~1e5 RPS avg → peak much higher10cache hit 99% still leaves large origin QPS without edge11metadata shards + multi-node cache + CDN required12analytics warehouse path mandatory1314ID PICKER15low write + simple → random base62 + unique retry16high write multi-DC → Snowflake / range counters17custom alias → separate uniqueness + reserved words1819HOT PATH RULES201) 302 decision uses cache/DB only212) enqueue click never blocks response223) single-flight on hot miss234) disable flag for malware must reach edge fast245) 301 only if permanent + purge story exists2526FAILURE DRILLS27cache stampede, counter service down, alias race 409,28analytics lag, DB outage serve hot from cache, abuse flood

Could you drive a full shortener design: requirements, capacity→ID/storage choice, write/read paths, hot key, async analytics?

New to itGetting thereConfident

Takeaways

  • Estimation either kills over-engineering or forces edge cache — depending on numbers.
  • Base62 + counter ranges or random+unique; customs are a policy path.
  • 302 path: cache-aside, expiry, async clicks; never inline viral counters.
  • Idempotent creates; unique codes; hot alias plan.
  • Label bit.ly-class figures as reported/heuristic when you cite them.

Next: newsfeed at scale — fan-out-on-write vs pull, celebrities, ranking, and cost per render.

Sources

Free to read · better with Enzo

Learn it with Enzo

Save your progress, answer the checkpoints, and let Enzo quiz you on what you just read.