Index/URL Shortener

SponsorGitHub
Design13 min

URL Shortener

A hundred million links a day and a hundred to one read skew. The classic estimation warm-up.

Primary concepts and the hard part

Concepts
unique ID generationbase62 encodingextreme read:write skewcaching strategykey-value storageCDN/redirect semanticsanalytics pipeline
The hard part they’re probing
Generating short, unique, non-guessable keys without a central bottleneck — and recognizing that this is a cache problem, not a database problem. It's also the classic estimation warm-up, so sloppy math is penalized heavily here.

Requirements

Functionalwhat it must do
Shorten a long URL → short code
Redirect short code → original URL
Optional custom alias
Optional expiry
Basic click analytics
Non-functionalhow well it must do it
Redirect p99 < 50ms (it's in the critical path of a page load)
Very high availability — a dead shortener breaks every link ever shared
Codes must not be predictable (enumerable codes leak private links)
Redirects vastly outnumber creations
Out of scopedeliberately left out
User accounts
Link editing
Malware scanning (mention it belongs)
Scale
Creates:  100M/day  → 100M / 10^5 = 1,000/sec   (peak 3x = 3,000/sec)
Reads:    100:1 ratio → 100,000/sec
Storage:  ~500B/record × 100M/day = 50 GB/day → 18 TB/yr → ~90 TB over 5 yrs
Bandwidth: 100k/sec × 500B = 50 MB/sec
Key space: base62^7 = 3.5 × 10^12  → 100M/day for ~95 years. 7 chars is enough.
Conclusion
(a) 3,000 writes/sec fits one tuned Postgres — don't shard on day one, but design a shardable key. (b) 90 TB doesn't fit one box, so plan for partitioning. (c) The 100:1 ratio is the headline: this is a caching problem. A cache holding the hot 20% of links serves ~95% of traffic.

API / Model

POST
/v1/urls
{long_url, custom_alias?, expires_at?}
201Created{short_url}
GET
/{code}
410 Gone once the link has expired
302Found410Goneredirect to long_url
GET
/v1/urls/{code}/stats
200OKclick analytics
primary or partition keysort keyforeign key → referenced columnnullableHover a table or column to trace its keys

Note the partition key: short_code is effectively random (base62 of a hashed/encoded counter), so it distributes perfectly with no hot-partition risk. That's a rare gift — say so.


High-level architecture

DatabaseCacheQueue / streamAPI gatewayLoad balancerClick a node for details

The shortener is two paths joined by one Redis cluster: a write path at about 1k requests per second that creates links, and a read path at about 100k that redirects them. A separate analytics pipeline hangs off the redirect.

  1. A client's POST /v1/urls passes the API Gateway, which applies auth and an abuse rate limit, and reaches the Shorten Service.
  2. ID generation takes the next number from a range of 10,000 that the ticket server handed out, scrambles it, and encodes it as a base62 short_code.
  3. The urls store saves the row with a conditional write on short_code, and write-through copies the mapping from code to long_url into the Redis cluster.
  4. Later, a browser's GET /{code} goes through the Load balancer to the Redirect Service, which is stateless and autoscaled and looks the code up in the Redis cluster.
  5. About 95% of lookups hit and return 302 Found straight away. The other 5% read the urls store read replicas, populate the cache, and then return the 302.
  6. The browser follows the redirect to the destination site.

Every 302 also fires a click event to Kafka and returns without waiting for it. A stream processor groups those events into tumbling windows and writes to two places: clicks_agg, which serves the stats API, and the data warehouse, which keeps raw analytics.


Trade-offs and deep dives

01
ID generation: three approaches, pick one and defend it
ApproachHowWhy / why not
Hash the URL (MD5 → take 7 chars)deterministic, no coordinationCollisions are inevitable at 10^11 records; requires collision check + retry loop, and identical URLs map to the same code (sometimes desired, sometimes a privacy leak)
Counter + base62central or ranged counter, encodeNo collisions ever, shortest possible codes. But sequential IDs are enumerable — scrape every link by counting up
Snowflake + base6264-bit local IDNo coordination, no collisions, but 64 bits → 11 base62 chars, longer than needed

The pragmatic answer: ticket-server ranges (each app node pre-fetches 10,000 IDs and increments locally, so coordination happens once per 10,000 creates, not per create), then scramble the counter before encoding — XOR with a secret, or apply a Feistel permutation — so codes are unguessable while collisions remain impossible. Gaps from unused ranges when a node dies are harmless.

02
Why base62

[0-9a-zA-Z] = 62 symbols, all URL-safe, no escaping. 62^7 ≈ 3.5 trillion. Consider excluding visually ambiguous characters (0/O, 1/l/I) if links are ever typed by hand — that drops you to base58 and you should mention the trade.

03
301 vs 302 — a real question, not trivia
  • 301 Permanent: browsers cache it aggressively, so repeat clicks never touch your servers. Great for load, but you lose all analytics after the first click, and you can never change the destination.
  • 302 Found: every click hits you. You keep analytics and can update or expire links.

Choose 302 for an analytics product. Say why.

04
Should the CDN cache redirects?

Only with short TTLs, and only if you accept losing per-click analytics for cached hits. Most shorteners skip CDN caching of the redirect itself for exactly this reason — the analytics are the product. This is a nice place to show that a technically-better-performing option can be the wrong product choice.

05
Analytics must never be synchronous

Fire the click event to Kafka and return the 302 immediately. The user is waiting on a page load; they must not wait on an analytics write. If Kafka is down, drop the event and serve the redirect — availability of the redirect outranks completeness of analytics. That's a deliberate, statable priority.

06
Caching strategy

Cache-aside with LRU + TTL. Write-through on create so a freshly-created link is warm (people click their own link immediately after shortening). Hit rate is the primary metric; it should sit above 90%.

07
Custom aliases

are the one place you need strong consistency: two users must not both claim /sale. Use a conditional write (IF NOT EXISTS) rather than read-then-write, which races.

08
Deletion and expiry

Don't scan for expired rows. Set a TTL on the storage row (Cassandra/DynamoDB do this natively) and check expires_at at read time. Return 410 Gone rather than 404 so it's distinguishable.


Possible follow-up questions

6 questions·try answering before you reveal