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
5,000 rps
Spike multiplier
3×
Budget
$3,500/mo
Read ratio
99:1
Load profile
You're a founding engineer at a startup building enterprise link management. Product just got featured in TechCrunch. Your link system needs to handle 5,000 redirects per second at steady state — spiking to 15,000 when a link goes viral on social media. Marketing wants per-link click analytics. SRE wants five-nines availability. The CFO wants it under $3,500/month. The hard part isn't the system — it's knowing which numbers dictate every other decision before you write a single line.
Functional
URL to a unique 7-character codeURLs to the original with < 100ms p99 latencyURL (eventually consistent is fine)Non-functional
/sec steady stateregion-A database outage with < 1% error rateThe endpoints your system must implement. The hot path is the one the SLO is measured on.
Request body
| field | type | notes |
|---|---|---|
| url | string | The long URL to shortene.g. https://very-long-url.com/path?campaign=launch&source=social |
| custom_code? | string | Optional 7-char vanity codee.g. mylink1 |
| expires_at? | string | ISO 8601 expiry timestampe.g. 2025-12-31T00:00:00Z |
Response body
| field | type | notes |
|---|---|---|
| code | string | The 7-character short code |
| short_url | string | Full short URL (https://short.ly/{code}) |
| expires_at | string | When the link expires, if set |
Status codes
Idempotent: the same long URL submitted by the same authenticated user returns the existing code. Writes are only 1% of traffic — they can be slow. Focus your design on reads.
Path params
| field | type | notes |
|---|---|---|
| code | string | 7-character short codee.g. abc1234 |
Status codes
HOT PATH — 5,000 rps. Response header: Cache-Control: public, max-age=86400. Your design must answer: where does a redirect resolve — CDN edge, Redis, or Postgres? In what order? What happens on cache miss?
Path params
| field | type | notes |
|---|---|---|
| code | string | 7-character short code |
Response body
| field | type | notes |
|---|---|---|
| code | string | |
| total_clicks | integer | |
| created_at | string | |
| clicks_by_day | object | Map of date string to click count |
Status codes
Analytics are eventually consistent — click counts are buffered in Redis and flushed to Postgres every 60s. A 60s staleness window is acceptable for a marketing dashboard.
The core record — maps a short code to a long URL. This is the table every redirect resolves against (on cache miss). Design the primary key so a lookup by code is a single index scan.
| column | type | constraints | notes |
|---|---|---|---|
| code | varchar(7) | PK · IDX | 7-char base62 code; PK ensures O(log n) lookup |
| long_url | text | — | Uncapped — URLs can exceed 2000 chars |
| user_id? | uuid | IDX · FK→users.id | NULL for anonymous shortens |
| created_at | timestamptz | — | DEFAULT now() |
| expires_at? | timestamptz | — | NULL means never expires; checked on every redirect |
| is_active | boolean | — | DEFAULT true; soft-delete for expired or abused links (→ 410) |
capacity · 100M rows × 200B ≈ 20GB data + ~10GB indexes. Single large Postgres instance handles this. You need a read replica for HA, not sharding.
Per-code daily click rollup, fed by the Redis counter flush job. Never write here on every redirect — use the Redis INCR pattern instead.
| column | type | constraints | notes |
|---|---|---|---|
| code | varchar(7) | FK→urls.code | FK to urls.code |
| date | date | — | Truncated to UTC date |
| click_count | bigint | — | Accumulated clicks; flushed from Redis every 60s via GETDEL |
capacity · 4.3M new URLs/day × 365 days/year = 1.6B rows/year. Partition by date or cap retention at 90 days (most links go cold after a week).
Key patterns, TTLs, and commands. Your design must justify the hotness-critical keys.
Long URL for a given code — the hot redirect cache.
GET redirect:{code} # hit → return 301; miss → read Postgres → SET below
SET redirect:{code} {long_url} EX 86400 # populate on miss; NX optional if you want write-once
DEL redirect:{code} # invalidate on deactivation or expiry update
rationale · 99% read ratio makes aggressive caching mandatory. At a 95% hit ratio, only 250 rps reach Postgres — well within its capacity. Hot set: top 10M codes ≈ 2.5GB, fits in a single r6g.large Redis node with headroom. TTL at 24h balances freshness with hit ratio; long-lived viral links get re-cached on miss without operator intervention.
In-memory click counter; flushed to click_stats every 60s.
INCR clicks:{code} # on every redirect hit — O(1), never blocks
GETDEL clicks:{code} # atomic read-and-clear during flush job
UPDATE click_stats SET click_count = click_count + $delta WHERE code=$code AND date=today ON CONFLICT DO UPDATE ...
rationale · A Postgres write on every redirect at 5k rps would saturate DB write IOPS immediately. Buffer in Redis (INCR is O(1), sub-ms) and flush in batch every 60s. Worst-case analytics data loss on Redis crash: 60s. Acceptable for a marketing dashboard — it's not financial ledger data.
Rate limiter for anonymous URL shortening requests by IP.
INCR rate:shorten:{ip_hash}
EXPIRE rate:shorten:{ip_hash} 3600
# Authenticated users: key is rate:shorten:uid:{user_id}, limit 1000/hour
rationale · Prevent abuse of the shorten endpoint. Anonymous: 20/hour. Authenticated: 1000/hour. The INCR+EXPIRE pattern is a fixed-window counter — simple and fast. For a URL shortener the precision is fine; use sliding window only if clients complain about boundary bursts.
Pre-computed numbers to anchor your justifications. Use these — the grader checks your claims against them.
traffic
432M/day
Redirects per day
= 5,000 rps × 86,400 s/day
traffic
4.3M/day
Write volume (new URLs)
= 50 rps (1% of 5k) × 86,400 s
capacity
3.5 trillion unique codes
Code space (7-char base62)
= 62^7 = 3,521,614,606,208
storage
~200 bytes
Storage per URL record
= 7B code + 100B avg URL + 93B metadata
storage
~20GB
Storage at 100M URLs
= 100M × 200B + indexes ≈ 30GB total
storage
~2.5GB
Hot-set cache (top 10M codes)
= 10M × 250B/entry — fits in a single r6g.large Redis
traffic
250 rps
Postgres load at 95% cache hit
= 5% miss × 5,000 redirect rps → well within a db.r6g.large
cost
~$760/mo
Reference infra cost
= Redis ×2 $200 + Postgres primary+replica $300 + app ×4 $240 + LB $20
Work through these phases in order before submitting. Each phase builds on the last.
Phase 1 — Scope & Estimate
10 min · Anchor the design in real numbers before touching the canvas. The single most im…
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?
It 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?
100M 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?
Exhaustion 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?
< 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)
15 min · Design the path that handles 5,000 rps with < 100ms p99. Every component on this…
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?
Tiered: 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)?
301 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?
Zipf 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)?
ALL 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.
Common pitfall
Putting Postgres directly on the redirect hot path with no cache. At 5,000 rps, even a db.r6g.2xlarge gets saturated. A cache is not optional on a 99%-read workload — it's the design.
Phase 3 — The Write Path & Code Generation
10 min · Design how new short codes are created. 50 rps — correctness matters more than s…
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?
Four 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?
INSERT 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?
50 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
15 min · Design for the db-crash scenario: region-A Postgres goes down for 60 seconds. Re…
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?
Cache 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?
Your 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?
The 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?
Postgres 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.
Common pitfall
Assuming the cache fully absorbs the outage. At 5% miss rate and 5k rps, 250 rps miss AND hit Postgres. Those all error if region-A is down. That's 5% error rate — fails the rubric. You need the replica.
Phase 5 — Operations & Cost
10 min · Verify the design is within the $3,500/month budget and has a credible ops story…
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?
Redis 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)?
Redis: 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?
Cache 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.
The redirect path must serve 5k RPS steady and 15k during the spike without saturation.
Full credit
No component exceeds 80% utilization during the spike; p99 stays under 100ms.
Partial
Brief saturation during the spike but error rate stays under 2%.
Zero
Sustained saturation or >5% dropped requests in steady state or spike.
A 99% read workload demands a cache layer. The justification must name the hit ratio target, the hot-set size assumption, and what happens on miss.
Full credit
Cache present, sized sensibly; justification covers hit ratio (with a number), hot-set estimate, and miss-path behavior.
Partial
Cache present but justification is superficial — asserts "high hit ratio" without a number.
Zero
No cache, or cache present with no justification at all.
The db-crash scenario kills every region-A database for 60s. Reads must continue via replicas in other regions or warm cache.
Full credit
Error rate under 1% during the outage — replica or cache absorbs the miss traffic.
Partial
Degraded but bounded (1-5% errors) with a credible fallback described in the justification.
Zero
Redirects hard-fail for the full outage window.
Total monthly cost under $3,500; over-provisioning beyond justified headroom loses points.
Full credit
Under budget with roughly 2x headroom; each component's size is tied to a specific bottleneck.
Partial
Under budget but with unjustified over-provisioning, or marginally over by < 10%.
Zero
More than 30% over budget.
Justifications must state trade-offs — why this store, why this cache size, what alternatives were rejected. Numbers must match the sim results.
Full credit
Each major component justified with a trade-off and a number; no claims contradicted by the sim.
Partial
Justifications present but assert conclusions without arguing them ("Redis for speed").
Zero
Missing or boilerplate justifications with no reasoning.
Each scenario fires automatically during your simulation run. Your design must survive all of them.
Launch day steady state
A link goes viral (3x traffic)
Region A database outage
Ready to build it?
Best on desktop — the canvas needs room to breathe. Drafts autosave locally.