shreyansh

URL Shortener

30-Second Glance

  • What: TinyURL — shorten long URLs to 7-char keys, redirect in <50 ms p99, custom aliases + TTL.
  • Scale: ~100M new URLs/month (~40 writes/s, ~400 reads/s avg); ~6 TB for 10 years of mappings.
  • Core trick: Pre-generated keys (KGS) + base62 encoding; shard DB by hash of short key; cache the hot 20%.
  • Hardest part: Key generation without collisions or a single-point bottleneck; 301 vs 302 semantics; click analytics without a DB write per click.

Interactive playground Steppable request flow with moving packets, capacity sliders and failure toggles: Open URL Shortener simulator →

1. Requirements

Functional:

  1. shorten(longURL, customAlias?, ttl?) → short URL.
  2. redirect(shortKey) → HTTP redirect to long URL.
  3. Custom aliases (first-come-first-served, validation + reservation).
  4. Optional expiry (TTL); expired keys return 404/410.
  5. Basic analytics: click count + rough geo/device breakdown per key.
  6. Rate-limited public API with per-user quota.

Non-functional:

Requirement Target
Read (redirect) latency p99 < 50 ms (cache hit < 10 ms)
Write (shorten) latency p99 < 300 ms
Availability 99.9% reads; writes may degrade before reads do
Consistency Eventual is fine — a new key visible within ~1 s; redirects must never go to the wrong URL
Durability Mappings never lost (DB replicated, daily backups)

Out of scope:

  • Full web-analytics suite (no funnels, no real-time dashboards).
  • Malware/phishing scanning beyond a pluggable hook.
  • URL preview pages.

2. Capacity Estimation

Assumptions: 100M new URLs/month; read:write ≈ 10:1; avg stored record ≈ 500 B (keys, URL ~200 B avg, timestamps, counters); retain 10 years; peak = 5× avg.

Metric Math Result
Write QPS (avg / peak) 100M / (30 × 86400) ≈ 38.6 ~40 / ~200
Read QPS (avg / peak) 10 × writes ~400 / ~2,000
Storage total (10 y) 100M × 12 × 10 × 500 B = 6×10¹² B ~6 TB (+ replicas/indexes ≈ 15–20 TB raw)
Bandwidth (redirect) 2,000 × (1 KB req + 1 KB resp) peak ~4 MB/s — negligible; this system is storage + QPS shaped, not bandwidth shaped
Keyspace (7-char base62) 62⁷ ≈ 3.5 × 10¹² 12B keys in 10 y ≈ 0.3% utilisation — 7 chars lasts decades; grow to 8 on approach

3. API Design

POST /api/v1/shorten
Content-Type: application/json
Idempotency-Key: <uuid>

{ "long_url": "https://example.com/a/very/long/path?q=1",
  "custom_alias": "my-link",      // optional
  "ttl_seconds": 2592000 }        // optional

201 Created
{ "short_url": "https://t.ly/aZ3kP9q",
  "short_key": "aZ3kP9q",
  "expires_at": "2026-10-15T00:00:00Z" }
Endpoint Purpose Notes
POST /api/v1/shorten Create mapping Idempotency-Key → same key on retry; 409 on alias taken; 422 on invalid URL
GET /{shortKey} Redirect 301 (permanent, cacheable) or 302 (expiring/tracked); 404 unknown, 410 expired
GET /api/v1/urls/{shortKey}/stats Click stats Authenticated owner only; served from analytics store, not hot path
DELETE /api/v1/urls/{shortKey} Owner delete / admin takedown Tombstone; CDN/cache purge by key
  • Same (longURL, user) re-shortened → return existing key (hash-lookup) instead of minting duplicates.
  • Pagination/cursors unnecessary (single-key lookups only).

4. Data Model & Storage

Entity Store Partition / shard key Why this store
URL mapping (short_key → long_url, owner, expires_at) MySQL / Postgres (sharded) hash(short_key) Structured, needs uniqueness + TTL scans; hash shards spread load evenly (no hot shard)
Hot-key cache Redis (LRU) short_key 20% keys serve ~80% redirects; p99 < 10 ms
Click events / stats Kafka → ClickHouse / Cassandra (short_key, day) Write-heavy counters; never on redirect critical path
KGS key pool Dedicated MySQL table (tiny) auto-increment ranges Single source of pre-generated numeric IDs (see §6.1)
CREATE TABLE url_mapping (
  id          BIGINT PRIMARY KEY,          -- from KGS, base62(id) = short_key source
  short_key   CHAR(8) NOT NULL UNIQUE,     -- hash-shard key; indexed
  long_url    TEXT NOT NULL,
  long_hash   CHAR(40) NOT NULL,           -- SHA-1(long_url) for dedup lookup, indexed
  owner_id    BIGINT NULL,
  created_at  TIMESTAMP NOT NULL,
  expires_at  TIMESTAMP NULL,              -- NULL = permanent (301-eligible)
  INDEX (expires_at)                       -- janitor sweep for expiry
) ENGINE=InnoDB;
  • Shard by hash(short_key) mod N — uniform, and lookups are always point queries so no range-scan penalty. Rebalance via Rebalancing Partitions (consistent hashing when N grows).
  • Authoritative store is the RDBMS with Single Leader Replication per shard; Redis is pure cache (cache-aside).

5. High-Level Design

Write path: client → LB → API servers → (dedup check by long_hash) → KGS range allocator → insert into hash-shard → warm cache. Read path: client → CDN/edge (301s only) → LB → API → Redis → DB shard on miss → async click event to Kafka.

loading diagram…
  • Load balancer / gateway: TLS termination, per-IP throttling, routes 301-cacheable traffic hints.
  • App tier (stateless): shorten + redirect logic; holds an in-memory KGS range (see §6.1).
  • KGS (Key Generation Service): hands out disjoint numeric ranges from an auto-increment table; base62-encoded into short keys.
  • Cache: Redis LRU, TTL = min(key TTL, 24 h); negative caching for 404s (60 s) to absorb scan attacks.
  • Primary store: hash-sharded MySQL; each shard single-leader + 2 replicas.
  • Async path: redirect emits fire-and-forget click event → Kafka → Flink/Spark → ClickHouse; counters never block redirects.

6. Deep Dives

6.1 Key generation: counter vs hash vs KGS

Approach Pros Cons Use when
DB auto-increment → base62 Trivially unique, short keys Single writer = bottleneck + SPOF; leaks creation order/count Toy scale only
Hash(longURL), e.g. MD5 → 7 chars Stateless, natural dedup Collisions need retry-with-salt loop; adversarial collision grinding; custom aliases don't fit Content-addressed stores, not here
KGS (chosen): pre-generated ranges No per-request coordination; each app host mints locally; keys opaque-ish Wasted keys on crash (acceptable); needs standby KGS This design

KGS detail: one tiny MySQL table with next_id; each app host requests a range (e.g. 10k IDs) via UPDATE kgs SET next_id = next_id + 10000 (atomic) and mints from memory until the range is 80% consumed, then prefetches. At ~200 keys/s peak, a 10k range lasts ~50 s per host — DB load is ~1 QPS. Run active-standby KGS behind a lock from Coordination Services (ZooKeeper/etcd lease); standby takes over the same table, so no ID reuse. Unused IDs on crash are simply skipped — with 3.5T of keyspace, waste is irrelevant.

6.2 301 vs 302, TTL and custom aliases

  • 301 Permanent: browser + CDN cache it → kills repeat origin traffic. Only for expires_at IS NULL and analytics-off links. Downside: owner can't change analytics/expiry later; bad actors harder to revoke (need cache purge + key tombstone).
  • 302 Found: every click hits origin → enables counting + expiry enforcement. Cost: full read QPS. Default for TTL links and tracked links.
  • Rule: permanent + untracked → 301; anything with TTL/analytics/owner-editable → 302. Say this sentence in the interview.
  • Custom aliases: separate reservation path — INSERT ... ON CONFLICT DO NOTHING on short_key; 409 if taken. Validate charset/length + reserved-word blocklist (api, admin, stats). Alias claims bypass KGS.

6.3 Cache + analytics without per-click DB writes

  • Redis holds {short_key → (long_url, expires_at, redirect_type)}; cache-aside with TTL jitter to avoid stampedes; singleflight (request coalescing) on miss so a viral key causes 1 DB read, not 2,000.
  • Clicks: redirect path does kafka.send(click) fire-and-forget with a bounded in-memory drop counter (shed load before slowing redirects). Stream job aggregates per (key, minute) → ClickHouse; owner stats API reads ClickHouse. click_count in MySQL updated hourly by batch — never per click.
  • Expiry janitor: hourly scan on expires_at index → tombstone + async CDN/cache purge; 410 for 7 days, then 404.

7. Bottlenecks, Trade-offs & Monitoring

First bottleneck at 10x: a single Redis (hot-key skew on viral links) → Redis Cluster with replicas + client-side local LRU (Caffeine, 10k entries) in front; next is KGS range-DB failover time → prefetch earlier (50% watermark) + longer ranges.

Approach Pros Cons Use when
Counter + base62 Simple, shortest keys SPOF, order leaks Prototype
Hash-based keys Stateless, dedup free Collision loop, grinding Dedup-dominated workloads
KGS ranges (chosen) Coordination-free hot path, opaque keys Tiny stateful service to HA-harden Production shortener
301 everywhere Minimal origin load No analytics/expiry Permanent links
302 for tracked/TTL (chosen) Counting + revocation Full origin QPS Default
Metric Alert if
Redirect p99 > 100 ms for 5 min
Cache hit ratio < 70% for 15 min (hot-key or eviction problem)
KGS range exhaustion rate any host < 5 s of IDs remaining
Kafka click-lag > 5 min (analytics staleness)
404 rate spike > 3× baseline (scan attack or bad deploy)

Interview one-liners

  • Q: Why not auto-increment in MySQL directly? / A: Single-writer bottleneck and SPOF at scale; KGS amortises coordination to one DB op per 10k keys.
  • Q: 301 or 302? / A: 301 only for permanent untracked links (cacheable); everything with TTL or analytics is 302 so origin enforces and counts.
  • Q: How do you shard? / A: Hash of short_key — point lookups only, so hash sharding gives perfect spread with no range-scan cost.

try it live

Steppable request flow, capacity sliders and failure toggles — open the interactive simulator →