loading
loading
Design a URL shortener serving 5,000 redirects/sec with a 100:1 read/write ratio. Survive a viral-link traffic spike and a primary database crash — on a $3,500/month budget.
Steady traffic
5k rps
Spike
3×
Budget
$3.5k/mo
Ref. components
7
6 links
How the reference architecture is reasoned out, end to end.
The URL shortener's architecture is almost entirely dictated by one number: the 99:1 read/write ratio. Every redirect (GET /{code}) is a read; every new link creation (POST /shorten) is a write. At 5,000 rps with 99% reads, you have roughly 50 writes/s and 4,950 reads/s.
The entire architecture flows from this: the write path can be simple and even slow — a direct Postgres insert with a collision check — while the read path must be aggressively cached to avoid saturating the database. The reference architecture places a Redis cache between the app servers and Postgres for all redirect lookups. With a 95% cache hit ratio (justified by Zipf distribution — the top 10M codes handle ~95% of traffic, fitting in ~2.5GB of Redis), only 250 rps ever reach Postgres at steady state.
During the viral spike (15k rps), 5% misses = 750 rps to Postgres — still comfortably within capacity. A CDN layer in front of the app servers handles browser-cached 301 responses for globally popular links, further reducing origin load. Click counting uses Redis INCR (atomic, sub-ms) with a batch flush to Postgres every 60 seconds, avoiding write amplification on the hot path.
The region-A database outage is survivable because the cache and the database are independent failure domains. At 95% hit ratio, 4,750 of 5,000 rps are served entirely from Redis — which remains healthy. The remaining 250 rps that miss the cache fall back to a Postgres read replica in region-B.
This achieves < 1% error rate during the outage: cache hits are zero error, and the 5% miss traffic routes to the replica rather than the dead primary. Write availability (new link creation) degrades gracefully during the outage and recovers when the primary returns.
Overall defense
This design handles 5,000 redirects/sec with a CDN absorbing 60% of traffic at the edge, three url-api replicas behind a load balancer giving 9,000 rps headroom, and a Redis cache at 99% hit ratio so the postgres primary (3,000 rps cap) only sees ~50 rps of cache misses. During a viral spike the CDN and api replicas absorb the 3× load spike with headroom. A database crash fails over automatically to the postgres replica in region-b via same-kind sibling resolution, keeping error rate under 1%. The region-A outage is survived because the postgres replica in region-b serves read traffic while writes are paused — acceptable for a read-heavy shortener.
The choices that separate a passing design from full credit.
Redis redirect cache is the critical fix
Without the cache, every redirect hits Postgres at 5k rps — but Postgres can only handle ~3k rps on a reasonably sized instance. The cache is not an optimization; it is the design. At a 95% hit ratio, Redis reduces Postgres load from 5,000 rps to ~250 rps, leaving enormous headroom for growth and spikes. The hot set (top 10M codes at ~250B each = 2.5GB) fits entirely in a single r6g.large Redis node — a cost of ~$100/mo buys you the entire read capacity of the system.
Region-B replica serves reads during outage
When region-A Postgres dies, redirect reads continue from two sources: (1) cache hits — still 95% of traffic — served by Redis which is a separate failure domain, and (2) cache misses — 5% of traffic — routed to the read replica in region-B. The outage only affects write operations (new link creation), not existing redirects. The error rate during the outage is bounded to less than 0.1% — only requests for codes that have never been cached AND whose lookup reaches a dead primary could fail, and those are a tiny fraction.
Cache-aside vs write-through
The redirect cache uses cache-aside (lazy population): on a cache miss, the app reads from Postgres and populates Redis. Write-through (populate cache on every write) would require the shorten endpoint to also write to Redis — acceptable but unnecessary given the low write volume. Cache-aside naturally populates only the codes that are actually accessed, which matches the Zipf distribution perfectly. TTL of 24h for hot links; shorter TTLs for links with expiry dates. On Redis cold start (restart or failover), all 5k rps hit Postgres until the cache warms up — design for this transient spike by sizing Postgres to survive it.
Click counting at scale
HINCRBY (or INCR on a per-code counter) is atomic and sub-millisecond. Writing to the click_stats table on every redirect at 5k rps would generate 5,000 writes/s to Postgres — saturating its IOPS budget on analytics data that doesn't need real-time precision. Instead, batch flush every 60 seconds via GETDEL (atomic read-and-clear) and an UPSERT into click_stats. Worst-case analytics data loss on Redis crash: 60 seconds. This is entirely acceptable for a marketing dashboard — it is not a financial ledger.
What most candidates get wrong on this challenge.
Adding a DB read replica to serve redirects instead of Redis — a replica doubles read capacity but 99% of redirects are for the same top-linked codes; the Zipf distribution means cache hit ratio makes Redis 100x more effective than a replica for reads. A replica helps with cold-start safety (outage scenario), but it is not the primary read solution.
Caching at the CDN for 307 Temporary Redirects — browsers do not cache 307 responses. Use 301 Permanent for links without expiry (browsers cache indefinitely, maximally offloading origin) or 302 Temporary only when expires_at is set. Mischoosing the status code breaks your entire CDN caching strategy.
Using a SQL UPDATE on click_stats on every redirect instead of Redis INCR — at 5k rps, even with batching this overwhelms Postgres write IOPS. The in-memory counter (INCR) absorbs 100% of click events at sub-ms cost; Postgres only sees 1 UPSERT per code per 60 seconds.
Forgetting the rate limiter on POST /shorten — without it, a single IP can create millions of links and exhaust the 7-character base62 namespace in hours, or at minimum consume storage and DB write capacity. The rate limiter (20/hour anonymous, 1000/hour authenticated) is a required correctness constraint, not a nice-to-have.
Expand each criterion to see the exact bar.
Full credit requires: Redis cache present with a stated hit ratio (95% or higher), hot-set size estimated (10M codes × 250B = 2.5GB), Postgres utilization during spike calculated (15k × 5% miss = 750 rps — within capacity). The canvas must show no component above 80% utilization at 15k rps. Partial credit if cache is present but hit ratio is asserted without a number.
Full credit requires naming the hit ratio target AND the assumption behind it (Zipf distribution / top-N codes = X% of traffic). Must also describe the miss path: what happens when Redis doesn't have the code (read Postgres, populate Redis). Must note what happens on Redis cold start (all misses hit Postgres transiently). Partial credit if cache is present but justified only with "Redis is fast."
Full credit requires: read replica in region-B on the canvas, connected from the app server's miss path. Justification must compute: 95% hits still work (Redis up), 5% misses route to replica → < 1% error rate. Must acknowledge that write operations (new URL creation) fail during the outage — this is acceptable per the NFR (< 1% errors on redirects, not on shortens). Partial credit if replica is present but the math isn't shown.
Full credit requires line-item cost that comes in under $3,500/mo, with each component size tied to a specific bottleneck. Reference design: Redis 2× r6g.large ($200) + Postgres primary + replica ($300) + 4× app servers ($240) + LB ($20) = ~$760/mo. Adding unnecessary components (CDN origin shield, multiple Redis clusters, etc.) without justification is over-provisioning and loses points.
Full credit requires trade-offs argued, not just conclusions stated. Must cover: (1) why Redis over a replica for reads (Zipf distribution math), (2) why 301 vs 302 (cacheable vs. not, based on expiry), (3) code generation strategy chosen and alternatives rejected with reasoning, (4) what happens during region-A outage (< 1% errors explained). Claims must not contradict the sim output.
The same phase-by-phase guide shown during solving — with answers.
Phase 1 — Scope & Estimate
Anchor the design in real numbers before touching the canvas. The single most important skill in system design is knowing which number drives every other decision. For this challenge, it's the read/write ratio.
Anchor the design in real numbers before touching the canvas. The single most important skill in system design is knowing which number drives every other decision. For this challenge, it's the read/write ratio.
What does the 100:1 read/write ratio tell you before anything else?
show answerhideIt tells you that 5,000 of the 5,050 rps are redirects, not shortens. Your entire architecture is optimized for reads. Writes can afford to be 10× slower — nobody notices 200ms to shorten a URL.
How big is the dataset? Does it fit in memory?
show answerhide100M URLs × 200B = 20GB. Doesn't fit in RAM but is trivial for Postgres. The HOT SET is what fits in Redis: top 1% of codes (1M) handle ~80% of traffic via Zipf distribution. Top 10M handles ~95%. That's 2.5GB — fits in a single Redis r6g.large.
62^7 = 3.5 trillion codes. What problem does this solve?
show answerhideExhaustion is not a problem — at 4M new URLs/day it takes 2,400 years to exhaust. Collision at 100M codes is ~0.003%. This means you can use random generation with a simple retry-on-collision rather than needing a distributed ID service.
What is your latency budget per component hop?
show answerhide< 100ms p99 total. Subtract: ~5ms network, ~2ms app server, ~1ms Redis, ~5ms Postgres (on cache miss). That's ~87ms headroom. The constraint isn't math — it's that Postgres on EVERY redirect eats the budget in the miss case.
Deliverable
A back-of-envelope note (in your overall defense): hot-set size, required cache hit ratio to keep Postgres under 500 rps, and your code generation strategy. Write this BEFORE placing anything on the canvas.
Phase 2 — The Redirect Path (Hot Path)
Design the path that handles 5,000 rps with < 100ms p99. Every component on this path either earns its place or cuts it. This is where most engineers under-design (no cache) or over-design (CDN + Redis + Postgres replica for a 5k rps system that needs none of that).
Design the path that handles 5,000 rps with < 100ms p99. Every component on this path either earns its place or cuts it. This is where most engineers under-design (no cache) or over-design (CDN + Redis + Postgres replica for a 5k rps system that needs none of that).
Where does the redirect resolve — CDN edge, Redis, or Postgres? In what order?
show answerhideTiered: CDN (for static links) → Redis (hot set) → Postgres (on miss). Each tier serves different traffic shapes. CDN handles globally popular links and browser-cached responses. Redis handles the hot set at 5k rps. Postgres is only hit on cold misses (~250 rps at 95% hit).
Should you return 301 (permanent) or 302 (temporary)?
show answerhide301 tells browsers to cache indefinitely — the user's browser never hits your server again. Great for throughput, bad for link updates or expiry enforcement. 302 forces a server round-trip every time. The right answer: 301 when no expiry is set, 302 when expires_at is configured. A returning visitor with a cached 301 skips your whole stack — design for this.
What is your target cache hit ratio, and how do you compute it?
show answerhideZipf distribution: top 1M codes ≈ 80% of traffic. Top 10M ≈ 95%. Cache 10M codes at 250B each = 2.5GB Redis. At 95% hit: 250 rps reach Postgres. At the viral spike (15k rps), 5% miss = 750 rps — still fine for a read replica. Set your cache hit ratio slider to 0.95 and justify the assumption in the Redis justification.
What happens during a Redis cold start (restart or failover)?
show answerhideALL misses hit Postgres. 5k rps × 0% hit = 5k read rps. Will your Postgres survive that? (It can if you size it right — but it's worth naming in your justification as a known spike scenario with a TTL warm-up time of ~few minutes.)
Deliverable
Clients → LB → App Server → Redis on the canvas. The miss path connecting App Server → Postgres (read replica in region-B for HA). Redis hit ratio slider set to 0.95 with justification explaining the Zipf assumption.
Phase 3 — The Write Path & Code Generation
Design how new short codes are created. 50 rps — correctness matters more than speed. The critical question is collision avoidance and what you do when two simultaneous shortens race on the same custom code.
Design how new short codes are created. 50 rps — correctness matters more than speed. The critical question is collision avoidance and what you do when two simultaneous shortens race on the same custom code.
How do you generate a unique 7-character base62 code?
show answerhideFour options: (A) random base62 + collision check via INSERT/SELECT, (B) hash(long_url) truncated to 7 chars, (C) auto-increment ID → base62 encode, (D) pre-generated pool in Redis. (A) is simplest and correct at this scale — collision probability is ~0.003% at 100M codes. (C) leaks your URL count to competitors. (B) gives same code for same URL (breaks per-user analytics). Which fits?
Custom vanity codes — what's your race condition strategy?
show answerhideINSERT INTO urls (code, ...) ON CONFLICT (code) DO NOTHING — then check rows affected. 0 rows = conflict, return 409. Simple and correct. The DB constraint is the lock. No need for distributed locking at 50 write rps.
Should the write path be fast? What if it takes 200ms?
show answerhide50 rps of shortens → users wait a bit for a new link. 200ms is totally acceptable. Reserve your optimization budget for the redirect path. Don't add a queue or async workers here — that's premature.
Deliverable
The write path shares the app server from Phase 2 (same process, different endpoint). No separate write service needed. Note in your overall defense which code generation strategy you chose and why you rejected the others.
Phase 4 — Availability: Region-A DB Outage
Design for the db-crash scenario: region-A Postgres goes down for 60 seconds. Redirects must still work with < 1% error rate. This is where caching and replicas work together — you need both, and you need to understand exactly which rps each handles.
Design for the db-crash scenario: region-A Postgres goes down for 60 seconds. Redirects must still work with < 1% error rate. This is where caching and replicas work together — you need both, and you need to understand exactly which rps each handles.
What keeps redirects working when region-A Postgres is down?
show answerhideCache hits still work — Redis is in region-A but it's a separate process (separate failure domain from Postgres). At 95% hit ratio, only 5% × 5k = 250 rps miss. Those 250 rps need a fallback. That fallback is a Postgres read replica in region-B.
Does the read replica catch miss traffic automatically, or do you have to route it?
show answerhideYour app server's connection string needs to point at the replica for reads. Either: (A) two connection pools (primary for writes, replica for reads) with automatic failover, or (B) a Postgres proxy like RDS Proxy or PgBouncer that handles failover transparently. The app server should not need to know region-A is down.
What about write availability during the outage?
show answerhideThe primary is in region-A. If region-A is down, shorten endpoints fail. The NFR says < 1% error rate on REDIRECTS, not shortens. It's acceptable for new URL creation to fail during a 60s outage. Say this explicitly in your justification — it's a valid trade-off.
What is your recovery story when region-A comes back?
show answerhidePostgres replication lag was accumulating during the outage. When region-A returns, the primary needs to catch up. During catch-up, reads from the primary might be slightly stale vs. the replica. Mention this transient inconsistency window in your justification.
Deliverable
A Postgres read replica in region-B on the canvas, connected from the app server's miss path. Your justification must explain: why < 1% error rate is achievable (cache absorbs 95%, replica absorbs the 5% miss), and what happens to the 60-second write outage window.
Phase 5 — Operations & Cost
Verify the design is within the $3,500/month budget and has a credible ops story. Over-provisioning costs points — every component's size must be justified by a specific bottleneck, not defensive padding.
Verify the design is within the $3,500/month budget and has a credible ops story. Over-provisioning costs points — every component's size must be justified by a specific bottleneck, not defensive padding.
What does your infrastructure cost, component by component?
show answerhideRedis 2× r6g.large (HA pair): ~$200/mo. Postgres primary + replica (r6g.large each): ~$300/mo. App servers 4× t3.medium: ~$240/mo. Load balancer: ~$20/mo. Total: ~$760/mo. You have $2,700 headroom. Do not add components to consume it — you'll lose points for unjustified over-provisioning.
How do your components hold up at the 3× spike (15k rps)?
show answerhideRedis: trivially scales (INCR is O(1)). App servers: add 2 more during spike via autoscale or pre-size to ×3. Redis cache: 15k rps × 5% miss = 750 rps to Postgres — still fine. The only thing that could fail: your LB maxRps limit and app server maxRps. Run the sim to verify no component exceeds 80%.
What monitoring do you add on day one?
show answerhideCache hit ratio (alert below 90% — cold-start risk), Postgres connection count (alert near pool limit), Redis memory (alert at 70% — prevents eviction kicking in), and p99 redirect latency (alert above 80ms — 20ms buffer before SLO breach).
Deliverable
Component sizes finalized with the monthly cost in the top bar matching your expectation (~$760/mo reference design). Overall defense written — it should mention: hot set size, hit ratio assumption, code generation strategy, region-A failover story, and cost breakdown.
Ready to test your design?
Open the canvas, place your components, and run the failure scenarios to get graded.