Design a URL Shortener
Design a URL Shortener (TinyURL / Bitly)
1. Problem & Real-World Context
A URL shortener takes a long URL like https://www.example.com/products/electronics/laptop?ref=campaign_2026&utm_source=twitter and produces a short one like https://short.ly/aB3xY9z. Anyone who opens the short link gets redirected to the original.
Why it exists in the real world:
- Character-limited contexts (Twitter/X historically; SMS still today)
- Click analytics for marketing campaigns (Bitly's actual business model — the redirect is the product, analytics is what customers pay for)
- Cleaner links in print, QR codes, and presentations
- Twitter's t.co wraps every link posted on the platform — not to save characters (all links count as 23 chars regardless) but to scan for malware and collect click data. That's a real production URL shortener at massive scale.
Why interviewers love it: it's the "hello world" of system design, but it has surprising depth. It tests whether you can:
- Do back-of-envelope estimation honestly
- Recognize a read-heavy workload and design for it
- Solve the one genuinely hard sub-problem — generating unique short codes at scale without coordination becoming a bottleneck
- Reason about caching, sharding, and hot keys
- Surface trade-offs (301 vs 302, SQL vs NoSQL, sync vs async analytics) instead of just naming technologies
A Staff candidate is expected to breeze through the basics and spend most of the time on ID generation, hot keys, failure modes, and operational concerns. Getting only the happy path right is a mid-level answer.
2. Requirements
Always split functional vs non-functional and justify each — interviewers score the justification, not the list.
Functional Requirements
- Shorten: given a long URL → return a unique short URL
- Redirect: given a short URL → redirect to the original long URL
- Analytics: click count (and ideally geo/referrer/time) per short URL
- Optional (confirm scope with interviewer): custom aliases (
short.ly/my-brand), expiration dates, link deletion
Non-Functional Requirements
| Requirement | Target | Why |
|---|---|---|
| Redirect latency | < 10 ms p99 server-side | The redirect sits in front of someone else's page load — any latency you add delays their entire experience |
| Availability | 99.99% | A down shortener breaks every link ever created — links live in printed material, old tweets, emails you can't edit |
| Durability | No URL ever lost | Same reason: links are permanent public commitments |
| Read-heavy scale | ~100:1 read:write | Each URL is created once, clicked many times |
| Uniqueness | Two long URLs must never map to the same short code | A collision silently sends users to the wrong site — worst-possible failure mode |
| Unpredictability (soft) | Codes shouldn't be trivially enumerable | Sequential codes let attackers scrape every private link (see Security section) |
Assumptions to state out loud: we do NOT need strong consistency on analytics counts (eventual is fine); we DO need read-your-write on creation (the creator will click their own link seconds later); URLs are effectively immutable after creation.
Read-your-own-writes — close the hole explicitly. If reads are eventually-consistent and a user shortens a URL then immediately clicks it, an eventual read can miss the just-written row → a false 404 on a link you just created. Fix: write-through the cache at creation time — the new mapping is in Redis the instant the write returns, so the first redirect is served from cache regardless of DB replication lag. (Backup: confirm the create via a strongly-consistent read on the primary.) Note the tension with cache-aside's "don't pre-warm" rule in the write flow — this is the one deliberate exception, and only for the creating user's own link. Write-latency target is loose (~100 ms p99 is fine); the strict SLO is redirect latency, not creation.
3. Capacity Estimation
Show the math slowly. Interviewers care that you can reproduce numbers, not memorize them.
Traffic
Assume a Bitly-scale service:
- Writes: 10M new URLs/day
- Read:write ratio: 100:1 (each link is clicked ~100 times over its life) → 1B redirects/day
Convert to QPS. A day ≈ 86,400 s ≈ 10⁵ s (round for mental math):
Write QPS = 10M / 10^5 s ≈ 100 writes/sec (peak ~2–3x → ~300/sec)
Read QPS = 1B / 10^5 s ≈ 10,000 reads/sec (peak ~2–3x → ~25–30K/sec)
Sanity check the ratio: writes are trivially cheap to serve (a few hundred QPS fits on one DB node). The entire design pressure is on the read path. Say this sentence in the interview — it drives every decision that follows.
Storage
Average record size (long URL dominates):
shortCode (7B) + longUrl (~400B) + userId (16B) + timestamps (16B) + metadata ≈ 500 bytes
Over 5 years:
10M/day × 365 × 5 = ~18B URLs
18B × 500 bytes = ~9 TB
9 TB over 5 years is small. This is a key Staff insight: the dataset fits on a handful of machines; you shard for throughput and availability, not for raw storage.
Bandwidth
Read: 10,000 req/s × 500 bytes ≈ 5 MB/s outbound (trivial)
Write: 100 req/s × 500 bytes ≈ 50 KB/s inbound (trivial)
Bandwidth is a non-issue — say so and move on.
Cache Sizing
Traffic to URL shorteners is Pareto-distributed: roughly 20% of URLs receive 80% of clicks. Cache the hot 20% of daily traffic:
Unique URLs read per day ≈ 200M (rough)
Hot set = 20% × 200M = 40M entries × 500 bytes ≈ 20 GB
Round up to ~25–50 GB of Redis — one beefy node or a small cluster. Cheap insurance for a 10,000 QPS read path.
Short-code length (the 62⁷ math)
Base62 alphabet = [a–z, A–Z, 0–9] = 26 + 26 + 10 = 62 characters.
62^6 = ~57 billion ← too tight vs 18B URLs (only 3x headroom)
62^7 = ~3.5 trillion ← 3,521,614,606,208 — ~200x headroom over 18B ✅
Verdict: 7 characters. Enough for centuries at this rate, short enough to stay pretty.
4. API Design
POST /api/v1/urls
Request: {
"longUrl": "https://example.com/very/long/path?q=1",
"alias": "optional-custom-alias",
"expiresAt": "2027-01-01T00:00:00Z" // optional
}
Response: 201 Created
{
"shortUrl": "https://short.ly/aB3xY9z",
"shortCode": "aB3xY9z",
"expiresAt": null
}
Errors: 400 (invalid URL), 409 (alias taken), 429 (rate limited)
GET /{shortCode}
Response: 302 Found
Location: https://example.com/very/long/path?q=1
Errors: 404 (unknown code), 410 Gone (expired)
GET /api/v1/urls/{shortCode}/stats
Response: 200 { "clicks": 1234, "createdAt": "...", "lastAccessed": "..." }
DELETE /api/v1/urls/{shortCode} // owner-authenticated
Response: 204 No Content
Idempotency: POST /urls should accept an Idempotency-Key header. Client retries (mobile networks!) must not mint duplicate short codes for the same logical request. Store idempotencyKey → shortCode with a short TTL; replay returns the original response. Note: this is request-level dedup, not URL-level dedup — see Edge Cases for why we deliberately do NOT dedup by long URL.
301 vs 302 — the trade-off in depth
301 Moved Permanently | 302 Found (temporary) | |
|---|---|---|
| Browser behavior | Caches the redirect — future clicks skip your server entirely | Every click hits your server |
| Server load | Lower (repeat clicks free) | Higher (you pay for every click) |
| Analytics | You lose repeat-click visibility per browser | Every click is countable ✅ |
| Changing/expiring the destination later | Nearly impossible — browsers hold the cached redirect indefinitely | Trivial |
| SEO | Passes link equity to the target | Historically didn't (Google now treats both similarly) |
The interview-folklore answer is "use 302 because analytics." The deeper truth: Bitly actually serves 301s — every new browser still makes a first request (which they count), and they accept undercounting repeat clicks from the same browser in exchange for lower load and faster repeat redirects. Twitter's t.co similarly uses 301.
Staff answer: default to 302 because it preserves optionality — you keep full analytics AND the ability to expire, edit, or block a link after creation (critical for the malware-takedown case: with 301, a browser that cached a redirect to a phishing page keeps going there even after you kill the link). Mention that 301 is a legitimate cost optimization if analytics precision and post-hoc control matter less. Showing you know both real-world behaviors is the differentiator.
Also send Cache-Control: private, max-age=90 on 302s to soften repeat-click load without giving up control entirely.
5. High-Level Architecture
graph TD
Client([Client]) -->|POST /urls| LB
Client -->|GET /shortCode| LB
LB[Load Balancer] --> AG[API Gateway<br/>auth + rate limiting]
AG -->|write| WS
AG -->|read| RS
subgraph write["Write Path"]
WS[URL Shortener Service]
KGS[Key Generation Service /<br/>Counter Ranges]
DB[(Primary DB<br/>DynamoDB / Cassandra)]
MQ[[Kafka]]
WS --> KGS
WS --> DB
WS -.->|async event| MQ
end
subgraph read["Read Path"]
RS[Redirect Service]
Cache[(Redis Cache<br/>LRU, ~50 GB)]
Replica[(Read Replicas)]
RS --> Cache
Cache -.->|miss| Replica
Replica -.->|miss / stale| DB
RS -.->|click event| MQ
end
subgraph analytics["Analytics (async)"]
AS[Analytics Consumer]
ADB[(Cassandra /<br/>ClickHouse)]
AS --> ADB
end
MQ --> AS
style write fill:#fff7ed,stroke:#f97316,stroke-width:1.5px
style read fill:#f0fdf4,stroke:#22c55e,stroke-width:1.5px
style analytics fill:#faf5ff,stroke:#a855f7,stroke-width:1.5px
style Client fill:#dbeafe,stroke:#3b82f6
style LB fill:#e9d5ff,stroke:#7c3aed
style AG fill:#e9d5ff,stroke:#7c3aed
style WS fill:#fed7aa,stroke:#ea580c
style KGS fill:#fef9c3,stroke:#ca8a04
style DB fill:#ccfbf1,stroke:#0d9488
style MQ fill:#fed7aa,stroke:#ea580c
style RS fill:#bbf7d0,stroke:#16a34a
style Cache fill:#ccfbf1,stroke:#0d9488
style Replica fill:#ccfbf1,stroke:#0d9488
style AS fill:#f3e8ff,stroke:#9333ea
style ADB fill:#f3e8ff,stroke:#9333ea
Note the deliberate read/write path separation — different services, scaled independently. The redirect service is tiny, stateless, and horizontally scalable; you'll run 10–50x more redirect instances than shortener instances, matching the 100:1 traffic ratio.
Write flow — step by step
1. Client → POST /urls { longUrl }
2. API Gateway: authenticate, rate-limit (creation is the abusable endpoint)
3. Shortener Service validates URL (syntax, allowlist scheme http/https,
optional Safe Browsing check)
4. Obtain a unique shortCode (from KGS / counter — Section 7)
5. Write { shortCode → longUrl, userId, createdAt, expiresAt } to DB
with a conditional put (fail if shortCode exists — belt-and-braces)
6. Return 201 with the short URL
7. (No cache write — don't pollute the cache with URLs nobody has clicked yet;
let the first read populate it. This is cache-aside working as intended.)
Read flow — step by step
1. Client → GET /aB3xY9z
2. Redirect Service checks Redis: GET url:aB3xY9z
3. HIT (~80% of traffic) → 302 + Location header. Total: ~1–2 ms.
4. MISS → read replica → found → SET url:aB3xY9z (TTL 24h) → 302. ~10 ms.
5. Not in DB → 404. Expired → 410 Gone.
6. Fire-and-forget: publish {shortCode, ts, ip, userAgent, referrer} to Kafka.
The redirect NEVER waits on analytics — if Kafka is down, drop the event
and serve the redirect. Losing a click count beats slowing every user.
6. Data Model & Storage Choice
Schema
urls (main table):
shortCode (PK) String "aB3xY9z"
longUrl String "https://..."
userId String owner (GSI/secondary index for "my links" listing)
createdAt Long epoch ms
expiresAt Long epoch ms, nullable
clicks (analytics store, separate system):
shortCode (partition key) | timestamp (clustering key) | ip | referrer | country | userAgent
Keep aggregate clickCount in the analytics store or a Redis counter — never as a hot column on the urls row (a viral link would turn your read-optimized row into a write hotspot).
SQL vs NoSQL
| Dimension | SQL (Postgres/MySQL) | NoSQL (DynamoDB/Cassandra) |
|---|---|---|
| Access pattern fit | Fine, but we never join — relational power unused | Perfect: pure key-value get(shortCode) |
| Scale ceiling | Vertical first; sharding is manual and operationally heavy | Horizontal partitioning built-in |
| Consistency | Strong by default | Tunable (DynamoDB strong reads cost 2x; eventual is fine here) |
| Auto-increment IDs | Free (useful for counter approach) | Not native — need external counter |
| Latency at p99 | Good with proper indexes | Single-digit ms at any scale |
| Ops burden at 10K QPS | Read replicas + connection pooling; very doable | Near-zero (managed DynamoDB) |
| Cost at our size (9 TB) | Cheap | Cheap |
Verdict: NoSQL key-value store (DynamoDB or Cassandra). The access pattern is a dictionary lookup — the textbook NoSQL fit. No joins, no transactions across entities, tunable consistency, and horizontal scale for free.
But say this too: at 100 write QPS and 9 TB, a single Postgres primary with read replicas + Redis genuinely works — Bitly historically ran on sharded MySQL. Choosing NoSQL here is about operational simplicity at scale and partition-tolerance, not because SQL "can't handle it." Interviewers upgrade you for acknowledging that the naive answer isn't wrong, just less future-proof. See [[Design a Distributed Key-Value Store]] for what's under the hood of the store you just picked.
7. The Core Problem: Unique Short-Code Generation
This is the heart of the interview. Everything else is standard plumbing; this is where depth is scored. The problem: generate 7-char codes that are unique (hard requirement), fast (no coordination on the hot path), and non-enumerable (soft requirement) — pick two, then engineer your way to the third.
First, understand base62
Base62 is just base conversion, like binary or hex, using 62 symbols: 0-9a-zA-Z. Any integer maps to a unique string:
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def encode_base62(n: int) -> str:
if n == 0: return ALPHABET[0]
out = []
while n:
n, rem = divmod(n, 62)
out.append(ALPHABET[rem])
return "".join(reversed(out))
# 1 → "1"
# 100 → "1C"
# 1000000 → "4c92"
# 3.5e12 → 7 chars (62^7 boundary)
Why base62 and not base64? Base64 needs + and / (or - _), which are ugly/ambiguous in URLs and often get mangled by copy-paste and print. Base62 is the largest alphabet that's pure alphanumeric. (Some services also drop lookalikes 0/O/l/1 → base58, Bitcoin-address style — worth mentioning.)
Approach 1: Hash the URL + take a prefix
Mechanism: shortCode = base62(MD5(longUrl))[0:7]
graph LR
A[longUrl] --> B[MD5 / SHA-256<br/>128-256 bit digest]
B --> C[Take first 43 bits]
C --> D[Base62 encode<br/>7 chars]
D --> E{Exists in DB?}
E -->|no| F[Store & return]
E -->|yes, same URL| F
E -->|yes, different URL<br/>COLLISION| G[Append salt/counter<br/>to longUrl, re-hash]
G --> E
The collision math (birthday paradox): you're mapping into 62⁷ ≈ 3.5×10¹² slots. Collisions become likely around √(3.5×10¹²) ≈ 1.9M URLs. We create 10M/day — so you'd hit collisions within the first few hours of launch, and the retry loop gets hotter as the table fills. Anyone who says "hash collisions are rare" hasn't done this math — doing it out loud is a strong signal.
Pros:
- Stateless — no counter, no coordination service
- Deterministic (same URL → same code — though we don't actually want that, see dedup edge case)
- Codes look random → not enumerable
Cons:
- Collisions are guaranteed at scale → every write needs a read-check → retry loop
- Retry loop under contention = unpredictable write latency
- Determinism fights the "same URL, different owners" requirement — you end up salting anyway, which kills the one nice property
Verdict: fine to mention, wrong to choose. Useful only as a fallback if your primary generator is down.
Approach 2: Auto-increment counter → base62
Mechanism: a global counter issues 1, 2, 3, …; base62-encode the number. Uniqueness is free — counters never repeat.
Pros:
- Zero collisions by construction — no read-before-write
- Trivially simple; codes start short and grow
- Dense keyspace usage
Cons:
- Single point of failure & throughput ceiling: one counter = one row/node everyone contends on
- Enumerable:
/1,/2,/3… — an attacker scripts a scraper and downloads every link ever created, including "private" ones. Also leaks your business volume (competitors can watch your write rate — the classic "German tank problem")
Fixes for each con:
- SPOF → counter ranges: see Approach 2b
- Enumeration → don't encode the raw counter; encode
permute(counter)with a keyed bijection (e.g., a small block cipher / multiplicative inverse mod 62⁷). Uniqueness preserved (bijections can't collide), output looks random. This one sentence is a Staff-level move.
Used by (real): Flickr famously generated unique IDs with two MySQL "ticket servers" using
auto_increment_increment=2and offsets 1/2 — odd/even split for HA. Instagram generated IDs inside Postgres shards (timestamp + shard id + per-shard sequence). Both are published engineering-blog designs worth citing.
Approach 2b: Counter ranges (the practical winner)
Each app server leases a batch of IDs from a coordination store (ZooKeeper, or just a DB row updated with an atomic increment), then hands them out from memory:
graph TD
ZK[(ZooKeeper / DB row<br/>next_range = 3,000,000)]
S1[Server A<br/>owns 1,000,000-1,999,999<br/>serves from memory]
S2[Server B<br/>owns 2,000,000-2,999,999<br/>serves from memory]
S1 -->|range exhausted:<br/>atomic fetch-and-add| ZK
S2 -->|range exhausted:<br/>atomic fetch-and-add| ZK
- Coordination happens once per million writes, not once per write → the counter is off the hot path entirely
- Server crash loses at most one unissued range — 1M codes out of 3.5T. Who cares. (State this explicitly: wasting keyspace is free; coordination is expensive. That's the trade.)
- Still apply the bijective permutation before encoding to kill enumerability
Pros: no collisions, no per-request coordination, horizontally scalable, tiny blast radius on failure. Cons: slight keyspace waste; codes within a range are sequential unless permuted; needs a (highly-available but rarely-touched) coordinator.
Approach 3: Key Generation Service (KGS) — pre-generated keys
Mechanism (the classic Grokking design): an offline service pre-generates random 7-char keys into a available_keys table. On write, the app grabs one and moves it to used_keys.
graph LR
GEN[Offline generator<br/>random 7-char keys] --> AVAIL[(available_keys)]
APP[Shortener Service] -->|take batch of 1000<br/>into memory| AVAIL
APP -->|assign on write| USED[(used_keys / urls table)]
- Servers fetch keys in batches into memory → no DB hit per request
- Collision checking happens offline at generation time, off the hot path
- 3.5T keys × 7 bytes ≈ 25 TB to store them ALL — so you don't; you keep a rolling buffer (e.g., a few billion) and generate more as consumed
Pros:
- Zero collision risk at request time; O(1) assignment
- Keys are random → non-enumerable for free (no permutation trick needed)
- Concept generalizes (gift-card codes, coupon codes)
Cons:
- New stateful component to build, replicate, and monitor (SPOF if you're lazy)
- Key-handout concurrency: two servers must never receive the same key → needs atomic take (
DELETE ... RETURNING/ conditional update) - Keys held in a crashed server's memory buffer are lost (again: acceptable waste — say so)
- More moving parts than counter ranges for roughly the same benefit
Approach 4: Snowflake-style distributed IDs
See [[Design a Unique ID Generator]] for the full canonical treatment (bit-budget reasoning, clock-skew handling, comparison against UUID/ticket-server/multi-master approaches) — this section covers only why it's the wrong fit specifically for short codes.
Mechanism: Twitter Snowflake mints 64-bit IDs with zero coordination:
| 1 bit sign | 41 bits timestamp (ms) | 10 bits machine ID | 12 bits sequence |
Each machine can mint 4,096 IDs/ms independently. IDs are unique (machine ID disambiguates) and roughly time-ordered.
Pros:
- No coordination at all, extreme throughput, battle-tested (used by Twitter for tweet IDs; Discord and Instagram use variants — all real)
- Time-ordered IDs are great for range queries elsewhere
Cons — and here's the interview trap:
- A 64-bit ID in base62 is ~11 characters, not 7. Your "short" URL just got 60% longer. The 41-bit timestamp burns keyspace whether you use it or not.
- Timestamp prefix leaks creation time and is partially enumerable within a millisecond window
- Requires machine-ID assignment (which usually means… ZooKeeper — you've reinvented the coordinator)
Verdict: Snowflake is the right answer for event IDs (see the analytics pipeline!), the wrong answer for short codes. Recognizing that mismatch — right tool, wrong slot — is exactly what Staff interviews test.
Comparison & final verdict
| Approach | Collisions | Hot-path coordination | Enumerable? | Ops complexity | Code length |
|---|---|---|---|---|---|
| Hash + prefix | Guaranteed at scale, retry loop | Read-check per write | No | Low | 7 |
| Single counter | None | Every write | Yes (fixable w/ permutation) | Low but SPOF | ≤7 |
| Counter ranges + permutation | None | 1 per ~1M writes | No (permuted) | Medium | ≤7 |
| KGS pre-gen | None | None (batched) | No | Medium-high (stateful svc) | 7 |
| Snowflake | None | None | Partially | Medium | ~11 ❌ |
Recommendation: counter ranges (ZooKeeper or DB-row leased blocks) + bijective permutation + base62. KGS is an equally defensible answer — pick one, defend it, and name the other as a valid alternative. If the interviewer pushes "what if ZooKeeper is down?" — servers keep serving from their in-memory range (median ~500K codes of runway ≈ hours of writes), and you alarm on range-remaining; total exhaustion falls back to hash+retry. Failure doesn't stop writes, it degrades gracefully. That's the answer they want.
8. Scaling & Bottlenecks
Caching (cache-aside)
- Pattern: cache-aside (lazy). Read: check Redis → miss → DB → populate. Write: don't pre-warm; first click populates. See [[Caching Strategies]].
- Eviction: LRU. TTL 24h (or min(24h, expiresAt)).
- Invalidation: URLs are immutable, so the classic hard problem mostly vanishes — the only invalidation events are delete/expire/takedown, which do an explicit
DEL. Say this: immutability is why caching is so safe here.
Defend the cache with hit-rate math (don't just say "cache = fast"):
Effective latency = HitRate × CacheLatency + (1 − HitRate) × DBLatency
Pareto traffic (80/20): hit rate ≈ 80%
= 0.8 × 1ms + 0.2 × 10ms = 2.8ms ← strong win, meets <10ms p99
Counter-example — long-tail traffic (90% of URLs clicked once ever):
hit rate ≈ 10% → 0.1 × 1ms + 0.9 × 10ms = 9.1ms + cache overhead → net negative
Decision: measure your distribution before buying the cache.
The hot-key problem (viral URL)
A Super Bowl ad's short link can pull 100K+ QPS onto one key — one Redis shard, one DB partition.
Viral shortCode → ALL app servers hammer the same cache key / DB row
Adding more app servers → MORE concurrent requests to the same contention point
→ horizontal scaling makes it WORSE, not better
Fixes (mention proactively — interviewers watch for this):
- Local in-process cache per redirect server for the top-N hot keys (even a 1-second TTL absorbs enormous load; staleness is irrelevant for immutable data)
- Request collapsing / singleflight: concurrent misses for the same key share one DB read instead of stampeding
- Key replication: write hot keys as
key#1..key#Nacross cache shards, read a random replica
Sharding
- Shard key:
shortCode(hash-based or via [[Consistent Hashing]]). Every redirect carries the shortCode → single-shard lookup, perfect distribution because codes are (permuted-)random. - Anti-pattern to call out: sharding by
userIdorcreation date— date-sharding puts all of today's writes AND most reads (recent links are hottest) on one shard. Random short codes are the ideal shard key precisely because they carry no locality. - Cost of this choice: "list all my URLs" becomes a scatter-gather → serve it from a
userIdGSI/secondary index instead. Trade named, trade paid.
Replication & geo
- 3x replication per partition (DynamoDB/Cassandra default posture); eventual consistency is fine because rows are immutable.
- Vocabulary check (you'll get dinged for this): DynamoDB/Cassandra are leaderless, not primary/replica — so say "eventually-consistent reads on the cluster", NOT "read from a read replica." "Read replica" is RDBMS/Postgres language; using it after you picked Cassandra signals you're reciting patterns you haven't run. Cassandra tuning:
R + W > N(e.g. quorum) for strong reads,R=1for fast eventual. DynamoDB: eventually-consistent read = default (½ cost), strongly-consistent read = 2× cost, opt-in per call. - Multi-region: redirects are latency-sensitive and global. Run read replicas + cache in each region; writes can home to one region (100 QPS doesn't need multi-master). A user in India shouldn't cross an ocean to resolve 7 characters.
CDN / edge
Staff-level nudge: a redirect is a tiny, cacheable computation — you can push it to the edge entirely (Cloudflare Workers + KV / Lambda@Edge style), resolving hot links within ~10ms of the user with no origin hit. Counter-trade: edge KV stores are eventually consistent, so takedowns propagate in seconds-to-minutes — decide whether your abuse-response SLA tolerates that.
DB choice at scale — layered honestly
| Scale | Right-sized answer |
|---|---|
| Side project | Postgres. Done. |
| 1K QPS reads | Postgres + read replicas + Redis |
| 10K–100K QPS, global | DynamoDB/Cassandra, partition by shortCode, regional caches |
| Bitly-actual | Historically sharded MySQL + heavy caching — proof the "boring" stack stretches far |
9. Deep Dives / Follow-ups
Custom aliases
- Namespace check at write time: conditional put on the same
urlstable (alias IS the shortCode) → atomic uniqueness, no second table - Validate: length 4–30, alphanumeric+dashes, blocklist reserved words (
admin,api,login, brand names, profanity) - Product wrinkle: aliases collide with generated codes — either reserve a prefix/length band for aliases or (simpler) rely on the conditional put since generated codes come from the counter space
Analytics pipeline
graph LR
RS[Redirect Service] -->|fire-and-forget<br/>click event| K[[Kafka<br/>partition by shortCode]]
K --> F[Stream processor<br/>Flink / Kafka Streams]
F -->|1-min aggregates| CH[(ClickHouse /<br/>Cassandra)]
K --> S3[(S3 raw events<br/>batch reprocessing)]
CH --> API[Stats API<br/>eventually consistent]
- Redirect latency budget is sacred → producer is async,
acks=1, small buffer, drop on failure (losing clicks beats slowing redirects — an explicit, stated trade-off) - Pre-aggregate per-minute counts in the stream; raw events to S3 for replay/backfill
- Never
UPDATE clickCount = clickCount + 1on the main table — that's a write hotspot and a lock convoy on viral links
Link expiration / TTL reaper
Two-layer strategy (do both):
- Lazy deletion: on read, if
expiresAt < now→ return410 Gone, delete row + cache entry. Correctness layer — expired links are never served, even if cleanup lags. - Background reaper: periodic job scans an index/GSI on
expiresAt(or use DynamoDB native TTL / Cassandra row TTL and get this for free) to reclaim storage. Hygiene layer — batched, rate-limited, off-peak. Interview soundbite: lazy gives correctness, reaper gives hygiene; neither alone is sufficient.
Rate limiting
- The write path is the abusable surface: spammers mint millions of short links for phishing campaigns. Token bucket per API key / IP at the gateway (see [[Rate Limiter]]) — e.g., 100 creates/hour anonymous, tiered for paid.
- Read path limiting is lighter (protect against scrapers/enumeration probes), coarse per-IP.
Security
- Enumeration: never expose raw sequential codes (permute or use random KGS keys). Monitor 404-rate per IP — a scanner probing the keyspace produces a distinctive 404-heavy signature → auto-block.
- Phishing/malware: shorteners hide destinations, so they're a spam favorite — check on write against Google Safe Browsing (real API), re-scan async post-creation (attackers submit clean URLs and swap destination content later), support takedown (delete + cache purge — another reason 302 beats 301). Add an optional preview endpoint (
?preview=1) showing the destination before redirecting. - Open-redirect hygiene: only allow
http/httpsschemes — nojavascript:,data:,file:. - Code reuse after expiry: don't recycle expired codes — old printed/emailed links would silently point at new (possibly malicious) destinations. Keyspace is effectively infinite; tombstone and move on.
10. Staff-Level Lens
What separates a Staff answer from a solid Senior one on this "easy" problem:
- Right-size before you architect. 100 write QPS and 9 TB is small. Present the boring Postgres answer first, then justify each added component against a specific number. Reaching for Cassandra + Kafka + ZooKeeper unprompted signals resume-driven design; a Staff candidate earns every box on the diagram. Org-grounds version: every component is an on-call rotation, a runbook, and an upgrade treadmill — the naive-complex design fails on people cost before it fails technically.
- Consistency, stated precisely: strong uniqueness on shortCode allocation (conditional writes), read-your-write for creators (route the creator's first read to primary or seed the cache on their first click), eventual everywhere else — and why each is acceptable. Blanket "we need strong consistency" is a red flag.
- Failure-first narrative. Walk unprompted through: Redis down (DB absorbs 5x read load — is it provisioned for that, or does a cache outage cascade? Answer: replicas sized for cache-miss storm + load-shed analytics first). ZooKeeper down (in-memory ranges = hours of runway, alarm on range-remaining). Primary DB failover mid-write (conditional put + client retry with idempotency key = safe). Kafka down (drop click events, redirects unaffected — pre-decided data loss).
- Monitoring signals you'd page on: redirect p99 (the product SLO), cache hit ratio (early-warning — a falling ratio predicts the DB storm before latency moves), 404 rate per IP (enumeration attack), KGS/range runway remaining, Kafka consumer lag (analytics freshness), 5xx on the write path.
- Migration story: how do you move from single-DB to sharded without downtime? Dual-write with backfill → shadow reads comparing results → cut reads over shard-by-shard → retire old path. Links must NEVER 404 during migration — availability of old data is the product.
- Know what's actually easy vs hard here: immutability makes caching trivially safe and consistency mostly moot. The real hard parts are the ID generator's failure modes, hot keys, and abuse. Spending your minutes where the difficulty lives — instead of evenly across sections — is itself the Staff signal.
11. Interview Drill Questions
Q1 (easy): Why 7 characters and not 6? 62⁶ ≈ 57B; at 10M URLs/day that's ~15 years to exhaustion, but you want headroom for growth and for keyspace wasted by leased ranges. 62⁷ ≈ 3.5T gives ~200x headroom over a 5-year 18B projection. One extra character buys 62x capacity — cheapest insurance available.
Q2 (easy): Why is cache-aside a better fit than write-through here? Write-through populates the cache at creation, but most new URLs are never clicked (or not soon) — you'd fill the cache with cold data. Cache-aside populates on first read, so cache contents self-select to actual read traffic. Immutability removes cache-aside's usual staleness downside.
Q3 (medium): Two requests shorten the same long URL simultaneously. What happens? By design: two different short codes — that's correct, not a bug. We deliberately don't dedup by longUrl: different users must own different codes (else user B's link dies when user A deletes "theirs"), and one user wants separate codes per campaign for attribution. Bitly doesn't dedup by default. If the interviewer means retry of the same request: that's the idempotency-key mechanism — replay returns the original code.
Q4 (medium): Your Redis cluster dies entirely. Walk through the blast radius. Hit rate → 0; DB read load jumps ~5x (from the 80% that was absorbed). If replicas are provisioned only for steady-state miss traffic, they saturate → latency spike → timeouts → retry storm. Mitigations: provision replicas for full cache-miss load (it's cheap at this data size), request collapsing to stop stampedes on hot keys, load-shed stats/analytics endpoints first, and keep the tiny local in-process hot-key cache as a shock absorber. Key point: a cache must be an optimization, never a load-bearing dependency you can't live without — if it is, you've mis-provisioned the layer below.
Q5 (hard): How do you make counter-based codes non-enumerable without giving up uniqueness?
Apply a bijective permutation to the counter before base62-encoding: a keyed small-domain block cipher (format-preserving encryption) or modular multiplicative inverse (code = (n × k) mod 62⁷ with k coprime to 62⁷). Bijection ⇒ distinct inputs give distinct outputs ⇒ uniqueness preserved; output order looks random ⇒ scraping sequential codes fails. Contrast with "just hash it," which reintroduces collisions.
Q6 (hard): Design the takedown path for a phishing link that's gone viral — end to end, with propagation times.
(1) Mark row deleted/blocked in primary DB (source of truth, ~ms). (2) Explicitly DEL the cache key — never wait for TTL (~ms). (3) Purge per-server local hot-key caches — pub/sub invalidation broadcast, or accept the ≤1s local TTL. (4) Edge/CDN KV purge if resolving at edge (seconds–minutes; know your provider's purge SLA). (5) Browsers: this is why you served 302 — with 301, victims' browsers keep redirecting from local cache forever with no server round-trip to intercept. (6) Emit takedown audit event; feed the URL back into write-path blocklists. Total propagation target: seconds, bounded by the slowest cache layer you chose to add — say that trade-off out loud.
12. What Interviewers Look For (self-check)
- Clarify functional vs non-functional requirements first ✅ 2026-03-29
- Back-of-envelope estimation (storage, RPS) — reproduce Section 3 cold ✅ 2026-07-18
- Explain 301 vs 302 tradeoff ✅ 2026-03-29
- Justify NoSQL over SQL ✅ 2026-03-29
- Cache layer for read performance ✅ 2026-03-29
- Async analytics to not block redirect ✅ 2026-03-29
- Handle collisions in URL generation ✅ 2026-03-29
- Mention expiry + cleanup strategy (lazy + reaper, both) ✅ 2026-07-18
- Defend cache with hit rate math (not just "cache = fast") ✅ 2026-07-18
- Mention hot key problem for viral URLs + fix (request collapsing / local cache) ✅ 2026-07-18
- Failure-first: what happens when primary DB goes down mid-failover? ✅ 2026-07-18
- Birthday-paradox math on hash collisions (~1.9M URLs to first collision) ✅ 2026-07-18
- Why Snowflake is wrong for short codes (11 chars) but right for event IDs ✅ 2026-07-18
Trade-off Summary
| Decision | Option A | Option B | Chose | Why |
|---|---|---|---|---|
| Redirect code | 301 | 302 | 302 | Analytics + post-hoc control (takedowns); 301 = valid cost optimization, name it |
| Code generation | Hash-based | Counter ranges / KGS | Counter ranges + permutation | Zero collisions, coordination off hot path, non-enumerable |
| DB | SQL | NoSQL | NoSQL (DynamoDB/Cassandra) | Pure KV access, horizontal scale; concede Postgres works at this size |
| Analytics | Sync | Async (Kafka) | Async, drop-on-failure | Redirect latency is the product; click loss is the cheaper failure |
| Cache pattern | Write-through | Cache-aside | Cache-aside | Most new URLs never get read; let traffic select cache contents |
| Cache eviction | LRU | LFU | LRU | Simpler, good enough for Pareto-shaped traffic |
| Dedup by longUrl | Yes | No | No | Ownership + per-campaign analytics; Bitly doesn't dedup by default |
| Expired code reuse | Recycle | Tombstone | Tombstone | Old printed links must never resolve to new destinations |
Cross-links
- [[Design a Distributed Key-Value Store]] — what's inside the DynamoDB/Cassandra box we picked
- [[Rate Limiter]] — the write-path abuse defense, as its own design problem
- [[Consistent Hashing]] — how shortCode sharding rebalances when nodes join/leave
- [[Design a Unique ID Generator]] — the canonical treatment of Snowflake and its alternatives
- [[Caching Strategies]] — cache-aside vs write-through/behind, eviction, invalidation
- [[System Design/Senior SD Mindset]] — hot key, cache math, failure-first thinking, L7 mindset