the brief
“Design a URL shortener like bit.ly: given a long URL, return a short one; given the short one, redirect to the original — at scale.”
clarify first
- Custom aliases, or system-generated only? A custom alias introduces a uniqueness race on the write path that a generated code doesn’t have.
- What’s the read:write ratio, roughly? This is a link-sharing product — reads will dominate — but confirming that, even roughly, is what tells you the read path is where the design effort belongs.
- Do links expire, or live forever? An expiring link needs a TTL and something to act on it; a permanent one needs a storage-growth plan instead.
- Do we need click analytics? That’s a second write, per redirect, and it must not sit on the critical path of the redirect itself.
301or302? This single HTTP detail decides whether browsers cache the redirect for you or every click still has to reach your service — see below.- Single region, or global? Decides whether the code-generation scheme can lean on one authority or has to work without one.
the numbers
Assumptions, stated as assumptions:
- 100 million new short links created per month.
- A read:write ratio of
100:1— typical for a link-sharing product, not a measured figure. - Mappings retained for 5 years, nothing deleted.
- Each stored row (code, long URL, metadata) costs roughly 500 bytes.
Writes: 100,000,000 / (30 × 86,400s) ≈ 39 creates/sec average.
Reads: 100 × 39 ≈ 3,900 redirects/sec average.
Total links over 5 years: 100M × 12 × 5 = 6 billion.
Key space check: base62 (26 + 26 + 10 = 62 symbols). Six characters gives
62^6 ≈ 56.8 billion possible codes — almost ten times the 5-year total; seven characters
(62^7 ≈ 3.5 trillion) gives comfortable headroom either way, which matters most for a
hash-based scheme where a fuller key space means fewer collisions to retry near the end of the
range.
Storage: 6B × 500 bytes ≈ 3 TB over 5 years — unremarkable for one well-indexed
relational table. That number is the whole point of doing the arithmetic: storage was never
going to be the constraint here, which tells you the interesting design decision is on the read
side.
Cache footprint: if reads follow a power law — a small fraction of links account for most of the traffic, an assumption, not a measurement — caching even the 20 million most-recently-active mappings at 500 bytes each is about 10 GB: a single Redis instance’s job, not a distributed cache’s.
the sketch
┌────────────┐
client (create) ──────▶│ API layer │───▶ ID generator (leased counter ranges)
└─────┬──────┘
│ write {code → long_url, meta}
▼
┌────────────────┐
│ mapping store │ SQL Server / PostgreSQL, unique index on `code`
└────────┬────────┘
│ populate on write
▼
┌────────────┐
client (GET /code) ────▶│ cache │───▶ 302 → long_url
└─────┬──────┘
│ miss
▼
┌────────────────┐
│ mapping store │
└────────────────┘
Write path: the API asks the ID generator for the next code, writes {code, long_url, created_at, ...} to the mapping store, and populates the cache in the same request
(write-through), so the very first read never has to miss.
Read path: look up the cache first; on a hit, respond with the redirect immediately; on a miss, read the mapping store, populate the cache, then respond. Because writes are rare and reads dominate, the whole design bends toward keeping that cache-hit path short — this is caching’s cache-aside pattern applied to the one access pattern this system actually has.
ID generation, two real options:
- Leased counter ranges, base62-encoded. A single authority hands each app instance a block of IDs up front (not one round trip per write), and the app encodes them. Sequential IDs within a leased block are guessable in order, which is a real concern if enumerating your link table is a problem for the product.
- Hash the long URL (truncate an MD5/SHA-256 digest to 6-7 chars). No shared counter, but a
truncated hash can collide, so the write path needs a uniqueness check before it commits —
an extra read, on the write path, in exchange for removing the counter dependency. A unique
index on
code(an EF Core[Index(nameof(Code), IsUnique = true)]maps straight onto it) turns a collision, or a racing custom-alias claim, into one clean failure instead of a silent overwrite.
301 vs 302
301 (permanent) tells the browser to cache the redirect itself, so repeat clicks never even
reach your service — but you also lose the ability to change the target, retire the link, or
count the click server-side, because the browser stops asking. 302 (temporary) keeps every
click on your service, which is what you want by default: it costs you the browser’s cache,
which your own cache-aside layer replaces anyway.
A cache key here is one short code, and the row it fetches — code, URL, metadata — is bigger than what a redirect strictly needs (just the URL). That’s the same shape as the memory hierarchy’s cache line: the unit of caching is fixed once, and every fetch pays for the whole unit whether it needed all of it or not, because finer-grained caching would cost more in bookkeeping than it saves.
the tradeoffs
| decision | chosen | buying | paying |
|---|---|---|---|
| ID generation | leased counter ranges + base62 | no per-write uniqueness check | needs a counter authority; codes are guessable in sequence within a lease |
| redirect code | 302 |
freedom to change or retire a mapping, and per-request routing (A/B, geo) | every click is a real request — no browser caching |
| mapping store | one indexed relational table (SQL Server/PostgreSQL) in front of a cache | operational simplicity, and the numbers above show it’s enough | would need to shard, or move to a horizontally-scaled store (Cosmos DB), before storage size forces it — read QPS is what would actually force that move |
For a typical link-sharing product, leased counter ranges plus 302 behind a cache-aside layer
is the right default. Reach for 301 only when a mapping is deliberately immutable and you’re
trading server-side control for taking redirect traffic off your service entirely — an SEO
decision, not a scaling one. Reach for a horizontally-scaled store only once read QPS, not the
3 TB of storage, is what’s forcing the move — see
partitioning for what that migration actually costs.
how it fails
- A link goes viral (hot key). The cache absorbs almost all of the extra reads, but a brand-new viral link starts with a cold cache, so the first wave of concurrent misses can all hit the mapping store for the same row at once. The fix is request coalescing — one in-flight read per key, everyone else waits on it — not one query per concurrent miss.
- The counter authority is unreachable. With leased ranges, this only delays new-link creation; reads never touch the ID generator at all. That separation is exactly why the generator should never sit anywhere near the read path.
- Open-redirect abuse. Someone uses the shortener to mask a phishing URL. This is a product/security failure, not a scale one — it needs an explicit control (a URL-safety check on write, per-account rate limiting) rather than anything the storage or cache layer fixes.
- Two clients race the same custom alias. The unique index on
codeturns the race into one winner and one clean failure rather than a silent overwrite.
what they ask next
- “Does the same long URL always get the same short code?” Only if the product wants that —
it needs a second, reverse index (
long_url → code) with its own write cost. Most systems skip it deliberately and mint a fresh code every time. - “How would you add click analytics without slowing the redirect down?” Don’t write the event synchronously. Publish it to a queue (Kafka, Azure Service Bus, RabbitMQ) at request time and let a separate consumer aggregate it — the redirect’s response never waits on that write.
- “The mapping store is a single instance — what happens at ten times the traffic?” The numbers already answered this: storage was never the constraint, read QPS was. The cache absorbs the great majority of it, and beyond that the store gets read replicas (replication) — the write path barely moved, so it doesn’t need reshaping at all.