SkillPilotAI

Case Study 2: URL Shortener (bit.ly / TinyURL)

Turn a long URL into a 7-character short link, redirect in milliseconds, and show analytics for every click.

C.1 Requirements

Functional requirements:

  • Give a long URL, get a short URL (for example sho.rt/aB3xK9p).
  • Opening the short URL redirects the user to the original long URL.
  • Custom alias option: the user picks their own name (sho.rt/diwali-sale) if it is free.
  • Expiry: a link can expire after a date; the user can delete/disable the link.
  • Analytics: total clicks, unique clicks, country, device, referrer, time-wise graph.
  • Optional dedup for the same user and the same long URL, so a new code is not created again and again.

Non-functional requirements:

  • Redirect latency p99 < 50 ms (the user must not feel any delay).
  • Availability 99.99% for redirects; the create API can be slightly less critical.
  • Read-heavy: read:write ratio ~100:1.
  • The short code must be unique (never a collision) and must not look guessable/sequential.
  • Scale: 10 crore (100 million) new URLs/month, store 5 years of data, analytics eventually consistent (a few seconds of delay is OK).

C.2 Estimation (back-of-the-envelope)

ItemNumberHow it is derived
New URLs (writes)~40/sec avg10 crore/month / (30 x 86,400) = ~38/sec; peak 5x = ~200/sec.
Redirects (reads)~4,000/sec avg, 20K peak100:1 ratio; 40 x 100 = 4,000 QPS; peak 5x = 20K QPS.
Total URLs in 5 years600 crore10 crore x 12 x 5 = 600 crore (6 billion) records.
Code length7 chars base6262^6 = ~5,680 crore (56.8 billion), 62^7 = ~3.5 lakh crore (3.5 trillion); 7 chars leave plenty of headroom.
Storage~3 TB (5 years)One record is ~500 bytes (code, long URL, user, dates); 600 crore x 500 B = 3 TB, with 3x replication = 9 TB.
Cache size~35 GB RedisAbout 35 crore redirects per day; 80-20 rule: 20% hot = 7 crore x 500 B = 35 GB.
Analytics events~35 crore/dayEach click is one event of ~200 B; 35 crore x 200 B = 70 GB/day of raw clicks.

C.3 API design

EndpointPurpose
POST /v1/urlsBody {long_url, custom_alias?, expires_at?}; response {short_url}; auth and rate limit via API key
GET /{code}302 redirect to the long URL (Location header); 404 if not found, 410 if expired
DELETE /v1/urls/{code}The owner disables the link
GET /v1/urls/{code}/stats?from=&to=Breakdown of clicks by country, device, referrer

C.4 Data model

Table / StoreFieldsWhy
urls (Cassandra/DynamoDB, partition key = code)code PK, long_url, user_id, created_at, expires_at, is_activeSimple key-value lookup, billions of rows, easy horizontal scaling.
users (MySQL)user_id PK, email, api_key, plan, created_atSmall data, relational, used for billing/plan.
user_urls (Cassandra)user_id PK, created_at CK, codeA separate table to list all of a user's links (query-first design).
click_events (Kafka -> ClickHouse)code, ts, ip_country, device, browser, referrerA columnar OLAP DB makes aggregation queries (group by country/day) fast.
id_ranges (ZooKeeper/etcd)range_start, range_end, assigned_to_serverGive every app server a unique ID range, avoiding collisions.

C.5 High-level architecture

URL Shortener: architecture
  1. 1.Clients: Browser, Mobile App, API Client (POST /v1/urls), Stats dashboard
  2. 2.Edge: DNS (nearest edge) and CDN / Edge (sho.rt/aB3xK9p)
  3. 3.Load Balancer + API Gateway; Rate Limiter: per API key create limit, IP limit against spam / scanning
  4. 4.Write path: Shortener Service (validate + blacklist) -> ID Generator (in-memory counter, no DB hit; 1 lakh range from ZooKeeper / etcd id_ranges) -> Cassandra (urls PK = code, user_urls)
  5. 5.Read path: Redirect Service (302 Location) -> Bloom Filter (code exists?) -> Redis Cache (hot codes, LRU, 35 GB) -> on miss Cassandra
  6. 6.Analytics: Kafka topic clicks (partition = code) -> Click Aggregator (GeoIP country, device, HLL unique, per-minute counts) -> ClickHouse click_events OLAP -> Analytics API (GET /stats)
  7. 7.Housekeeping: the Expiry Cleanup Job removes expired / disabled links; MySQL holds users, api_key, plan

Write: ~40/sec, peak 200. Redirect: 20K QPS peak, p99 < 50 ms, 95%+ Redis hit, click goes async to Kafka.

ComponentWhat it does
Shortener ServiceValidates the long URL, takes a unique ID, builds the base62 code and saves it in Cassandra.
ID GeneratorZooKeeper gives every server a range of 1 lakh IDs; the server increments a counter in memory, no DB hit.
Redirect ServiceFinds the long URL from the code (Redis first, Cassandra on a miss) and sends a 302.
Redis CacheMapping of hot codes with LRU eviction; 95%+ of redirects are served from here.
Bloom FilterStops non-existent codes from reaching the DB (protection against random code scanning attacks).
KafkaEvery click event is published async; the redirect path does not slow down.
Click AggregatorReads from Kafka and writes per-minute/hour counts to ClickHouse.
Rate LimiterA per-API-key create limit at the API Gateway to prevent spam/abuse.

C.6 Main flows

Short URL creation flow:

  1. The client sends POST /v1/urls {long_url}; the LB hands the request to Shortener Service.
  2. The service validates the URL format and checks the malware/phishing blacklist.
  3. If there is a custom alias, do a conditional insert in Cassandra (IF NOT EXISTS); if it is taken, return a 409 error.
  4. Otherwise the server takes the next number from its local ID range (for example 125000001).
  5. The number is converted to base62 to make the code; to avoid a sequential look, the ID can first go through a bijective shuffle/XOR.
  6. The record {code, long_url, user_id, expires_at} is saved in Cassandra and can also be put in Redis.
  7. The response returns short_url = https://sho.rt/{code}.

Redirect flow:

  1. The user opens sho.rt/aB3xK9p; DNS routes the request to the nearest LB/edge.
  2. Redirect Service checks the Bloom filter first; if the code does not exist, return 404 immediately.
  3. Look up the code in Redis; on a hit the long URL is found (~1 ms).
  4. On a miss, read from Cassandra, then set it in Redis with a TTL (cache-aside).
  5. If expired or disabled, 410 Gone; otherwise a 302 response with Location: long_url.
  6. After the response is sent, the click event (code, IP, user-agent, referrer) is published to Kafka fire-and-forget.
  7. The browser goes to the long URL; the analytics pipeline processes the event async.

Analytics flow:

  1. The Kafka topic "clicks" is partitioned by code.
  2. The Click Aggregator consumes events and derives the country from the IP (GeoIP) and the device from the user-agent.
  3. Per-code per-minute counts are inserted into ClickHouse in batches.
  4. Use HyperLogLog for unique clicks (an approximate unique count in little memory).
  5. The Stats API runs a group-by query on ClickHouse and gives the data to the dashboard.

C.7 Deep dives

Unique code: ZooKeeper ID range, shuffle, base62 (7 chars)
  1. 1.ZooKeeper gives every server a range of 1 lakh IDs: server A 125000001-125100000, server B 125100001-125200000
  2. 2.The server does Counter++ in memory: 125000001
  3. 3.Feistel / XOR shuffle with a secret: 125000001 -> 601951641863
  4. 4.Base62 (0-9 a-z A-Z, repeated % 62): digits 10, 37, 3, 33, 46, 9, 25 = a B 3 x K 9 p -> sho.rt/aB3xK9p

Benefits: no coordination on every request, no MD5-style collisions, and wasting the remaining range after a server crash is fine. 62^7 = ~3.5 lakh crore codes; only 600 crore are needed in 5 years. Without the shuffle, 125000001 -> "8sud3": sequential, guessable.

Redirect decision: GET /aB3xK9p
  1. 1.User clicks sho.rt/aB3xK9p -> Bloom filter: code exists? No -> 404 immediately, no DB
  2. 2.Yes -> Redis lookup: hit (~1 ms) -> 302 Location: long_url
  3. 3.Miss -> Cassandra read, then Redis SET; expired / disabled -> 410 Gone
  4. 4.After the response, the click event goes to Kafka (code, IP, user-agent, referrer): fire-and-forget, the redirect is never blocked

301: the browser caches it, less load but clicks are missed. 302 chosen: every click is counted.

Analytics pipeline and viral link
  1. 1.Kafka topic clicks, partitioned by code
  2. 2.Click Aggregator: IP -> country (GeoIP), UA -> device, HyperLogLog = unique clicks
  3. 3.ClickHouse: per-code per-minute counts, batch insert
  4. 4.Stats API -> dashboard group-by
  5. 5.Viral link = hot key: local in-memory LRU (a few seconds TTL) + Redis read replicas

Problem: how to generate a unique short code (no collisions). A hash (first 7 chars of MD5) can collide and needs a retry. Better: counter-based ID + base62. Ranges are allocated by ZooKeeper, so servers do not coordinate on every request.

Problem: sequential codes are guessable (privacy). Before base62-encoding the counter ID, apply a reversible permutation (Feistel cipher/XOR with a secret); the code looks random but stays unique.

Problem: 301 vs 302 redirect. 301 is permanent and the browser caches it, so server load is lower but analytics will miss clicks. If you need analytics, use 302 (or 301 with a short cache).

Problem: hot key on a viral link. If one link gets lakhs of clicks/sec, a single Redis node becomes hot. Add a local in-memory LRU cache (a few seconds TTL) in the Redirect Service and Redis read replicas.

Problem: random code scanning / abuse. The Bloom filter keeps fake codes away from the DB; add IP-based rate limiting and a Safe Browsing-style check for malicious long URLs at create time. Note: a Bloom filter can give false positives (but never false negatives), and custom aliases must be added to the filter too.

Problem: analytics must not slow the redirect. Click logging is async on Kafka; if Kafka is slow, use a local buffer and a drop policy, so the redirect is never blocked. Analytics is eventually consistent.

C.8 Trade-offs

DecisionWhat was chosen and why
Hash-based vs counter-based IDChose counter + base62: no collisions, fixed length. A hash helps with dedup but needs collision handling.
SQL vs NoSQLCassandra/DynamoDB: simple key lookup, billions of rows, easy sharding. No joins are needed, so SQL offers little benefit.
301 vs 302Chose 302 because analytics is a core feature; accepted slightly more server load.
Dedup same long URL ya nahiA new code on every request (simpler, per-user analytics stay separate). Dedup needs an extra index, so it is kept optional and per-user only.

C.9 Interview tips

  • Show the base62 math: 62^7 = ~3.5 trillion, so 7 chars are enough.
  • Make the read path (redirect) super fast and cache-first; the write path QPS is very low.
  • Compare ID generation options: hash, DB auto-increment, Snowflake, ZooKeeper ranges.
  • Mentioning the 301 vs 302 and analytics trade-off is a strong signal.
  • Also mention abuse (spam, phishing links) and expiry cleanup.