DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on Originally published at prepstack.co.in

Design a URL Shortener (bit.ly) — The Complete System Design Walkthrough (with Production .NET Code)

"Design a URL shortener" is the question almost every system design interview opens with — and that's exactly why it's worth nailing. It looks trivial ("it's just a hashmap"), but the follow-ups are where candidates fall apart: how do you generate a short code with no collisions at a billion URLs? How do you serve 120,000 redirects a second without melting the database? 301 or 302?

This is the condensed walkthrough; the full guide (every step, all three key-generation approaches, the Kafka analytics path, and the full production .NET 9 code) is on my site 👇

Full guide: https://prepstack.co.in/blog/design-a-url-shortener-system-design

The design at a glance

Concern Decision
Short code 7-char base62 → ~3.5 trillion combinations
Key generation Key Generation Service (KGS) — pre-generate unique keys, hand them out
Storage Key-value store (shortCode → longUrl), sharded by shortCode
Read path Cache-first (Redis) — 100:1 read:write means the cache does the work
Redirect 301 for performance, 302 if you need per-click analytics
Scale ~1,200 writes/sec, ~120,000 redirects/sec, ~90 TB over 5 years

Estimates decide everything

100M new URLs/day, 100:1 read:write:

Writes:  100,000,000 / 86,400 ≈ 1,160/sec   (~1.2k/sec — trivial)
Reads:   100x writes          ≈ 116,000/sec  (~120k/sec)
Peak (≈2–3x)                  ≈ ~300k/sec
Storage (5 yr): ~180B URLs × ~500 B ≈ ~90 TB
Enter fullscreen mode Exit fullscreen mode

Two takeaways: writes are trivial, and ~120k reads/sec is what forces caching and replication. The redirect path must almost never touch disk.

Why 7 chars? 62^6 ≈ 57B (too few for ~180B URLs); 62^7 ≈ 3.5 trillion (plenty). So 7 it is.

The heart of the problem: generating the short code

Approach A — hash the URL. Truncate MD5/SHA-256 to 7 base62 chars. Problem: truncation will collide, forcing a DB check + re-hash on every write (stateful, slow), and identical URLs collide to the same code. Avoid.

Approach B — counter + base62. Each URL gets the next integer, base62-encoded. Zero collisions by construction, but a single counter is a bottleneck + SPOF, and sequential codes are guessable. Fix the bottleneck by handing out ranges (a coordinator gives each server a block of 10,000).

Approach C — Key Generation Service (what to ship). Pre-generate unique 7-char keys offline into a pool split into unused/used. On a write, grab an unused key and mark it used — O(1), no hashing, no collision check on the request path. Each server checks out a block into memory so it rarely hits the KGS. Run a standby replica; losing an in-memory block is harmless (3.5T keyspace).

The read path is cache-first

Client → LB → stateless app servers
                 │
           [ Redis ] ── hit (~90%+) ──▶ 301/302
                 │ miss
                 ▼
           [ KV store ] ─▶ populate cache ─▶ redirect
Enter fullscreen mode Exit fullscreen mode

Link popularity is heavily skewed, so an LRU Redis cache holding the hot links absorbs the vast majority of the 120k reads/sec. Shard the KV store by shortCode — it's uniformly distributed, so no hot shards.

301 vs 302 — a real trade-off

301 (permanent) lets the browser cache the redirect: subsequent clicks skip your server entirely (fast, cheap, but you lose per-click analytics). 302 (temporary) routes every click through you: full analytics and a changeable target, at the cost of more load. Analytics-driven shorteners lean 302.

Tracking clicks without slowing the redirect

If you chose 302 and analytics matter, never write to an analytics DB on the redirect path. Fire-and-forget onto a queue and return immediately:

GET /{shortCode}
  → look up longUrl (cache)   ~1–2ms
  → emit click event to Kafka  fire-and-forget
  → 302 redirect               returned immediately
        [ Kafka ] → [ workers ] → [ analytics store ]
Enter fullscreen mode Exit fullscreen mode

I shipped this in production

Our report-sharing feature first embedded a 36-char sequential-GUID primary key in the share URL — long, ugly, enumerable, and a SELECT against Azure SQL on every open. Rebuilt as exactly this design (KGS handing out 7-char base62 codes by the block, cache-first .NET 9 endpoint):

Metric Before After
Share-link format 36-char sequential GUID 7-char base62
Guessability Enumerable Crypto-random, ~3.5T keyspace
Redirect data source Azure SQL every open Redis cache-first, SQL only on miss
Cache hit rate none ~97%
Redirect p95 ~180 ms ~5 ms

The concurrency story is one SQL statement — UPDATE TOP (@blockSize) ... OUTPUT inserted.Code WITH (UPDLOCK, READPAST) — so many app servers check out disjoint blocks with no distributed lock, then serve each block from an in-memory queue. (Full KeyGenerationService + cache-first minimal-API redirect code is in the post.)

The model to carry forward

A URL shortener is a key-generation problem stapled to a caching problem. Three habits it teaches: (1) let the read:write ratio pick your architecture — 100:1 means "cache," full stop; (2) generate uniqueness ahead of time — pre-computing keys turns a collision problem into an O(1) hand-out; (3) name the 301 vs 302 trade-off.

The full guide has all three approaches in depth, the base62 code, custom-alias handling, the capacity table, the design checklist, the full production .NET 9 implementation, and the "when this is overkill" honest section:

https://prepstack.co.in/blog/design-a-url-shortener-system-design

Originally published on PrepStack.

Top comments (0)