Case Study: URL Shortener

Applying the System Design Approach end to end, against a single concrete problem, is more useful than reading the framework in the abstract — this case study walks all five steps for "design a URL shortener" (like bit.ly or TinyURL).

Step 1: Requirements

Functional: given a long URL, return a short one; given a short URL, redirect to the original. (Extensions like custom aliases, link expiry, and click analytics change the design — worth explicitly asking whether they're in scope, but this case study covers the core two operations.)

Non-functional: high availability (a broken shortener breaks every link that uses it, everywhere); low-latency redirects (the redirect is on the critical path of someone else's page load); uniqueness (two long URLs must never collide onto the same short code, and the same long URL shortened twice can either return the same code or a new one — worth clarifying, but not load-bearing for the design).

Step 2: Capacity estimation

Reusing the worked numbers from Capacity Estimation: ~77 writes/second peak, ~7,720 reads/second peak, ~9TB storage over 5 years. The read:write ratio (100:1) is the single most important number here — it means the design should optimize heavily for fast reads, and caching redirects will handle the large majority of traffic.

Step 3: High-level design

graph LR client["Client"] --> lb["Load Balancer"] lb --> api["API servers (stateless)"] api -->|"GET /{code}: check cache first"| cache["Cache (hot codes)"] cache -.->|"miss"| db["Database<br/>(code -> long URL)"] api -->|"POST /shorten"| idgen["ID Generator"] idgen --> db

POST /shorten (create a short code) and GET /{code} (redirect) are the two API endpoints, matching the two functional requirements directly. The read path checks the cache first — given the 100:1 read-heavy ratio, this is where most traffic is absorbed before ever reaching the database.

Step 4: Deep dive — generating the short code

This is the interesting part of this specific problem: how does POST /shorten produce a short, unique code?

Option A — random string + collision check: generate a random 6-character base62 string ([a-zA-Z0-9], 62^6 ≈ 56 billion possible codes), check if it already exists in the database, retry on collision. Simple, but every write now costs a read to check for collision, and collision probability rises as the keyspace fills — at billions of stored URLs, retries become more frequent.

Option B — base62-encode an auto-incrementing ID: a distributed counter (or a centrally-issued range of IDs per server, to avoid one counter becoming a bottleneck) produces a unique integer; base62-encode it (123456 → 4 base62 characters). No collision check ever needed — uniqueness is guaranteed by the counter itself, not probabilistically. The trade-off: a distributed counter needs coordination (a service like Zookeeper handing out ID ranges to each server, or a database sequence), and sequential codes are guessable/enumerable, which may or may not matter depending on whether short codes are meant to be unguessable.

graph TD id["Counter: 125"] -->|"base62 encode"| code["'21' (base62 of 125)"]

Base62 encoding: 125 in base62 — 125 = 2*62 + 1 → digits [2, 1] → characters 2 and 1 (using 0-9a-zA-Z as the 62-symbol alphabet) → short code "21". The same math as any base conversion, just with a 62-symbol alphabet instead of 10 or 16, chosen specifically because it packs more information per character than base10 or base16, keeping codes short.

Which to pick: Option B is generally preferred at scale specifically because the read:write ratio computed in step 2 is so read-heavy — Option A's extra read-per-write cost is exactly the wrong trade for a system that's overwhelmingly reads already; adding cost to the already-rare write path to simplify it is a reasonable trade only if the write path weren't already cheap enough to not need simplifying.

Step 4 (continued): caching strategy

Given ~7,720 reads/second peak and a read-heavy access pattern, a cache-aside strategy in front of the database is the direct application of that earlier page's content: on GET /{code}, check the cache; on a hit, redirect immediately; on a miss, query the database, populate the cache, then redirect. Because URL popularity is famously skewed (a small fraction of shortened URLs account for the large majority of redirects — a link shared on social media vs. one shared in a private message), an LRU eviction policy naturally keeps the actually-hot URLs cached without needing manual curation.

Step 5: Bottlenecks and trade-offs

The ID-generation counter is a potential single point of contention if implemented as one global counter — mitigated by issuing ranges of IDs to each API server upfront (server A gets IDs 1-1000, server B gets 1001-2000), so each server can generate codes independently without a coordination round-trip per request, only needing to ask for a new range occasionally. The database, at ~9TB and ~77 writes/second, doesn't need sharding at the scale computed in step 2 — but the design should state explicitly what would change if that scale grew 100x: at that point, sharding by the short code's hash would distribute both storage and write load, at the cost of needing to query the right shard on lookup (still O(1), just against a different specific shard).

Common pitfall

Defaulting to a relational database with strong ACID guarantees for this specific access pattern (code -> long URL, no joins, no multi-row transactions) is over-engineering relative to what the problem actually needs — see SQL vs NoSQL's decision framework: this is exactly the "access pattern is simple, known in advance" case that framework points toward a key-value store for, not a relational one. Choosing PostgreSQL here isn't wrong, but defending it requires a real reason (existing team expertise, operational simplicity of one less system to run) rather than reaching for it as an unexamined default.