URL Shortener
The system-design interview's most famous first problem. We treat it as a scale-evolution study: four architectures for the same problem, from 10K to 1B RPS.
The scenario
Where every real system design starts
Alright. Sit down. Let me set the scene the way it actually happens in real life, because most tutorials skip this part and it's the most important part.
You've just joined a startup as engineer #4. Your CTO drops by your desk on a Monday morning with coffee in hand and says: "We need a URL shortener. Like bit.ly. Something we can offer as a service to marketing teams. Ship an MVP in 8 weeks. Handle whatever traffic comes. Oh — and it needs to be reliable, we're pitching to Fortune 500 customers."
She walks away.
Now — what do you do?
Ninety percent of engineers (junior AND senior, honestly) do the wrong thing here: they open a whiteboard and start drawing boxes. Load balancer. Cache. Database. Kafka for some reason. It's a reflex.
Don't.
The first hour of any system design is not architecture. It's understanding what you're actually being asked to build. Because "URL shortener" is a wildly under-specified problem. Is this internal or public? Analytics or no analytics? Custom aliases? Rate limits? Retention forever, or expire after 30 days?
Every one of those questions changes the architecture. If you draw the architecture first and THEN answer them, you're going to redesign three times.
The whole journey at a glance
Before we dive in, here's where these 17 chapters are actually going. Every 10× in scale forces a fundamentally different architecture. Play with the interactive evolution below — click each tier, watch the components stack up, read the added-in-this-tier badges:
The 4 scales you'll design. Every 10× forces a new architecture.
Click a tier to focus it. Auto-plays through all 4 tiers. Each tier ADDS to the previous — architecture doesn't get rewritten.
8-week MVP timeline · 3 engineers hold it in their heads. Single Postgres Multi-AZ handles 10K RPS at 30% CPU with room to spare. No cache. No microservices. No Kubernetes. This is where half the internet lives — forever.
Chapter map: Ch 5 walks through L4 in full. Ch 6 + 6.5 walks through the cache decision, cache-aside flow, and read replicas. Ch 7 + 7.5 + 7.6 walks through global topology, analytics, and security defense-in-depth. Ch 8 walks through L7 as business strategy, not tech.
That's the whole arc. Each scale added something the previous scale couldn't handle. Chapter 5 walks you through building L4 from scratch. Each subsequent chapter shows what BREAKS at the next tier and what you add to fix it — never a rewrite, always an evolution.
Why URL shorteners exist — 20 years of scaling history
Before you design a URL shortener, know why they were invented and how the design has evolved. The interview question "design a URL shortener" is really asking "can you build a Bit.ly?" — and the answer only makes sense if you know the history that shaped what a modern URL shortener actually is. Play through the interactive timeline below — it auto-plays through 20 years of scaling history, or click any era to focus:
The story that shaped the design. Play, click, or scrub through the timeline.
Auto-playing at 3.5s / event. Click any era below to jump; click any event to focus it.
TinyURL launches
Kevin Gilbertson builds first widely-used shortener
Kevin Gilbertson's workaround for long affiliate links that broke email clients when wrapped. Simple counter → Base36 encoding. Free. No accounts. Anyone could use it.
- 1Analytics is the product, not shortening
Every serious shortener from 2008 onward sold analytics. That's why we use 302 (not 301), why we build Kafka-based click pipelines, and why L7 makes 100× more revenue than L4.
- 2Abuse detection is non-negotiable
TinyURL's decline + goo.gl's shutdown both trace to abuse. Modern shorteners have Google Safe Browsing + real-time scanning + rate limiting from day one.
- 3Scale is monetization
L4 = hobby project. L7 = $50-500M/yr business. Every tier unlocks a new customer segment (individual → SMB → enterprise → platform).
The three lessons that shape the modern architecture:
- Analytics is the product, not shortening. From 2008 onward, every serious shortener sold click analytics. That's why we use 302 not 301 (so every click reaches your server), why we build Kafka-based click pipelines at scale (Ch 7.5), and why L7 sells for 100× more revenue than L4.
- Abuse detection is non-negotiable. TinyURL's decline + goo.gl's shutdown both trace to abuse. Modern shorteners have Google Safe Browsing integration + real-time URL scanning + rate limiting from day one. Ch 7.6 covers this.
- Scale is monetization. At L4 you have a hobby project. At L7 you have a $50-500M/yr business. Every scale tier unlocks a new customer segment (individual → SMB → enterprise → platform). Chapter 8 is really about business strategy, not technology.
So here's the discipline I want you to build in this course: before you draw anything, you ask questions. That's Chapter 1. We're going to walk through exactly what to ask, and — this is the part most tutorials skip — WHY each question matters, because if you don't understand why, you'll ask them mechanically like a checklist and get useless answers.
Ready? Let's go.
System design begins with understanding requirements, not drawing boxes. Every 'obvious' problem is under-specified until you ask questions.
- Why do we ask clarifying questions before designing?
- What's the risk of jumping straight to architecture?
- What does 'under-specified' mean in a system design problem?
In Chapter 1 we do the questioning. I'll show you the exact 6 questions to ask a URL shortener interviewer, and — crucially — why each one matters. Skip the 'why' and you're just checklist-driven; understand the 'why' and you sound like an architect.
How systems break as you scale
The SCALE → BOTTLENECK → REDESIGN loop, visualized
Before we start designing URL Shortener, I want to answer the one question that separates good candidates from great ones:
Why does the architecture change as traffic grows?
Most tutorials just show you the "final architecture at 1B RPS" and call it a day. That's memorizing the answer. You need to understand why each layer exists — because each layer exists to unblock a specific bottleneck that the previous architecture had.
The whole game of system design is a loop:
```
SCALE ↑ → Something BREAKS → Add a REDESIGN → new BOTTLENECK → next REDESIGN → ...
```
You start at 100 RPS on a single VM. You grow to 10K RPS and the database melts — you add a cache. You grow to 1M RPS and the regional LB caps out — you go multi-region. You grow to 1B RPS and network round-trip becomes the wall — you move compute to the edge.
Every senior engineer has this loop internalized. They see any system and can immediately tell you which component will break next, and why.
The interactive simulator
Drag the RPS slider across 8 tiers — from 100 RPS to 1 BILLION RPS. Watch:
- The architecture changes — components appear and disappear
- Traffic flows through each component (animated dots)
- Each component shows a capacity gauge — green (healthy), yellow (stressed), red (BROKEN)
- When something breaks, the simulator explains what broke and why, and what redesign fixes it
Traffic Flow Simulator — 100 RPS → 1B RPS
Watch how the architecture morphs as traffic scales. Green = healthy · Yellow = stressed · Red = BROKEN with redesign call-out.
The 8 tiers explained
| Tier | Architecture | What breaks next |
|---|---|---|
| 100 RPS | Single VM (app + DB on one box) | Nothing yet. You have room. |
| 1K RPS | LB + 3 apps + PG | App CPU. Scale apps horizontally. |
| 10K RPS | LB + 15 apps + PG primary | 🚨 MySQL primary saturates at ~5K reads/sec. Add cache. |
| 100K RPS | + CDN + Redis + read replicas | Regional bandwidth. Cache works — buys 10× headroom. |
| 1M RPS | + Sharded MySQL (Vitess) + Redis cluster + Kafka | 🚨 Regional LB caps at ~500K RPS. Also: cross-continent latency. Go multi-region. |
| 10M RPS | Multi-region active-active + GeoDNS | Cross-region write consistency. |
| 100M RPS | + Edge compute (Cloudflare Workers) | Origin escape hatch for the 1% that can't be edge-cached. |
| 1B RPS | Own CDN + tiered storage + real-time analytics | Physics. Not engineering. |
The 5 patterns you'll see over and over
Study these until you can spot them without thinking:
- Horizontal scaling — take the bottleneck component and run N copies with a load balancer. Works until the component behind it (usually DB) becomes the new bottleneck.
- Caching — put a faster tier in front of a slower one. Trade fresh data + memory for latency + throughput. The "92% hit ratio" is not lucky — it's what happens when your access pattern has locality.
- Sharding — split state into shards, route each request to its shard. Works until you have cross-shard queries or need atomic multi-shard writes.
- Async decoupling — move slow work off the hot path via a queue. Users get 202 Accepted immediately, worker processes when it can. Cost: eventual consistency.
- Multi-region — the last redesign, the most expensive. Data has to live in multiple places, which means CAP theorem is in your face.
Each of the following chapters teaches ONE of these patterns. By the end of Chapter 12, you'll be able to enter the simulator at any RPS tier and mentally sketch the architecture without help.
Newbie insight: the simulator's break-point explanations are the CORE of what interviewers are testing when they ask "what happens at 10× the current load?" The senior engineer doesn't recite an architecture — they walk through the failure mode + the redesign. Practice this out loud.
System design is a loop: SCALE → BOTTLENECK → REDESIGN → new bottleneck → next redesign. 5 patterns solve most problems: horizontal scaling, caching, sharding, async decoupling, multi-region. Every 10× in traffic reveals a new bottleneck; the interviewer wants to see you name the failure mode and the fix.
- Why does the architecture change as traffic grows?
- What are the 5 patterns that solve most scale problems?
- At 10K RPS, what's the bottleneck? At 1M?
- What's the SCALE → BOTTLENECK → REDESIGN loop?
- Which component breaks first as you cross each 10× threshold?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The full framing of the loop that this simulator visualizes. Every problem in the platform follows this loop.
The math that tells you WHICH tier you're at — feeds directly into which slider position to start from.
Chapter 1: the requirements. Now you've seen the whole map. Let's go back to the beginning — the 6 questions you ask before you touch a whiteboard. Each question we ask maps to which tier of the scale slider we end up at.
Ask before you design
Requirements & clarifying questions
Good. You're back. Let's talk about the six questions.
I'm going to walk you through every question I would ask if I were interviewing for a Staff role at Google right now. Each one comes with the "why" — what changes in the architecture based on the answer. Learn the "why" and you'll never need to memorize a checklist again.
Notice the pattern: every question either (a) changes what components appear in your architecture, (b) changes the scale you're designing for, or (c) changes the trade-off you're forced to make. If a question doesn't do one of those three things, don't ask it — you're wasting interview time.
Read through the questions below. For each one, before you look at the "why," ask yourself: what would change if the answer flipped? If you can articulate that, you've earned the right to ask the question.
Now — while we're here, let me also drop in the requirements. Functional (what it does) and non-functional (how well it does it). These aren't guesses. They're the result of the clarifying questions. You ask questions → you get answers → the requirements crystallize. Then and only then do you touch a whiteboard.
Clarifying questions
- 1What's our expected scale? (Drives the entire architecture.)
- 2Do we need custom aliases, or is auto-generated fine?
- 3Do we need real-time analytics or is a daily rollup enough?
- 4Is single-region acceptable, or do we need global?
- 5What's our availability target? (99.9%? 99.99%?)
Functional
- Create a short URL for a given long URL, optionally with a custom alias
- Redirect a short URL to the corresponding long URL
- Optional: analytics — click count, geo, referrer
Non-functional
- Availability: 99.99% at 1B RPS scale, 99.9% at 10K
- Redirect latency: p99 < 100ms at 10K → < 10ms at 1B (edge)
- Write throughput to grow linearly with scale tier
- URLs are effectively immutable — no updates after creation (simplifies caching)
Six questions, each tied to a specific architectural consequence. Functional requirements = what the system does. Non-functional = how well. Both fall out of the clarifying questions.
- What are the 6 clarifying questions for a URL shortener?
- Why does each question change the architecture?
- What's the difference between functional and non-functional requirements?
- Why should you ask questions before drawing?
Chapter 2: the math. Now that we know WHAT to build, we need to know HOW MUCH. Back-of-envelope estimation. This is where good candidates lose interviews — they either skip the math or do it wrong. I'll show you the derivation so you never memorize numbers, only formulas.
Do the math
Back-of-envelope capacity estimation
This is the chapter where I tell you the truth: capacity estimation is not optional, and most candidates blow it.
Not because it's hard. Because they don't understand it as reasoning, so they memorize numbers ("Twitter has 500M users") and can't derive anything else.
Here's the mental model I want you to build: capacity estimation is a funnel of ratios.
- Start with the top: how many people use the product?
- Multiply by activity: how many requests per person per day?
- Divide by seconds: what's the average requests per second?
- Multiply by peak factor: what's the peak?
- Add storage math: bytes per request × requests per day = storage per day
- Add bandwidth math: bytes per response × requests per second = bandwidth
If you know the funnel, you can estimate any system in 3 minutes. You are not memorizing numbers. You are memorizing a process.
Here's the funnel visualized — every row is derived from the row above by ONE arithmetic operation. Drag the sliders below to see how each row responds:
Drag the inputs. Watch the funnel derive peak RPS + storage + bandwidth.
Each row is derived from the row above by ONE arithmetic operation. Trace through it — memorize the process, not the numbers.
The rule: every 10× in RPS forces the architecture up one tier. Try dragging DAU from 1M to 1B — watch the tier recommendation walk L4 → L5 → L6 → L7.
Notice how each row implies the next. You cannot skip a step. The DAU number sets the whole funnel. The peak factor (usually 2-3×) is where amateurs fall off — they design for average and their system falls over at 12:30pm every day.
From RPS to "how many boxes?"
The funnel above gives you peak RPS. But that number alone doesn't tell you how much infrastructure to spin up. The missing step — the one most tutorials skip — is:
peak_rps × safety_margin ÷ (rps_per_box × cpu_utilization) = box count
Let me unpack each term, because this is where senior candidates separate from the pack:
- rps_per_box — how many requests a single instance can serve, empirically. For a URL shortener redirect (mostly a MySQL primary-key lookup), a Node.js or Go service on an
m5.largehandles roughly 1500-2000 RPS at 70% CPU. Reference: TechEmpower Round 22 benchmarks. Get this wrong and every other estimate is off. - cpu_utilization — you never run boxes at 100% CPU. Above ~80% CPU, latency spikes non-linearly (queueing theory) — one bad GC pause and you drop requests. Target 60-70% as the steady-state ceiling.
- safety_margin — buffer for the "oh no" moments: a viral traffic spike, a bad rollout that halves capacity, one AZ failing over. 1.5× is the industry-standard minimum; senior engineers use 2× when the workload is variable.
- HA floor — even if the math says you only need 2 boxes, Multi-AZ availability requires a minimum of 3 (one per AZ, plus one for rolling deploys without going below 2 healthy). This is where you get the "3 EC2 medium instances will be sufficient" answer you'll see quoted throughout this course — it comes from the HA floor at the L4 / MVP scale.
Play with the calculator below. Notice how the answer changes when you flip between L4 (10K RPS), L5 (100K RPS), and L6 (1M RPS) presets:
How many EC2 boxes do we need?
The missing step of the estimation funnel. Tune the assumptions, watch instance count + cost update live.
The interview soundbite: "I'd size the app tier at ⌈peak_rps × 1.5 ÷ (1500 × 0.6)⌉ boxes, with a minimum of 3 for Multi-AZ HA. At 100K peak RPS that's 167 required by math, so 167 m5.large boxes — about $12K/month on-demand, or $4K/month on 3-year reserved. The database is a separate calculation because it's stateful."
Below you'll see the actual numbers for our URL shortener at scale. Trace through them. Understand where each number comes from. If you can't derive one, come back to this chapter until you can. The derivation is the skill; the numbers are just outputs.
One thing many candidates miss: peak RPS is usually 2-3x average. Traffic isn't uniform across a day — everyone reads Twitter at lunch. If you design for average, you fall over at 12:30pm. Design for peak.
Also — and this is where senior candidates separate: your estimation directly implies your architecture. 10K RPS is a single MySQL. 100K RPS needs a cache. 1M RPS needs sharding. If you can't map estimation to architecture in your head, you're not ready for Chapter 5.
Assumptions we made (5)
- 100M redirect requests/day → ~1,200 RPS avg, ~10K peak
- 1M new URLs/day → ~10 RPS avg, ~100 peak
- Read:write ratio ~100:1
- Average URL length: 100 bytes; short code 8 bytes; total row ~120 bytes
- Retention: 1 year
Drag the inputs. See what the URL shortener actually costs.
2025 AWS US-East public pricing. Every line item is defensible — hover over it to see the assumption.
| Category | Assumption | Monthly | % of total |
|---|---|---|---|
| Compute | 3× EC2 t3.medium app servers (2 vCPU · 4 GB) — $30/mo each | $90 | 20% |
| Load balancer | 1× AWS ALB — $22/mo + $8/LCU-hour (~$28 at this traffic) | $50 | 11% |
| Primary database | RDS Postgres db.t3.medium Multi-AZ — $100/mo + $50 storage | $150 | 33% |
| Bandwidth | Data-out: 1500 GB/mo × $0.09/GB (AWS egress) | $135 | 30% |
| Ops surface | S3 backups + CloudWatch logs + Route 53 — $30/mo | $30 | 7% |
| Total (monthly) | $455 | ||
Boring works. A single Postgres, a load balancer, 3 app nodes. This is the L4 MVP — costs a couple hundred dollars a month and serves 10K peak RPS reliably. Half the internet runs at this scale forever. You'd design this in an 8-week interview timeline.
Estimation is a derivation, not memorization. DAU × requests/user ÷ 86400 = avg RPS; × peak factor = peak RPS. From RPS derive box count: ⌈peak_rps × safety_margin ÷ (rps_per_box × cpu_target)⌉ with a Multi-AZ HA floor of 3. Every 10× in RPS requires a different architecture tier.
- How do you derive average RPS from DAU?
- Why is peak RPS 2-3x average?
- What's the storage math for a URL shortener at 100K RPS?
- How do you derive the number of EC2 boxes from peak RPS?
- Why does Multi-AZ HA force a 3-box minimum even when math says 1?
- At what RPS do you introduce a cache? A shard?
Chapter 3: the contract. Now that we know the scale and the box count, we need to design the interface. APIs are the contract between your service and the world. Get them right on day 1 and you save yourself 6 months of migration pain later. I'll show you the 3 endpoints and the 4 subtle decisions inside them.
Design the contract
API design — 3 endpoints, 4 decisions
The API is the contract between your service and everyone who uses it. This is the ONLY thing your users see. Everything else — cache, database, sharding, Kafka — is invisible implementation detail. So get this right.
For a URL shortener you need three endpoints. Just three. If you design more, you're over-scoping. If you design fewer, you're missing something.
But inside those three endpoints there are four subtle decisions that separate a junior design from a senior one:
1. What's the redirect status code? 301 or 302? These are NOT interchangeable — one lets browsers cache the redirect forever (analytics undercount by 90%), the other doesn't. Wrong choice = wrong product.
2. How do you handle idempotency? If a client double-taps the "shorten" button, do they get one URL or two? Junior devs let it create two. Seniors design an idempotency-key header on day 1.
3. What's your versioning strategy? /v1/urls or /urls?version=1 or Accept: application/vnd.company.v1+json? All three work; only one is boring, obvious, and forever-compatible.
4. How do you handle authentication? Public endpoint (anyone can shorten) or authenticated (API key)? Both are valid — but they change your rate-limiting strategy and your abuse-prevention story fundamentally.
What the API round-trip actually looks like on the wire
Before you go further, let's watch the actual bytes fly. Here's what happens when a user POSTs a new URL and then somebody clicks the resulting short link. This is the wire-level view — headers, status codes, timing, the whole thing:
Watch the actual bytes fly. CREATE (1%) or REDIRECT (99%) at any cache tier.
Toggle between 4 scenarios · step through each hop · see status codes + latencies + fire-and-forget analytics badges.
- · POST is uncacheable. CDN never caches POST · every write reaches origin.
- · Idempotency-Key check BEFORE code generation. Client double-tap returns same short_code.
- · Fire-and-forget analytics. Kafka publish never blocks the redirect (Google SRE rule).
- · Multi-tier cache. CDN (40%) → Redis (92% of misses) → PG (cold tail).
A newbie should read every arrow. Notice:
- POST is uncacheable. The CDN forwards every POST to origin. That's fine because writes are 1% of traffic.
- The Idempotency-Key check happens BEFORE code generation. Client double-taps → server returns the same short_code from cache instead of creating two. See Chapter 3.5 for the RFC-cited pattern.
- Fire-and-forget analytics. The Kafka publish happens in a background task after the 302 goes out. If Kafka is down we still redirect the user; we just drop one analytics event. That's the Google SRE "auxiliary services never block the primary path" principle (Chapter 7.5) in action.
- Cache-Control: no-cache on the 302. Prevents browsers from caching the redirect forever (see Chapter 3.5 on why 302 beats 301 for analytics).
Numbered decisions map (deep-dives)
Every step in the diagram above ties to a deeper chapter:
- Idempotency-Key header semantics → Chapter 3.5 (RFC draft-ietf-httpapi-idempotency-key-header, Stripe's pattern)
- Short-code generation strategy → Chapter 4.5 (5 approaches from random+check to KGS pool)
- INSERT + Multi-AZ commit → Chapter 5 (MySQL CPU derivation, 5ms sync commit math)
- CDN edge caching for redirects → Chapter 7 (95% offload derivation, CDN comparative)
- Cache-aside pattern with SETNX stampede protection → Chapter 6 (the L5 evolution)
- Fire-and-forget Kafka analytics → Chapter 7.5 (partitioning math, Flink exactly-once)
Look at the API table below. See how each endpoint hints at these decisions? That's not accidental. Every field in a good API spec is defending against a future problem.
| Method | Path | Purpose |
|---|---|---|
| POST | /urls | Create a short URL req: { long_url, alias?, ttl? } res: { short_url, short_code, expires_at } |
| GET | /{short_code} | Redirect (301 Permanent or 302 Temporary depending on strategy) res: 302 Location: <long_url> |
| GET | /urls/{short_code}/stats | Fetch click stats res: { clicks_total, clicks_7d, top_referrers } |
3 endpoints, 4 subtle decisions: status code (302 for analytics), idempotency (client-provided key), versioning (URL-based /v1), auth (public vs API-key). Get these right on day 1 or you pay for them for years.
- What are the 3 endpoints for a URL shortener?
- 301 vs 302 — which do you pick and why?
- How do you make POST idempotent?
- How do you version an API?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The request/response protocol behind every endpoint you just designed — with the RFC 9110 methods + status codes + headers we referenced.
The Idempotency-Key header pattern deep-dive with Stripe / Shopify / OpenAI examples — the mechanism behind our POST /v1/urls flow.
Animated end-to-end walk from browser click to backend response — DNS, TCP, TLS, LB routing that our API sits behind.
Chapter 4: data. The API implies a data model. When someone POSTs a URL, where does it go? What columns does the table have? What's the primary key? Data model is the second-most-permanent decision in any system — right after the API. Change it later at 100M rows and you'll cry.
Every API decision, defended
301 vs 302 vs 307 vs 308 · idempotency · versioning · auth · rate limits — with RFC citations
Chapter 3 named the four subtle decisions. Now I'm going to defend each one with the actual RFC references and real-company practices — the level of rigor an interviewer expects at L5+.
Here's the rule you should internalize: any time you cite a technical behavior, cite the standard. "302 means temporary redirect" is weak. "Per RFC 7231 §6.4.3, 302 Found indicates the resource resides temporarily under a different URI, and clients SHOULD NOT cache it unless indicated by Cache-Control" is strong. In an interview, the second answer signals someone who has read the specs. Do that.
Let's go endpoint by endpoint, then cross-cutting.
Endpoint 1: POST /v1/urls — the create path
Why POST (not PUT)?
Per RFC 9110 §9.3.3 (HTTP Semantics, 2022), PUT is used when the client specifies the target URI. Here the server generates the short_code, so the client cannot know the URI ahead of time — that makes POST correct.
If we let the client specify a custom alias (e.g. sysd.link/team-offsite), we could argue for PUT /v1/urls/team-offsite — but that's a different endpoint semantically. Our design keeps POST as the default create; custom aliases are a POST with a custom_alias field.
Status code on success: 201 Created
RFC 9110 §15.3.2: 201 Created SHOULD be returned when the request has been fulfilled and a new resource created. Include a Location header pointing to the new resource:
httpHTTP/1.1 201 Created Location: /v1/urls/aB3xY9 Content-Type: application/json {"short_code": "aB3xY9", "short_url": "[https://sysd.link/aB3xY9](https://sysd.link/aB3xY9)", ...}
Why not 200 OK? 200 is generic success — you leave the interviewer wondering if you know the difference. 201 is the correct answer. Both work in practice; the interview probe tests whether you know the RFC.
Why not 202 Accepted? 202 is for async processing where the create is queued but not yet done. Since URL creation is synchronous (one MySQL INSERT), 201 is right.
Payload validation — 400 Bad Request cases
Reject early. Return 400 Bad Request (RFC 9110 §15.5.1) with a JSON body describing what failed:
| Reason | Why it's rejected |
|---|---|
long_url missing | Required field |
long_url scheme is not http or https | javascript: and data: URIs are XSS vectors (OWASP Top 10 — A03:2021 Injection). Bit.ly, TinyURL, and t.co all block these. |
long_url exceeds 2048 bytes | Common browser + LB limit; RFC 9110 §4.1 doesn't mandate a max, but historical browser limit is 2048. Reject longer. |
custom_alias matches an existing code | Return 409 Conflict, not 400 |
custom_alias violates format regex ^[a-zA-Z0-9_-]{3,20}$ | 400 with reason |
References: OWASP Top 10 2021 (owasp.org/Top10), RFC 9110 for HTTP semantics.
The Idempotency-Key header — how to defend safe retries
This is the pattern Stripe made famous (docs: stripe.com/docs/api/idempotent_requests). Client attaches a UUID header on each POST:
httpPOST /v1/urls HTTP/1.1 Idempotency-Key: 8e6b3c9d-4f2a-4d5e-9c1a-2b3f7a8e5c4d Content-Type: application/json {"long_url": "[https://example.com/very-long-url](https://example.com/very-long-url)"}
Server logic (all in Redis, ~1ms overhead):
- On POST, hash the key + user_id → Redis lookup
- If found → return the previously-generated response (identical body, 201)
- If not found → do the create, cache
{key_hash: response}in Redis with 24-hour TTL
Why 24 hours? Long enough to survive mobile network issues + client retries. Short enough that Redis memory footprint is bounded.
Why this matters: on flaky mobile networks, the client might retry a POST that already succeeded (response was lost in transit). Without an idempotency key, we create two short_codes for one URL. With it, retries are safe — same key returns same short_code.
References: RFC draft-ietf-httpapi-idempotency-key-header (in-progress IETF standard, 2024); Stripe Idempotent Requests documentation.
Endpoint 2: GET /r/{code} — the redirect path (the important one)
This is 99% of your traffic. Every design decision here compounds. Get it wrong, and the wrong answer echoes across billions of requests.
Why /r/{code} and not just /{code}?
The /r/ prefix separates the redirect route from the API route so the load balancer, CDN, and reverse proxy can route them differently:
/r/*→ CDN + specialized redirect service, aggressive caching, short-lived TLS termination/v1/*→ API service, no caching, longer connection reuse, different rate limits
Real-world example: [bit.ly/aB3xY9](https://bit.ly/aB3xY9) — Bit.ly uses the bare host + code, but then their edge routes it to a redirect-optimized backend. Same pattern, different path prefix.
The single most-probed API decision: 301 vs 302 vs 307 vs 308
This is where every URL shortener candidate gets tested. Here's the full comparison table with RFC citations:
| Code | Name | RFC | Method preservation | Cacheable by default | Analytics behavior |
|---|---|---|---|---|---|
| 301 | Moved Permanently | RFC 9110 §15.4.2 | Historically POST → GET (see note) | Yes (RFC 9111 §4.2.2 — cacheable by default) | Browsers cache aggressively; analytics undercount |
| 302 | Found | RFC 9110 §15.4.3 | Historically POST → GET | No (cacheable only if Cache-Control says so) | Every redirect hits server; analytics accurate |
| 307 | Temporary Redirect | RFC 9110 §15.4.8 | Preserves original method | No | Same as 302, method-safe |
| 308 | Permanent Redirect | RFC 9110 §15.4.9 (originally RFC 7538, 2015) | Preserves original method | Yes | Same as 301, method-safe |
Note on method preservation: RFC 9110 §15.4.2 documents that historically many clients rewrote POST → GET on 301/302 despite RFC 7231's earlier intent — which is why 307 and 308 were introduced to explicitly preserve the method. For URL shortener (which handles GET redirects only), method preservation is a non-issue, and 302 remains the standard choice.
What each code MEANS to a browser:
- 301 Moved Permanently: the browser records "sysd.link/aB3xY9 → example.com forever." Next time the user clicks the same short URL, the browser doesn't even ask your server — it goes straight to example.com. You never see the click. Your analytics undercount by 60-90% depending on browser cache lifetime.
- 302 Found: the browser records "sysd.link/aB3xY9 → example.com right now, ask again next time." Every click hits your server. Analytics work. Bandwidth cost slightly higher.
- 307 Temporary Redirect: same as 302 but method-safe (a POST stays a POST). Irrelevant for URL shortener because we only handle GETs.
- 308 Permanent Redirect: same as 301 but method-safe. Irrelevant here for the same reason.
Which does the URL shortener pick — and defend?
Default answer: 302. Because we want:
1. Analytics that work. Every click counted. If we sell "click-tracking analytics" as a feature (Bit.ly Pro's core value proposition), we MUST see every click.
2. The ability to retarget a short_code. If the destination URL changes (owner rotates a marketing campaign), the new destination takes effect immediately. With 301, browsers keep sending users to the old URL for days or weeks.
3. A/B testing capability. With 302, we can send 50% of clicks to variant A, 50% to variant B, and the destination decision happens per-request. With 301, the browser has already cached one destination.
When 301 is the right answer: ultra-high-volume redirects where every redirect hitting your server is a cost problem (e.g. hyperlinks from a search engine to canonical pages). Not the URL shortener case.
What Bit.ly actually does
Per Bitly's official support docs (support.bitly.com/hc/en-us/articles/230897368) as of 2024-2025, Bitly's default is 301 (Permanent Redirect) for standard short links — chosen for SEO, browser cache savings, and edge caching efficiency. But — and this is where the folklore gets fuzzy — Bitly ALSO serves 302 (Temporary Redirect) for any link where the destination URL is editable, dynamic, or a file-sharing target that may move (Google Drive links, editable custom links, some paid-plan features).
Empirical check (do this yourself, it's the interview signal that separates candidates who researched from candidates who parrot tutorials): create a fresh short URL on Bitly, hit it with curl -I, and inspect the response. On many accounts today you'll see HTTP/2 302 — because Bitly's newer default treats freshly-created links as potentially editable until the account tier upgrades them to permanent.
Interview honesty: this nuance is a great signal to bring up. "Bitly's docs say 301, but I tested and got 302 on a fresh link — likely because Bitly treats editable links as temporary. Both are valid; the tutorial claim 'Bitly uses 301' is simplified." That answer is worth 3 points of extra depth.
The "301 + pixel-tracking wrapper" pattern — a general technique, not exclusively Bitly
The "301 + JS-wrapper pixel" pattern is a well-known technique in URL-shortener design circles for combining 301's browser-cache savings with 302-like analytics accuracy. It's frequently attributed to Bitly in system-design blog posts (Educative, various Medium articles), but I could not find primary Bitly engineering documentation that confirms this specific implementation is deployed at bit.ly today.
Treat it as a design pattern, not a company case study. The mechanics below are worth studying because they teach a real trade-off; the "Bitly does this exactly" attribution is folklore that this course chooses not to propagate.
Watch how Bit.ly gets BOTH 301 browser caching AND 302 analytics counting.
301 redirects to a wrapper HTML · async pixel fires analytics · JS navigates to destination. Adds 50-200ms latency but cuts 90% server load.
- · Reduces server load ~90% (repeat clicks hit browser cache)
- · Analytics still work (pixel fires on wrapper page)
- · +50-200ms latency (wrapper + JS execution)
- · Breaks if user blocks JS/pixels
- · Bookmarks + crawlers see wrapper URL
Do NOT propose this in an L4 interview. It's an L6-scale optimization that's brilliant for Bit.ly's specific volume + business model, but naive at 10K RPS when 302 is fine.
The trade-off cocktail:
- ✓ Reduces server load ~90% (repeat clicks hit browser cache after first hit)
- ✓ Analytics still work (pixel fires on wrapper page render)
- ✗ Adds ~50-200ms of latency (wrapper page render + JS execution before nav)
- ✗ Doesn't work if user's browser blocks JS/pixels (privacy extensions)
- ✗ Wrapper HTML is a distinct URL — bookmarks + link-preview crawlers see it, not the destination
Do NOT propose this in an L4 interview. It's an L6-scale optimization that's genuinely brilliant for extreme-volume shorteners with monetized analytics, but naive to reach for at 10K RPS when 302 is fine. Interviewers who know URL shorteners well have seen this "cool hack" proposed 100 times by candidates who read the same blog post — leading with it signals derivative thinking, not depth.
Who actually uses which redirect code — the real-world table
The "301 vs 302 vs 307 vs 308" question shows up in every senior-level HTTP interview. The RFCs define the semantics; production systems reveal what those semantics buy in practice. Study this table before the interview — it's what separates candidates who've read RFCs from candidates who've operated infrastructure:
| Code | Real services using it | Why they picked it |
|---|---|---|
| 301 Permanent | Google Search (canonical redirects), old→new domain migrations everywhere, Wikipedia article redirects, Bit.ly (with pixel hack), Facebook fb.me | Long-term SEO signal (PageRank flows through 301). Browser cache eliminates repeat-lookup cost. When URL is truly permanent, saves infrastructure. |
| 302 Found | sysd.link, TinyURL, most URL shorteners, geo-based redirects (regional edge sites), A/B testing rewrites, session-based landing pages, Amazon product-of-the-day | Every hit reaches your server. Analytics work. You can rotate destinations. Session state preserved. Default choice for URL shorteners. |
| 307 Temporary + method-preserving | Payment gateways (Stripe, PayPal, Adyen mid-checkout POST redirects), HashiCorp Vault (standby→active node on POST), Consul + Nomad HA API redirects, older Elasticsearch shard reroutes, OAuth 2.0 authorization code exchange (RFC 6749 §3.1.2.5), Apollo Router (GraphQL POST canary), Kubernetes API version migration | POST must stay POST — the request body carries payment/auth/write data. 302 would demote it to GET and lose the body (per historical browser quirk). 307 guarantees preservation. |
| 308 Permanent + method-preserving | GitHub REST v3 → v4/GraphQL migrations, AWS/GCP regional endpoint permanent moves, Cloudflare + Fastly forced HTTPS on API endpoints, Terraform Cloud / HCP resource moves, Kong + Apigee + AWS API Gateway config-driven path migrations, Netlify _redirects & Vercel vercel.json (opt-in 308 config) | Permanent migration of an API where clients may be POST/PUT/DELETE. 301 could silently downgrade the write to GET and cause data loss. 308 makes the move safe. |
The 3-line RFC-timeline explanation:
- RFC 2616 (1999) — 301/302 ambiguous about method preservation. Real browsers demoted POST→GET on both. Devs relying on POST-preservation got burned silently.
- RFC 7231 (2014) — codified 307 as "temporary redirect with method preservation." Fixed the missing primitive.
- RFC 7538 (2015) — codified 308 as "permanent redirect with method preservation." Fixed it for the permanent case.
- RFC 9110 (2022) — current HTTP semantics spec. Consolidates + clarifies but doesn't change the 4-code taxonomy.
Rule of thumb for interviews:
- GET-only workload (URL shortener, static site canonicalization, blog post migrations): 301 or 302, based on caching preference. 307/308 add nothing.
- Anything with POST/PUT/DELETE that might redirect (API gateways, payment flows, admin panels): 307 (temporary) or 308 (permanent). Never 301/302 — you'll silently lose write payloads.
- Serving both: use different codes per route. There's no rule that a service uses only one.
The precise Cache-Control behavior — the L6 depth
Per RFC 9111 (HTTP Caching, 2022) §4.2.2, responses with certain status codes are cacheable by default absent explicit Cache-Control — this includes 200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501. 302 is NOT in this default-cacheable set — a browser will not cache it unless the response includes Cache-Control: max-age=N.
For URL Shortener, the practical setting is:
httpHTTP/1.1 302 Found Location: [https://example.com/very-long-url](https://example.com/very-long-url) Cache-Control: no-cache X-Analytics-Event: click-recorded
The Cache-Control: no-cache is belt-and-suspenders — even browsers that decide to cache 302 by mistake will re-validate on next click.
References: RFC 9110 (HTTP Semantics, 2022), RFC 9111 (HTTP Caching, 2022), RFC 7538 (308 Permanent Redirect, 2015), MDN Web Docs — HTTP Redirects.
Where the analytics event fires
The pattern:
- Redis lookup for short_code → get long_url
- Fire-and-forget a Kafka event:
{code, timestamp, ip_hash, user_agent, referer} - Return 302 with Location header
Never block the redirect on analytics. If Kafka is down, we still redirect the user; we just lose that one analytics event. The trade-off (correct latency vs 100% analytics coverage) is explicitly acceptable — RFC 3339 timestamps + at-least-once semantics on Kafka means eventual accuracy.
Endpoint 3: GET /v1/urls/{code}/stats — the analytics path
Authentication: token-based, only the creator sees stats
Return 401 Unauthorized (RFC 9110 §15.5.2) if no auth. Return 403 Forbidden (§15.5.4) if the token belongs to a different user.
httpGET /v1/urls/aB3xY9/stats HTTP/1.1 Authorization: Bearer <jwt>
Why JWT? RFC 7519 defines the token structure. We use asymmetric signing (RS256, RFC 7518 §3.3) so any service can verify without needing the private key. Best practice per OAuth 2.0 (RFC 6749) — short-lived access tokens (~15 min), refresh tokens for renewal.
Rate limit: separate bucket per user
httpHTTP/1.1 200 OK X-RateLimit-Limit: 60 X-RateLimit-Remaining: 47 X-RateLimit-Reset: 1735689600
Headers per the informal-but-widely-adopted RateLimit convention (draft-ietf-httpapi-ratelimit-headers, IETF in-progress standard). Bucket per user_id, not per IP — a browser tab hammering stats shouldn't rate-limit other users behind the same corporate NAT.
Cross-cutting decision 1: API versioning strategy
Three options, all valid, one right for us:
| Strategy | Example | Where the version lives | Pros | Cons |
|---|---|---|---|---|
| URL path | /v1/urls | Path segment | Simple, visible, cacheable per version, SEO-friendly | Forces breaking-change bump |
| Query param | /urls?version=1 | Query string | Backwards-compat with unversioned URLs | Caches sometimes ignore query params; less clean |
| Accept header | Accept: application/vnd.sysd.v1+json | HTTP header | Follows content-negotiation spec (RFC 9110 §12) | Hidden from URL; hard to debug; not cacheable per version |
Recommendation: URL path. Reasoning:
- 90% of REST APIs in production use this (GitHub API, Stripe API, Twilio API, Twitter API, Spotify API — cite as needed)
- CDN caches work correctly (each version has a distinct cache key)
- Devtools users immediately see the version in the URL
- Deprecation is explicit — /v1/* returns Deprecation header per RFC 8594
Antipattern to avoid: never version APIs implicitly ("the endpoint changed last Tuesday"). Explicit versioning is a contract.
References: RFC 9110 §12 (Content Negotiation), RFC 8594 (Deprecation header, 2019), the "Web API Design" cookbook by Brian Mulloy (apigee.com, e-book), major public API docs (GitHub, Stripe).
Cross-cutting decision 2: Authentication strategy per tier
Three tiers, three auth models:
| Tier | Auth | RFC | Rate limit | Analytics |
|---|---|---|---|---|
| Anonymous | None | — | 60 shortens/hour/IP (Redis token bucket) | Aggregated only |
| API key | Authorization: ApiKey <key> (custom scheme, RFC 9110 §11.4 allows this) | RFC 9110 | 1,000 shortens/hour default; upgradable | Per-key stats |
| OAuth 2.0 / OIDC | Authorization: Bearer <jwt> | RFC 6749, RFC 7519 | Unlimited within TOS | Per-user stats, personalization |
Why three tiers? Growth funnel. Anonymous is free trial (viral onboarding). API key is developer adoption. OAuth is paid tier with personalized product.
The critical L5+ point: authentication is a rate-limiting axis, not just a security axis. Anonymous traffic is rate-limited per IP; authenticated traffic is rate-limited per identity. This changes DDoS strategy — you can't ban an IP without blocking a whole corporate network, but you can ban an API key instantly.
References: RFC 6749 (OAuth 2.0, 2012), RFC 7519 (JWT, 2015), RFC 6750 (Bearer Token Usage), OpenID Connect Core 1.0 (openid.net/specs/openid-connect-core-1_0.html).
Cross-cutting decision 3: Rate limiting
Three algorithms (see also Cloudflare's engineering blog "How we built rate limiting capable of scaling to millions of domains" at blog.cloudflare.com):
| Algorithm | RFC/reference | Precision | Redis cost |
|---|---|---|---|
| Token bucket | Widely-cited (Nagle 1987; used in AWS API Gateway, Cloudflare, Envoy) | Smooth bursts | 1 Redis INCR per request |
| Sliding window log | Kong Gateway "Rate Limiting Advanced" plugin docs | Exact | 1 sorted-set op per request (more expensive) |
| Sliding window counter | Cloudflare's approach | Approximate but very cheap | 2 counters, weighted avg |
Recommendation for URL Shortener: token bucket. Simplest to implement (INCR + EXPIRE), acceptable precision, cheapest on Redis. Return 429 Too Many Requests (RFC 6585 §4) with a Retry-After header (RFC 9110 §10.2.3) when exceeded.
Example:
httpHTTP/1.1 429 Too Many Requests Retry-After: 42 X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1735689642 {"error": "rate_limit_exceeded", "retry_after_seconds": 42}
References: RFC 6585 (Additional HTTP Status Codes, 2012), RFC 9110 §10.2.3, Cloudflare Engineering Blog on rate limiting, AWS API Gateway documentation on throttling.
Cross-cutting decision 4: CORS (Cross-Origin Resource Sharing)
Per the W3C CORS specification (fetched from fetch.spec.whatwg.org, formerly W3C REC-cors-20140116):
/v1/*endpoints: enable CORS viaAccess-Control-Allow-Origin: *(for public shortening endpoint) or specific origins (for authenticated stats)/r/*redirect endpoint: do not enable CORS. These are top-frame navigations, not XHR/fetch requests. Enabling CORS here signals attackers you accept cross-origin requests to your redirect endpoint (rarely useful, sometimes exploited).
Preflight caching: Access-Control-Max-Age: 86400 (1 day) so browsers don't preflight every request.
References: WHATWG Fetch Standard (fetch.spec.whatwg.org) — the current authoritative source for CORS, superseding W3C CORS REC.
The full interview soundbite
When the interviewer asks "walk me through your API design decisions":
"Three endpoints — POST /v1/urls returning 201 with Location header, GET /r/{code} returning 302 with Cache-Control: no-cache so we see every click, and GET /v1/urls/{code}/stats behind JWT auth per RFC 7519. Per RFC 9110 §15.4.3, 302 is the correct choice over 301 because we're a URL shortener with analytics as a product feature — 301 lets browsers cache the redirect and we lose 60-90% of clicks. 307/308 add method preservation which is irrelevant here since we only serve GETs. Idempotency via the client-provided Idempotency-Key header, dedup'd in Redis with 24h TTL — following the Stripe pattern that's now on IETF standards track. Versioning in the URL path — /v1/ — because it's cacheable per-version and every major public REST API uses it (Stripe, GitHub, Twilio). Three auth tiers — anonymous (60/hr per IP), API key (1000/hr per key), OAuth 2.0 (unlimited per user) — because auth is our rate-limiting axis, not just security."
That's an 80-second answer that shows you've read the specs.
References — the master list for this chapter
- RFC 9110 — HTTP Semantics (2022): rfc-editor.org/rfc/rfc9110 — the current authoritative spec for status codes, methods, headers, redirects.
- RFC 9111 — HTTP Caching (2022): rfc-editor.org/rfc/rfc9111 — cache-control semantics, default cacheability.
- RFC 7538 — 308 Permanent Redirect (2015): rfc-editor.org/rfc/rfc7538 — introduced 308 alongside RFC 7231's 307.
- RFC 6585 — Additional HTTP Status Codes (2012): rfc-editor.org/rfc/rfc6585 — 429 Too Many Requests.
- RFC 6749 — OAuth 2.0 (2012): rfc-editor.org/rfc/rfc6749.
- RFC 7519 — JSON Web Token (2015): rfc-editor.org/rfc/rfc7519.
- RFC 6750 — OAuth 2.0 Bearer Token Usage (2012): rfc-editor.org/rfc/rfc6750.
- RFC 8594 — HTTP Deprecation Header (2019): rfc-editor.org/rfc/rfc8594.
- draft-ietf-httpapi-idempotency-key-header — IETF in-progress spec on Idempotency-Key: datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
- draft-ietf-httpapi-ratelimit-headers — IETF in-progress spec on RateLimit headers: datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
- WHATWG Fetch Standard — CORS authoritative spec: fetch.spec.whatwg.org
- OWASP Top 10 2021 — A03:2021 Injection (javascript: / data: URI risks): owasp.org/Top10/A03_2021-Injection/
- Stripe Idempotency Documentation: stripe.com/docs/api/idempotent_requests
- Cloudflare Rate Limiting Engineering Blog: blog.cloudflare.com — search "rate limiting"
- MDN Web Docs — HTTP Redirects: developer.mozilla.org/en-US/docs/Web/HTTP/Redirections
Read these primary sources at least once. When an interviewer probes a specific RFC section number, the strongest signal you can give is "yes, I've read §6.4.3." That's the difference between reading blogs and reading standards.
| Method | Path | Purpose |
|---|---|---|
| POST | /urls | Create a short URL req: { long_url, alias?, ttl? } res: { short_url, short_code, expires_at } |
| GET | /{short_code} | Redirect (301 Permanent or 302 Temporary depending on strategy) res: 302 Location: <long_url> |
| GET | /urls/{short_code}/stats | Fetch click stats res: { clicks_total, clicks_7d, top_referrers } |
Every API decision is defensible with an RFC citation. 302 Found (RFC 9110 §15.4.3) beats 301 for URL shortener because analytics require every click to hit the server. Idempotency-Key follows the Stripe pattern (now IETF-standard-track). URL-path versioning wins over query-param and Accept-header because caches respect it and every major public API uses it. Rate-limit per identity, not just per IP, so auth becomes a DDoS axis.
- Why does 302 beat 301 for URL shortener? (RFC 9110 §15.4.3)
- What does the Idempotency-Key pattern do server-side, and where's the RFC?
- Why URL-path versioning over query-param or Accept-header?
- What are the 4 auth tiers and their rate-limit implications?
- Which status code goes with which failure? (400, 401, 403, 409, 429)
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The full deep-dive on the Idempotency-Key pattern with the exact Stripe / Shopify / OpenAI server-side implementations we referenced above.
See exactly how the shortener request traverses DNS → TCP → TLS → LB → backend, with per-hop latency budgets.
L4 (packet routing) vs L7 (HTTP-aware routing) — which one your ALB is doing, and why L7 is the right choice for header-based routing + Idempotency-Key inspection.
Chapter 4: data model. Now that the API is defended, we look at the schema that supports it. Just 3 columns, but the primary-key decision alone has 3 alternatives (random string vs counter vs UUID) that each break at a different scale.
Model the data
The 3-column table that runs the world
Here's a fact that will surprise you: the URL shortener data model is just three columns. short_code, long_url, created_at. Optionally a fourth: expires_at.
That's it. Three columns, one table, one primary key, one index.
But: look at how much you can build with three columns. Bit.ly does exactly this. So does TinyURL. So do all the URL shorteners you've ever used. When someone asks "should we use MongoDB because we might add fields later?" the answer is: no, just ALTER TABLE. MySQL does it in seconds.
## The schema visualized
Here it is as an interactive schema explorer — click the "deferred" tables to see FK relationships light up, hover columns for type + constraint details, and adjust the storage-math slider to see how many shards you need at any DAU:
The core urls table + 3 deferred tables you'll add later.
Click a deferred table to toggle its FK relationship. Hover columns for type + constraint details. Try the storage-math slider below.
urls
The data model is the second-hardest thing to change in production. Only the API is harder. If you design it wrong, migration at 1B rows takes 3 months + 4 outages. Spend time here.
Read the diagram like this:
- PK = primary key (unique, indexed).
- FK = foreign key (references another table's PK).
- UK = unique constraint (indexed).
- The three "deferred" tables (users, teams, analytics_events) exist as columns/FKs in your head so the growth path stays smooth, but you don't build them at L4.
What this makes concrete that a static ASCII sketch glosses over: the exact column types, PK/FK/UK constraints, and cardinality of every relationship. In an interview, drawing this level of detail signals "I've built one of these before" — not just "I've read about it."
The interesting decisions in the data model aren't about what columns to have — they're about:
- What's the primary key? A random string. Not an auto-increment counter (leaks growth rate and creation time). Not a UUID (too long for a short URL).
- How do we index? Primary key on short_code (obvious). Optional index on created_at for retention cleanup.
- What's the size math? 12 bytes for short_code + 200 bytes avg for long_url + 8 bytes timestamp = ~220 bytes per row. At 1M writes/day for 10 years = 3.6B rows × 220 bytes = ~800GB. Fits in one MySQL. No sharding needed for a decade at this rate.
Why this schema is a perfect fit for MySQL (the clustered-index advantage)
Here's a MySQL-specific detail that pays off enormously for this workload: MySQL InnoDB stores the row data INSIDE the primary-key B+tree. The PK is called a clustered index — the leaf pages of the B+tree contain the actual row data (short_code, long_url, created_at), not just a pointer.
That means every redirect query — SELECT long_url FROM urls WHERE short_code = ? — traverses the B+tree exactly once, and the answer is right there in the leaf. One I/O per warm lookup. No secondary index dereference.
Compare to Postgres, where the primary key is a separate B-tree that points into a heap file. A cold lookup is 2 I/Os: index → heap. Twice the I/O cost. On the redirect hot path where 99% of traffic is PK lookups, MySQL's clustered index is a legitimate structural advantage.
Practical implication for the derivation in Chapter 4.75: MySQL's warm-hit cost is ~40µs vs Postgres's ~50µs. Small in isolation, meaningful at 100K QPS.
When it hurts: if you add secondary indexes (say, on created_at for retention scans), every secondary-index lookup has to dereference through the PK to reach the row. Two I/Os. But for our schema, secondary indexes are optional and rarely scanned on the hot path.
The actual CREATE TABLE (with reasoning per column)
Here's the exact DDL. Every constraint is deliberate — I'll explain each one:
sqlCREATE TABLE urls ( short_code CHAR(7) NOT NULL, long_url VARCHAR(2048) NOT NULL, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), expires_at TIMESTAMP(3) NULL, PRIMARY KEY (short_code), INDEX idx_expires (expires_at) -- optional; only if you TTL-delete ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC KEY_BLOCK_SIZE=8;
Column-by-column reasoning:
| Column | Type | Why this type (not others) |
|---|---|---|
short_code | CHAR(7) fixed-width | Fixed-width = InnoDB can compute row offsets without variable-length overhead. Storing 7 base62 chars = 7 bytes. Using VARCHAR(7) would add 1-byte length prefix + slightly more code paths. Not BINARY because base62 is printable ASCII; CHAR is fine. |
long_url | VARCHAR(2048) | RFC 7230 doesn't cap URL length but browsers/servers commonly reject >2KB. Chrome tops out at ~2MB, but real URLs cluster <500 bytes. VARCHAR(2048) supports the 99.99th percentile without wasting space on the mean. TEXT would push the value off-page (slower) — VARCHAR(2048) fits inline in InnoDB's 8KB page when row_format=DYNAMIC. |
created_at | TIMESTAMP(3) | Millisecond precision. 4 bytes. Auto-updates via DEFAULT CURRENT_TIMESTAMP. TIMESTAMP is timezone-aware (stored UTC, displayed in session TZ); DATETIME is not. TIMESTAMP wins for anything log-like. |
expires_at | TIMESTAMP(3) NULL | NULL = never expires. NOT NULL default = every row wastes 4 bytes for a mostly-empty column. Making it nullable saves storage AND makes the query WHERE expires_at IS NOT NULL AND expires_at < NOW() semantically correct. |
Table-level options:
ENGINE=InnoDB— the only serious choice. MyISAM has no crash recovery.CHARSET=utf8mb4— supports emoji-in-URLs (yes, that's a thing since 2018).utf8(MySQL's 3-byte broken variant) fails on 4-byte codepoints.ROW_FORMAT=DYNAMIC— variable-length columns stored on overflow pages when they don't fit; efficient for our long_url mix.KEY_BLOCK_SIZE=8— 8KB compressed page. Halves storage for the primary key B-tree in exchange for ~2% CPU. Reference: MySQL InnoDB Table Compression.
What we deliberately did NOT add (and why):
- `INDEX idx_long_url` — no. Reverse lookups (URL → codes) are 1 in 10,000 requests, done from analytics dashboards, not the hot path. Adding this index doubles write cost.
- `user_id BIGINT` — no at L4. Add it when you need auth/analytics — schema migration via online tools is safe (See pt-online-schema-change).
- `click_count INT` — DO NOT DENORMALIZE. Every click would UPDATE this row → hot row contention → dead system. Click counts live in a separate analytics pipeline (Chapter 8). Reference: Facebook's TAO paper — they learned this the hard way with the "like count on the post" problem.
- `is_deleted BOOLEAN` — no soft-delete. Just HARD DELETE. Soft-delete is a footgun that grows the table indefinitely. If you need audit, log deletes to a separate append-only table.
The lookup flow — every step, every I/O, every microsecond
Here's what happens end-to-end when a browser hits https://sh.rt/aB3xY9k:
Step-by-step trace:
text[1] Browser → GET /aB3xY9k HTTP/1.1 → Host: sh.rt | ~5 ms (LB to app server, in-region) [2] Load balancer → hash consistent (based on short_code) → app server pod | ~0.1 ms [3] App server → Parse "aB3xY9k" from URL path → cache_hit = redis.get("url:aB3xY9k") # Chapter 6 → if cache_hit: skip to [7] | ~0.3 ms (Redis in-region hit) [4] MySQL query planner → SELECT long_url FROM urls WHERE short_code = 'aB3xY9k' → Planner: PK lookup, choose clustered-index seek | ~5 μs [5] InnoDB buffer pool lookup → hash "aB3xY9k" against buffer pool index → 90% hit rate for warm data | Warm: ~40 μs Cold: ~1 ms (page from disk) [6] Clustered B+tree traversal → 4 levels deep for 3.6B rows: root → internal → internal → leaf → Each level = 1 pointer follow in-memory (buffer pool) → Leaf page contains the row directly (short_code, long_url, created_at) → NO secondary index dereference (clustered!) | ~20 μs [7] App server → Build HTTP 302 response → Location: {long_url} → Cache-Control: private, max-age=90 | ~50 μs [8] Browser → Receives 302 → Fires GET on long_url | Total end-to-end: ~5-6 ms warm, ~7-8 ms cold
Latency budget breakdown (warm path, 90% of traffic):
| Step | Time | % of budget |
|---|---|---|
| Network LB→App | 5 ms | 62% |
| Redis cache lookup | 0.3 ms | 4% |
| MySQL PK lookup | 0.1 ms | 1% |
| App response build | 0.05 ms | 1% |
| Network App→Browser | 2.5 ms | 32% |
| Total | ~8 ms | 100% |
Interview soundbite: "The clustered B+tree is why MySQL is our right choice here — the leaf page IS the row, so a warm PK lookup is one buffer-pool hit at ~40µs. If we went with PostgreSQL, the PK is a separate B-tree that points to a heap tuple — cold lookups pay 2 I/Os instead of 1. At 100K QPS that difference is real: it's the reason Uber, Pinterest, GitHub, Shopify all default to MySQL for hot-key workloads. The remaining latency is network — the hot path is ~99% network, ~1% database."
The write flow (for completeness)
Symmetric for POST /v1/urls:
text[1] Browser POST /v1/urls {long_url: "https://..."} [2] LB → App server pod [3] App generates short_code (Ch 4.5 Approach 4 = KGS pop) [4] MySQL: INSERT INTO urls (short_code, long_url) VALUES (?, ?) → PK B-tree insert = 1 page dirty → Redo log fsync = 1 IOPS → Binlog fsync = 1 IOPS (Multi-AZ) | ~2 ms (durable commit) [5] Redis: SET url:{short_code} = long_url TTL 24h | ~0.3 ms [6] App returns 201 Created + short_url | Total: ~5-7 ms
Why writes are slower than reads: 2× fsync (redo + binlog) + PK B-tree page-dirty + optional cache warm. Read = 1 buffer-pool hit (no disk).
Optimization decisions I would NOT recommend
- Cache the row in memcached AND Redis — dual-cache adds complexity + stale-cache bugs. Pick one.
- Materialize URL parse in a column — some tutorials suggest storing
domain,path,query_stringsplit out. Don't. Waste of space, breaks URL round-tripping (URL encoders/decoders differ). - Use INT for short_code — you'd need to base62-encode on every read. Wastes CPU for zero storage gain vs CHAR(7).
- HASH partition by short_code first byte — premature. At 800GB single MySQL handles it. Partition when you have real evidence, not conjecture.
Table design references
- MySQL 8.0 InnoDB Storage Engine — dev.mysql.com/doc/refman/8.0/en/innodb-storage-engine.html — the clustered-index architecture.
- MySQL InnoDB Table Compression — dev.mysql.com/doc/refman/8.0/en/innodb-table-compression.html — KEY_BLOCK_SIZE reference.
- Uber's Postgres→MySQL migration — eng.uber.com/postgres-to-mysql-migration — clustered-index advantage discussed.
- Facebook TAO paper — research.facebook.com/publications/tao — why click_count denormalization killed the like-button.
- pt-online-schema-change — docs.percona.com/percona-toolkit/pt-online-schema-change.html — how to add columns to a 3.6B-row table without downtime.
Senior insight: the data model is the second-hardest thing to change in production. Only the API is harder. If you design it wrong, migration at 1B rows takes 3 months and 4 outages. So spend time here. Look at the model below and ask yourself: if I had to serve 100x more traffic, would this schema hold up? If yes, move on. If no, redesign now.
urls(short_code CHAR(7) PRIMARY KEY, long_url VARCHAR(2048), created_at TIMESTAMP(3), created_by BIGINT, expires_at TIMESTAMP(3) NULL) clicks(short_code CHAR(7), ts TIMESTAMP(3), ip_hash CHAR(32), referrer TEXT, geo_country CHAR(2)) -- OLAP-partitioned
3 columns, 800GB storage over 10 years at 1M writes/day. Fits in one MySQL. Sharding is a solution to a problem you don't have.
- How many columns in a URL shortener table?
- Why not use auto-increment counter for short_code?
- How do you compute 10-year storage from writes/day?
- When would you shard this table?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The taxonomy of database engines — why relational (MySQL) beats document / KV / wide-column for the L4 MVP data model we just designed.
Which isolation level defends our short_code uniqueness constraint against concurrent INSERTs — the race we glossed over.
The mechanism inside MySQL that lets readers proceed while writers commit — foundational for why our design supports 10K read RPS on a single primary.
Chapter 4.5: the ONE hard problem. The data model looks trivial — but hidden inside those three columns is a genuinely hard distributed-systems problem: how do you generate that short_code so it's unique, unpredictable, short, and cheap to produce at billion-request-per-second scale? Most tutorials wave hands at this. I won't. It's the make-or-break interview probe.
The ONE hard problem
Short-code generation — 5 approaches, 4 scales, one interview probe
Read this carefully. Short-code generation is the single hardest problem in the URL shortener design, and 90% of candidates fumble it. Not because it's obscure — because they treat it as a one-liner ("just hash the URL") when it's actually 5 nested design decisions.
If you get this wrong, the interviewer will drill into it and expose the gap. If you get it right, they'll believe everything else you say. So we're going to spend 18 minutes here.
The requirements (what "good" looks like)
A short_code must be:
- Unique — no two URLs share a code
- Unpredictable — attackers can't guess the next code (scraping, enumeration attacks)
- Short — 6-8 chars typical (that's the whole product's value proposition)
- Cheap to produce — no expensive RPCs on the create path
- Collision-free at scale — at 1B URLs the collision handling can't be O(N)
- Consistent across regions — two data centers can't hand out the same code
Every approach below trades some of these off. Your job in the interview is to state which trade-off you're accepting and why.
The character-space math (this is the derivation you MUST know)
You get to pick your alphabet:
- base10
[0-9]= 10 chars — human-readable but 10 chars is 10 chars, code length is huge - base16
[0-9a-f]= 16 chars — hex, still short-alphabet - base36
[0-9a-z]= 36 chars — url-safe, case-insensitive - base62
[0-9a-zA-Z]= 62 chars — the standard for URL shorteners (case-sensitive, URL-safe) - base64url
[0-9a-zA-Z_-]= 64 chars — slightly denser but_and-cause copy-paste confusion
Everyone in the industry uses base62. Bit.ly, TinyURL, goo.gl (RIP), t.co, ow.ly — all base62. Follow the convention.
Now the math. Given base62 and code length L, the total code space is 62^L:
| Length | Total codes | Human-scale interpretation |
|---|---|---|
| 5 chars | 62^5 = 916 million | Enough for a hobby project. Bit.ly burned through this in a month. |
| 6 chars | 62^6 = 56.8 billion | Enough for a mid-size shortener. Twitter's t.co uses 6-10. |
| 7 chars | 62^7 = 3.52 trillion | Enough for 10 years at Bit.ly's rate (1B/month → 12B/year → covered for 290+ years) |
| 8 chars | 62^8 = 218 trillion | Astronomical. Overkill for anyone. |
Here's what that growth looks like — the choice of code length is a fundamentally exponential decision. Each additional character multiplies the total code space by 62×, adding roughly 50 years of runway at typical creation rates.
Real-world creation rates & runway (6-char base62 sweet spot):
| Product | URLs / year | Runway with 6 chars |
|---|---|---|
| Your MVP | ~1M | ~56,000 years |
| Bit.ly (all-time) | ~1B | ~50 years |
| Twitter t.co | ~1B (2013 peak) | ~50 years |
Interpretation: 6 chars is the industry sweet-spot. Below that = you'll run out. Above that = you're paying URL-length tax for zero benefit.
Interview soundbite: "I'd start with 6 chars — 56 billion codes handles 10 years at 5M creates/day. If growth exceeds forecast we can extend to 7 chars without changing the algorithm — new URLs just get longer codes."
How base62 encoding actually works — the mechanics you MUST know
Every previous "we base62-encode 6 chars" line has waved hands at the actual algorithm. Here it is in detail. Interviewers will drill in on this because it's a genuine "did you build one or read about one?" test.
The base62 alphabet
Pick an alphabet — 62 unique URL-safe characters. Two common orderings:
- `0-9A-Za-z` →
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"(Bit.ly-style, digits first) - `A-Za-z0-9` →
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"(some libraries)
The choice is arbitrary — but permanent. Once you deploy, existing codes are indexed by the alphabet you chose. Changing it means every stored code decodes to a different value. So pick, document, and never change.
The encoding algorithm — repeated division by 62
Given a numeric value n (integer, 32-bit, 64-bit, whatever), the algorithm:
textencode(n): digits = [] while n > 0: remainder = n mod 62 digits.append(alphabet[remainder]) n = n div 62 return reverse(digits)
Concrete worked example — encode the counter value `12,345,678` with alphabet 0-9A-Za-z:
| Step | n | n mod 62 | alphabet[n mod 62] | n div 62 |
|---|---|---|---|---|
| 1 | 12,345,678 | 6 | 6 | 199,124 |
| 2 | 199,124 | 42 | g | 3,211 |
| 3 | 3,211 | 49 | n | 51 |
| 4 | 51 | 51 | p | 0 |
| stop | 0 | — | — | — |
Read the collected digits in reverse: p, n, g, 6 → `png6`.
That's a 4-character code. Not 6. Which brings us to the padding problem.
The padding problem — small counters produce short codes
If your auto-increment counter starts at 1, then:
- encode(1) → "1" (1 char)
- encode(62) → "10" (2 chars)
- encode(3,844) → "100" (3 chars)
- encode(12,345,678) → "png6" (4 chars)
- encode(916,132,832) (= 62^5) → "100000" (6 chars — first time we hit 6 chars naturally)
Two fixes:
Fix A — Zero-pad on the left.
textpad_left(encode(n), 6, alphabet[0]) // encode(12345678) → "png6" → pad → "00png6"
Guarantees fixed length. But early codes are ugly ("000001", "000002") and predictable — attacker who sees "000042" can guess "000043" exists.
Fix B — Offset the counter by 62^5.
textinitial_counter = 62^5 = 916,132,832 // encode(counter) is guaranteed ≥ 6 chars from the start // encode(916,132,833) → "100001" (6 chars) // encode(12,345,678 + 916,132,832) → "1png6" nope let me recompute // = encode(928,478,510) → "1p6QMc" or similar 6-char result
Fixed-length forever, no leading zeros. This is what Bit.ly Classic and TinyURL do.
The random-bytes → base62 problem (modulo bias)
For Approach 1 (random + collision check), you generate random bytes and convert to a 6-char base62 code. If you're careless, you get modulo bias — some characters appear more often than others.
Why it happens: 62^6 = 56,800,235,584. If you generate 32 random bits (2^32 = 4,294,967,296), you have only 7.5% of the target space — you literally CAN'T produce most 6-char codes. If you generate 64 random bits (2^64 = 18,446,744,073,709,551,616) and take mod 62^6, you get uniform distribution because 2^64 / 62^6 = 324,752,533 codes per bucket with a tiny sub-bucket remainder that would bias the last ~5.4M codes IF you didn't discard them.
The correct algorithm:
textgenerate_random_6char(): SPACE = 62^6 // 56,800,235,584 MAX = 2^64 - (2^64 mod SPACE) // largest multiple of SPACE that fits in 64 bits loop: bytes = crypto.randomBytes(8) // 64 random bits n = uint64_from_bytes(bytes) if n < MAX: // rejection sampling — reject the biased tail return pad_left(encode(n mod SPACE), 6, alphabet[0]) // else retry — this happens < 0.001% of the time in practice
Without rejection sampling, ~5.4M of the 2^64 values map to the "over the multiple" range and slightly bias 5.4M codes toward being more likely. In practice this is imperceptible for our use case (56 billion codes, imperceptible bias), but the rejection-sampling loop is the correct algorithm and adds ≤ 1 ns per generation.
The decoding algorithm — base62 → integer (for Approach 2)
If you use counter-based codes and want to recover the counter from a short_code (for analytics, debugging), the reverse works:
textdecode(code): n = 0 for char in code: n = n * 62 + alphabet.index_of(char) return n
For alphabet 0-9A-Za-z and code "png6":
- p = 51 → n = 0 × 62 + 51 = 51
- n = 49 → n = 51 × 62 + 49 = 3,211
- g = 42 → n = 3,211 × 62 + 42 = 199,124
- 6 = 6 → n = 199,124 × 62 + 6 = 12,345,678 ✓
Trivia: you can only decode counter-based codes back to a counter. Random-generated codes decode to a random number — meaningless. That's a security feature: random codes don't leak "how many URLs exist" or "creation order."
Concrete code samples (production-ready)
Python (Approach 2 — counter-based):
pythonALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" def encode(n: int, min_length: int = 6) -> str: if n == 0: return ALPHABET[0] * min_length digits = [] while n > 0: digits.append(ALPHABET[n % 62]) n //= 62 return "".join(reversed(digits)).rjust(min_length, ALPHABET[0]) def decode(code: str) -> int: n = 0 for ch in code: n = n * 62 + ALPHABET.index(ch) return n
JavaScript / TypeScript (Approach 1 — random with rejection sampling):
typescriptconst ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; const SPACE = 62n ** 6n; // 56,800,235,584 const MAX_U64 = 2n ** 64n; const CUTOFF = MAX_U64 - (MAX_U64 % SPACE); export function generateShortCode(): string { while (true) { const bytes = crypto.getRandomValues(new Uint8Array(8)); let n = 0n; for (const b of bytes) n = (n << 8n) | BigInt(b); if (n < CUTOFF) { let value = n % SPACE; const digits: string[] = []; for (let i = 0; i < 6; i++) { digits.push(ALPHABET[Number(value % 62n)]); value /= 62n; } return digits.reverse().join(""); } // < 0.0000001% retry rate — imperceptible } }
Go (Approach 1 with crypto/rand):
goimport ( "crypto/rand" "encoding/binary" "math/big" ) const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" func generateShortCode() string { space := new(big.Int).Exp(big.NewInt(62), big.NewInt(6), nil) // 62^6 for { var b [8]byte _, _ = rand.Read(b[:]) n := new(big.Int).SetUint64(binary.BigEndian.Uint64(b[:])) maxU64 := new(big.Int).Lsh(big.NewInt(1), 64) cutoff := new(big.Int).Sub(maxU64, new(big.Int).Mod(maxU64, space)) if n.Cmp(cutoff) < 0 { value := new(big.Int).Mod(n, space) code := make([]byte, 6) div := big.NewInt(62) for i := 5; i >= 0; i-- { var mod big.Int value.DivMod(value, div, &mod) code[i] = alphabet[mod.Int64()] } return string(code) } } }
Interview soundbite for the mechanics
"For a 6-char base62 code from an integer counter, the algorithm is repeated division by 62 — take remainder, index into a 62-char URL-safe alphabet, right-to-left. To guarantee exactly 6 chars, either pad-left with alphabet[0] or offset the counter to start at 62^5. For random-generated codes, I generate 64 bits, use rejection sampling to avoid modulo bias, and mod by 62^6. Alphabet order is arbitrary but permanent — once shipped, don't change it or existing codes decode wrong."
Now you can defend the mechanics against any interviewer follow-up. Back to the approaches.
Approach 1: Random + collision check
The naive approach. On each create:
sqlLOOP: code = base62(random 32 bits) -- 6 chars from 2^32 = 4B space, need trim to 62^6 INSERT INTO urls (short_code, long_url) VALUES (code, $url) ON DUPLICATE KEY UPDATE short_code = short_code IF rows_affected == 1: return code ELSE: continue LOOP END
Collision probability (the birthday paradox — critical!): in a space of N codes with K URLs already stored, probability of collision on the next insert ≈ K/N.
For 6-char base62 (56.8B codes):
| URLs stored | Collision probability per insert | Expected retries |
|---|---|---|
| 1 million | 1/56800 = 0.0018% | Negligible |
| 100 million | 0.18% | 1 retry per 570 inserts |
| 1 billion | 1.8% | 1 retry per 55 inserts |
| 10 billion | 17.6% | 1 retry per 6 inserts — this is where it hurts |
Here's the same math as a growth curve — collision probability grows LINEARLY with URLs stored (that's what makes it manageable at first, then sudden):
Drag the slider. Watch the collision curve grow linearly, then bite.
K/N linear formula: probability grows with URLs stored ÷ code space. Manageable at first · retry storm above 5B.
Approach 1 (random + collision check) works UP TO ~1% of code space filled (for 6-char = 570M URLs). Beyond that, escalate to KGS pre-generation (Chapter 4.5 Approach 4). The formula is P(collision) ≈ K/N where K = URLs stored, N = 56.8B code space. Simple, linear, predictable — until you hit the wall.
Reading the curve:
| URLs stored | Collision probability | What it means |
|---|---|---|
| 0 - 500M | < 1% | Retry cost negligible |
| 500M - 2B | 1-4% | Starts to matter but manageable |
| 2B - 5B | 4-9% | Getting painful |
| Above 5B | 10%+ | Retry storm during viral events — switch to Approach 4 (KGS) |
Rule of thumb: Approach 1 (random + collision check) works UP TO ~1% of code space filled (for 6-char = 570M URLs). Beyond that, escalate to KGS pre-generation.
Verdict: works up to ~100M URLs comfortably. Above that, the retry loop becomes a MySQL write-amplification problem (each collision = 1 wasted write + 1 wasted index probe).
Where this wins: L4 (10K RPS), small shorteners, MVP. Bitly used this for its first years.
Where this loses: L6+ scale. You DO NOT want a stampede of collision retries during a viral event.
Approach 2: Counter + base62 (the "Bit.ly Classic")
Every create gets an auto-increment ID. Encode the ID in base62.
```
INSERT INTO urls (long_url) VALUES ($url) RETURNING id;
short_code = base62_encode(id);
UPDATE urls SET short_code = $code WHERE id = $id;
```
Pros:
- Zero collision probability (integer PK is unique by definition)
- Codes are sequential — good for range scans, backups, retention
- Simple to reason about
Cons — and these are big:
- Enumeration attack: knowing code abc123 tells you abc124 exists. Scrapers dump your entire URL corpus in 24 hours.
- Growth-rate leak: a competitor can shorten one URL a day for a month. Diff the codes. Now they know your daily create volume.
- Single writer bottleneck: the counter is one row. Every create takes an exclusive lock. At 100K creates/sec you saturate the counter row.
- Cross-region coordination: two regions can't hand out the next counter value independently.
Bit.ly's actual fix (2010-era): they used counter + XOR with a secret salt before base62. That obfuscates predictability. Still single-writer, still cross-region, but not enumerable.
Where this wins: L4 single-region, when you can accept the enumeration risk. Historical Bit.ly.
Where this loses: anything with adversarial users, anything multi-region, anything above 10K creates/sec.
Approach 3: Hash-based (MD5 or SHA-256 → truncate → base62)
Deterministic function of the URL:
```
raw = MD5(long_url + random_salt).digest() -- 16 bytes
code = base62(raw[0:6]) -- take first 6 bytes → base62
try_insert(code, long_url)
if conflict:
regenerate_with_different_salt()
retry
```
Same URL → same code: is this a feature or a bug?
- Feature at L4-L5: users who shorten the same URL twice get one code (nice UX, saves storage).
- Bug at L6+: users EXPECT that shortening the same URL twice gives two codes (different analytics buckets, different expiration).
The industry answer: randomize the salt on every create, so URL → many possible codes. Only dedupe when you want to.
Collision probability: identical to Approach 1's birthday paradox — you're picking 48 bits (6 base62 chars ≈ 48 bits) from a 62^6 = 56.8B space. Same math.
Pros:
- No coordination — every server generates codes independently
- No counter row, no cross-region write coordination
- Fast (MD5 is ~500 MB/s single-threaded)
Cons:
- Still has collision-retry loop at scale
- If you skip the salt, same URL → same code — usually you WANT them distinct
- Slightly more CPU per create than Approach 2
Where this wins: L5-L6, single-region or independent multi-region, up to ~500M URLs.
Where this loses: L7 (1B+ URLs) where collision rate becomes painful.
Approach 4: Key Generation Service (KGS) — the scale winner
Pre-generate unique codes into an "available" pool. Consumers pull codes from the pool at create time. No collision at runtime.
Architecture:
- KGS worker (batch job, runs continuously): generates 100M unique codes, writes to
available_codestable - KGS server (thin API, hot path): "give me N codes." Marks them as claimed, returns them.
- URL Shortener service: on create, takes 1 code from KGS, inserts URL row with that code.
Here's the flow — as a live interactive animation. Play with the worker rate + traffic rate sliders, then try "worker fleet dies" and watch the pool drain to zero. The alert banner turns red as depth drops:
Watch the cold path fill the pool. Watch the hot path drain it.
Cold path: KGS workers generate codes (INSERT IGNORE INTO available_codes). Hot path: shortener claims codes (SELECT ... FOR UPDATE SKIP LOCKED). Try "worker dies" and watch the pool drain to zero.
- HEALTHY> 7 days supply · workers keeping up
- WARN< 7 days supply · Slack alert · investigate cold path
- PAGE< 24 hours supply · PagerDuty pages on-call · spin up emergency worker fleet
- OUTAGEPool empty · every POST /v1/urls returns 500 · existential outage
The SQL underneath the animation:
sql-- KGS worker LOOP FOREVER: code = random_base62(6) INSERT IGNORE INTO available_codes (code, claimed) VALUES (code, false) END -- KGS server (called by shortener) UPDATE available_codes SET claimed = true, claimed_at = NOW() WHERE code IN ( SELECT code FROM available_codes WHERE claimed = false LIMIT 100 FOR UPDATE SKIP LOCKED -- concurrent-safe claim ) RETURNING code;
The magic: collision handling moves from the hot path (creating a URL) to the cold path (batch pre-generation). At 100K creates/sec, all you're doing is a locked read from a "available" pool.
Pool sizing: if you generate 10M/day and consume 5M/day, pool stays healthy. Monitor SELECT COUNT(*) WHERE claimed = false and alert if it drops below 24h of forecast supply.
Fault tolerance:
- KGS server dies → shortener uses a local batch of 1000 pre-fetched codes (retry with next batch on refill)
- KGS DB dies → same fallback, plus emergency batch generation locally
- Never should the create path fail because KGS is unhealthy
Pros:
- No collision-retry on hot path (all pre-generated)
- Horizontal-scale: sharded KGS pools per region
- Predictable latency (create is 1 DB write, no retry loop)
Cons:
- Two extra services to build and operate (worker + server)
- Pool must be monitored (starve = outage)
- Slightly more complex than Approach 1-3
Where this wins: L6-L7 (1M+ creates/sec, multi-region). This is what Bit.ly moved to. This is what any serious shortener uses at scale.
KGS deep-dive: throughput, latency, and mitigation math
Before we move on, let me answer three questions every interviewer will drill on:
- "How much can one KGS worker actually generate per second?"
- "You added an extra network hop — what does that cost us in latency?"
- "The pool starves — what actually happens?"
1. KGS throughput ceiling — the 3 floors
Question: Can one KGS worker keep up with 1M creates/sec?
Answer: No. Let me derive the throughput of a single KGS worker generating random 6-char base62 codes and INSERT IGNORE-ing them into MySQL:
Floor 1 — CPU cost of code generation:
random_base62(6)= 6 × getrandom() calls + 6 × modulo ops. Modern CPUs: ~500ns per code on a c6i.xlarge (2.4 GHz). That's 2M codes/sec theoretical (single-threaded).- With 4 worker threads on the same box → ~8M codes/sec generated, but this is CPU-only; disk is next.
Floor 2 — MySQL INSERT IGNORE throughput:
- Each generated code = 1 INSERT IGNORE against MySQL. On a db.r6i.2xlarge (8 vCPU, 64 GB RAM, 8K IOPS baseline) with a single-column PRIMARY KEY (code CHAR(6)) and no other indexes:
- IOPS floor: each batch = 1 log write + 1 data page write × 8 (batch size) → ~30K IOPS peak, exceeds 8K baseline
- Conclusion: one KGS worker + one MySQL box = ~10-30K codes/sec sustained. NOT 2M — the CPU cost was never the bottleneck; MySQL was.
Floor 3 — Consumption rate (what we need to serve):
- L5 target: 100K creates/sec = need 100K codes/sec produced sustained + ~2x overprovision for burst = 200K codes/sec.
- L6 target: 1M creates/sec = need 1M codes/sec produced + 2x = 2M codes/sec.
- Conclusion: at L5 we need ~10 KGS workers (each 20-30K codes/sec into partitioned pool tables). At L6 we need ~100 workers, sharded by code-prefix.
The scaling math for the pool table:
| Scale | Creates/sec | Codes/sec needed | KGS workers | Pool DB shards | Pool size (24h supply) |
|---|---|---|---|---|---|
| L4 | 100 | 1000 (10× headroom) | 1 | 1 | 86M codes (24h × 100/s × 10) |
| L5 | 100K | 200K (2× headroom) | 10 | 4 | 17.3B codes (24h × 100K/s × 2) |
| L6 | 1M | 2M (2× headroom) | 100 | 32 | 173B codes (24h × 1M/s × 2) |
| L7 | 10M | 20M (2× headroom) | 500 | 128 | 1.73T codes — NOTE: exceeds 62^6 = 56B, must switch to 7-char |
Interview soundbite: "One KGS worker + MySQL box sustains ~10-30K codes/sec. To feed 1M creates/sec (L6), I need ~100 KGS workers writing to a 32-shard pool table. The CPU cost of generating codes is trivial (~500ns each); the real ceiling is MySQL INSERT throughput. And at L7 you must switch to 7-char codes because 6-char space (56B) can't hold 24h of L7 pool inventory."
2. Latency cost of the extra hop — and how to hide it
The naive concern: create path was App → MySQL (2ms). With KGS it becomes App → KGS → MySQL (App) → MySQL (URL) = 4ms. Doubled the create latency!
The fix: local batch pre-fetch.
Every app server pre-fetches a batch of N codes from KGS before it needs them, holds them in memory, and consumes locally. The KGS hop happens 1 in every N creates, not every create.
Batch size math:
- App server sustains 1000 creates/sec. If it pre-fetches 100 codes, the batch lasts 100 ms.
- KGS round-trip (in-region) ≈ 1-2 ms. Amortized over 100 creates = 10-20 μs per create. That's noise vs the 2ms MySQL write.
- If batch = 10 codes → refill every 10 ms, amortized cost 100-200 μs per create. Still noise.
- If batch = 1 (worst case, no batching) → KGS hop on every create = full 2 ms per create.
Trade-off:
| Batch size | KGS refill frequency | Latency amortization | Wasted codes on crash |
|---|---|---|---|
| 1 | every create | +100% (2ms) | 0 |
| 10 | every 10ms | +10% (200μs) | up to 10 |
| 100 | every 100ms | +1% (20μs) | up to 100 |
| 1000 | every 1s | +0.1% (2μs) | up to 1000 |
| 10000 | every 10s | +0.01% (200ns) | up to 10000 |
Sweet spot: batch = 100-1000. Latency amortization <1% AND wasted codes on crash are negligible against a 56B pool.
Interview soundbite: "The extra KGS hop looks scary but amortizes to noise with local batch pre-fetch. Batch of 100 hides the 1-2ms KGS RTT to 20μs per create — invisible in the create latency budget. The only downside is up to 100 wasted codes per app-server crash, which is 0.00000018% of the pool — irrelevant."
3. Pool starvation — what actually happens
Scenario: KGS workers stall (bug, deploy, DB failover). Pool depth drops. What breaks first?
Failure sequence:
- T=0: KGS worker fleet stops producing codes. Pool depth stops growing.
- T=+1s to T=+24h: consumption continues, pool depth decreases at 100K/sec (L5) or 1M/sec (L6).
- Alert threshold: fire alert when pool depth = 24h of forecast supply. This gives on-call 24h to intervene.
- Second alert: at 6h of supply — page loudly, escalate to secondary.
- Emergency mitigation:
- T=+pool depth 0 without mitigation: create path FAILS. Redirect path unaffected. This is a Sev-1 outage.
Pool depth alerting math:
- At L5 100K creates/sec, 24h supply = 100K × 86400 = 8.64B codes. Fire when pool < 8.64B.
- At L6 1M creates/sec, 24h supply = 86.4B codes — very close to the 56B ceiling of 6-char base62. At L6+ you MUST run 7-char codes (3.5T space) to keep 24h supply headroom.
Two extra guardrails:
- Watchdog script runs every 5 min:
SELECT COUNT(*) FROM available_codes WHERE claimed=false. If below threshold, page. - Circuit breaker at app-server: if KGS timeouts exceed 10/sec, flip to inline collision-check for 60s, alert.
Interview soundbite: "Pool starvation is a Sev-1 but it never surprises you — the alert fires 24h before creates fail, so you have a full business day + on-call rotation to intervene. And the app-server local batches + inline-collision fallback mean the create path degrades gracefully rather than failing outright. This is the same pattern as any pre-generated pool (Kafka partition IDs, DB sequence caches, JWT signing keys)."
KGS references
- Bit.ly Engineering blog — word.bitly.com — multi-post series on their evolution from counter → KGS.
- Percona sysbench MySQL benchmarks — percona.com/blog testing-mysql-performance-in-the-cloud — INSERT throughput numbers referenced above.
- MySQL 8.0 SKIP LOCKED semantics — dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html — the concurrent-safe claim pattern.
- Amazon Aurora IOPS baselines by instance type — docs.aws.amazon.com Aurora storage — for the 8K IOPS floor.
Approach 5: Snowflake ID (or "truncated Snowflake")
Twitter's Snowflake: 64-bit ID = 41 bits timestamp + 10 bits node + 12 bits sequence. Unique across all nodes without coordination.
For URL shortener: encode the Snowflake in base62. 64 bits = 11 base62 chars. Too long for a URL shortener.
The trick: truncate to 6-8 chars by using less-precise timestamp + smaller node/sequence bits. Example:
- 20 bits timestamp (relative to epoch, ~12 days of unique values before wrap)
- 8 bits node (256 nodes)
- 12 bits sequence per ms per node (4K creates/ms/node)
- Total: 40 bits = 7 base62 chars
Here's the exact bit-layout of that 40-bit truncated Snowflake with a region prefix so no two regions can EVER collide — visualized as an interactive SVG (hover a segment to see its role):
Reading the layout above:
| Segment | Bits | Values | Purpose |
|---|---|---|---|
| REGION | 3 | 8 | Region prefix — 001 = us-east-1, 010 = eu-west-1, 011 = ap-northeast-1. Cross-region collision is mathematically impossible because bits differ per region. |
| TIMESTAMP | 17 | 131,072 ms (~2 min per value) | Milliseconds since a chosen epoch. Wraps every ~12 days — pick an epoch aligned to a maintenance window. |
| NODE ID | 8 | 256 | Nodes per region (typically app servers × #shards). |
| SEQUENCE | 12 | 4,096 | Codes per ms per node = 4M codes/sec/node theoretical max. |
Encoding math: 40 bits ÷ ~5.95 bits/char (log₂ 62) = 6.72 base62 chars → round up to 7-char short codes.
Example: 001|00010110|10111001|011001101010 → aB3xY9k
The runtime generation flow — how one ID actually gets built
The 40-bit layout above shows what an ID looks like. It does NOT show how a running server produces one when a POST /v1/urls request lands. That's a different — and equally important — visual. Step through the interactive 7-step algorithm below — try the "clock went back" and "sequence overflow" scenarios to see the 2 danger branches that cause 90% of Snowflake production incidents:
Step through the algorithm. Watch the 2 danger branches that cause production incidents.
Every senior candidate should be able to whiteboard this end-to-end. Try the "clock went back" + "sequence overflow" scenarios.
now_ms = System.nanoTime() / 1_000_000
Use monotonic clock, NOT wall-clock. Avoids Windows NTP jumps or leap-second corrections that would generate duplicate IDs.
Wrong clock → duplicate IDs after NTP sync
Step 2 (clock went back) is where 90% of Snowflake production incidents live — the fail-loud path exists so you don't silently corrupt IDs. Step 5 (sequence exhaustion) is the throughput ceiling — spinwait is fine at ~1μs but if it dominates your latency profile, you've maxed out per-node throughput and need to add nodes.
What the flowchart makes obvious that the ASCII buries: the two branches that hide operational risk. The Step 2 decision (clock went backwards) is where 90% of Snowflake production incidents live — the fail-loud path exists explicitly so you don't silently corrupt IDs. The Step 4 → Over branch (sequence exhaustion) is the throughput ceiling — spinwait is fine at ~1μs but if you see it dominating your latency profile, you've maxed out per-node throughput and need to add nodes.
Newbie mentor commentary — read this before you code it:
- Step 3 (the lock) is the part people skip. Under single-threaded load a Snowflake generator works without locking. Under high concurrency (typical of a production URL shortener at L5+), two threads can hit
sequence++at the exact same nanosecond, both read the same value, both write the same new value, and you get a DUPLICATE short code. This bug ships to production, causes rare mysterious 409 CONFLICT errors on retries, and takes 3 weeks to debug. Add the lock on day 1.
- Step 4 (the sequence overflow spinwait) is the subtle correctness bug. If you generate more than 4,096 IDs in a single millisecond (which happens at 4M+ RPS per node), the sequence exhausts. Naive code overflows and duplicates last-ms IDs. Correct code spinwaits (busy-loops for ~1 microsecond) until the next millisecond and resets. Spinwait is fine at this scale because it's < 1000ns — but you must code it explicitly.
- Step 6 (base62) has an ordering trap. The alphabet you pick fixes the sort order of your short codes forever. Consistent alphabets:
0-9a-zA-Z(base62 standard),a-zA-Z0-9(some libraries),A-Za-z0-9(URL-safe base62). Pick one, document it, never change it.
- Steps 1-7 must fit in <1 microsecond wall clock. Anything you add here (a database write to record the ID, a call to Redis, logging with a JSON serializer) will destroy your ID generation throughput. Log to a buffer, batch-flush. Persist the ID only when the URL insert happens downstream.
The clock-skew failure mode — Snowflake's #1 production incident
Every Snowflake deployment eventually hits this. Every one. It's the operational risk that separates "we tested Snowflake in dev" from "we ran Snowflake in production for a year." Here is what happens:
NTP resyncs your server's wall-clock. This can happen for many reasons: leap second adjustment, VM migration, hypervisor pause, NTP daemon restart. The result: your server's clock JUMPS BACKWARDS.
The failure timeline
| Time | What happens | Result |
|---|---|---|
| T=1000ms | generate ID → timestamp bits = 1000 | short_code = "abc123" |
| T=1001ms | generate ID → timestamp bits = 1001 | short_code = "def456" |
| T=1002ms | NTP resyncs. Wall clock jumps BACKWARDS to 995ms | Silent skew |
| T=995ms (now) | Naive code generates ID → timestamp bits = 995. Same node_id + sequence resets to 0 | 🔴 COLLISION with T=995 IDs from BEFORE the resync. Your uniqueness guarantee JUST BROKE. 5 minutes later, INSERT ... ON CONFLICT fires on a code you thought was unique. |
The three survival strategies
| Strategy | How it works | When to use |
|---|---|---|
| 1. FAIL LOUD (recommended for URL shortener) | In Step 2 of the runtime algorithm: if now_ms < last_recorded_ms, THROW EXCEPTION. Return 500. Page the on-call engineer. | Better to have a 1-minute outage than a permanent silent duplication bug. |
| 2. WAIT IT OUT | Sleep until wall-clock catches up: while now_ms < last_recorded_ms: sleep 100us; refresh now_ms. | Under a 5ms drift this adds 5ms to a handful of requests. NEVER acceptable if drift could be seconds — queue backs up, timeouts cascade. |
| 3. MONOTONIC CLOCK + LOGICAL COUNTER | Twitter's original solution: use System.nanoTime() (monotonic, never goes backwards) instead of wall-clock. Convert to Snowflake timestamp bits via a fixed offset. | The correct fix — but requires care because monotonic clock is per-process, not per-VM. Never affected by NTP jumps. |
The monitoring you must have
| Metric | Threshold | Meaning |
|---|---|---|
Rate of (now_ms - last_recorded_ms < 0) events / second | Any > 0 | Your uniqueness guarantee is dying in real-time. Wake someone up. |
| Rate of same-ms sequence exhaustion events / second | Sustained > 100/sec | Add a Snowflake node OR shrink the sequence bit-width to buy more nodes. |
References for the clock-skew problem:
- Twitter Snowflake source — github.com/twitter-archive/snowflake — the original monotonic-clock implementation.
- Discord "How Discord Stores Trillions of Messages" — discord.com/blog/how-discord-stores-trillions-of-messages — includes their Snowflake variant and the operational learnings.
- Google TrueTime — cloud.google.com/spanner/docs/true-time-external-consistency — the alternative approach that Google Spanner uses (hardware clocks with bounded uncertainty).
- Kleppmann Ch 8 (Distributed Systems Trouble) — Designing Data-Intensive Applications, section on "Unreliable Clocks" — the canonical reference.
Interview soundbite for Snowflake operational risk: "The 40-bit layout is the easy part. The hard part is clock skew — NTP resync can jump your wall-clock backwards, break uniqueness guarantees, and cause silent duplicate short codes. I'd use a monotonic clock (System.nanoTime in Java, monotonic library in Go, Instant.now() with steady_clock in C++) to compute the timestamp bits — never the wall clock. If a monotonic option isn't available, I'd add a hard fail-loud check on now_ms < last_recorded_ms and monitor that counter as a critical SLO. Clock drift is Snowflake's #1 production incident cause; it needs a plan on day 1."
Pros:
- Zero coordination between nodes
- Roughly time-ordered (nice for DB clustering)
- Fits in 7 chars if you're careful
Cons:
- Timestamp wraps → collision possible if you're not careful about epoch selection
- Leaks approximate creation time (fine for shortener; problem for privacy-sensitive systems)
- Sequence bits must be sized carefully or you rate-limit yourself
Where this wins: L6-L7, multi-region, when you want no coordination and can accept the time-leak.
Where this loses: anywhere the ordering leak matters, or when 7-char codes are unacceptable.
How this evolves across scales
This is the interview soundbite you memorize:
| Scale | Approach | Rationale |
|---|---|---|
| 10K RPS (L4) | Random + collision check OR counter + base62 | Simple, fast to build. Retry loop is negligible at ≤100M URLs. |
| 100K RPS (L5) | Hash + salt + collision check OR KGS with small pool | Removes counter bottleneck. Collision retries still tolerable. |
| 1M RPS (L6) | KGS with sharded pool | Removes collision retries from hot path. Sharded per region. |
| 1B RPS (L7) | KGS + regional pools + Snowflake for cross-region create-time ordering | Regional pools handle throughput. Snowflake bits ensure no code duplication if two regions accidentally claim from overlapping ranges. |
Collision handling: what to say in the interview
The interviewer will ask: "What happens if two servers generate the same code?"
Your answer, at every level:
- L4 (Approach 1): "INSERT with / MySQL: use INSERT IGNORE /. If the insert affects zero rows, retry with a new random code. Retry cost negligible at our URL count."
- L5 (Approach 3): "Same — hash includes a random salt. On collision, regenerate with new salt. Retry loop bounded to 3 attempts before we alert."
- L6 (Approach 4/KGS): "It literally cannot happen — codes are pre-generated and marked-claimed atomically. The only failure mode is the pool starving, which we monitor and alert on 24h of headroom."
- L7 (Approach 4 sharded): "Same, plus regional pools use disjoint code prefixes (region_1 uses codes with bit 63 = 0, region_2 uses bit 63 = 1) so cross-region collision is mathematically impossible."
The five decisions summarized
| Decision | L4 | L5 | L6 | L7 |
|---|---|---|---|---|
| Alphabet | base62 | base62 | base62 | base62 |
| Length | 6 chars | 6 chars | 7 chars | 7 chars |
| Approach | random+check | hash+salt+check | KGS | KGS + region-sharded |
| Coordination | Local | Local | Central pool | Regional pool + prefix bits |
| Failure mode | Retry loop | Retry loop | Pool starve | Pool starve per region |
Now — a warning. Do not memorize this table. Derive it. In the interview, walk the interviewer through the requirements → math → approach choice. If you memorize and they probe with "what if we needed 5-char codes?" or "what if predictability was fine?", you lose. If you derived it, you can re-derive with new constraints.
References (16 items)
- Twitter Snowflake (2010) — original engineering blog: blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake. Source for the 64-bit ID layout used in Approach 5.
- Instagram — "Sharding & IDs at Instagram" (2012): instagram-engineering.com/sharding-ids-at-instagram-1cf5a71e5a5c. Their variant of Snowflake for photo IDs.
- Discord Snowflake documentation: discord.com/developers/docs/reference#snowflakes. Production reference implementation.
- Bit.ly — engineering blog on their short-code architecture: word.bitly.com (multiple posts across 2010-2018). Documents their evolution through counter → hash → KGS-style pool.
- Zipf, George Kingsley (1949) — Human Behavior and the Principle of Least Effort (Addison-Wesley). The origin of Zipf's law, used to justify why "top 10% of codes get 90% of traffic" is a reasonable prior.
- Birthday paradox math — Feller, W. (1968) An Introduction to Probability Theory and Its Applications, Vol. 1 (3rd ed., Wiley), Chapter II §3. Where collision probability P ≈ K²/(2N) comes from.
- WHATWG URL Standard — url.spec.whatwg.org. Authoritative reference for URL-safe character sets including base62's
[A-Za-z0-9]. - MySQL 8.0 Reference Manual — INSERT syntax with IGNORE / ON DUPLICATE KEY UPDATE: dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html. For the collision-handling semantics used in Approaches 1 and 4.
- CockroachDB `unique_rowid()` docs — cockroachlabs.com/docs/stable/AUTO_INCREMENT. Production reference for cluster-wide unique ID generation without centralized coordination.
- RFC 4122 — UUID standard (2005): rfc-editor.org/rfc/rfc4122. For comparing base62 codes against UUIDs (both mentioned in the "why not UUID" discussion).
urls(short_code CHAR(7) PRIMARY KEY, long_url VARCHAR(2048), created_at TIMESTAMP(3), created_by BIGINT, expires_at TIMESTAMP(3) NULL) clicks(short_code CHAR(7), ts TIMESTAMP(3), ip_hash CHAR(32), referrer TEXT, geo_country CHAR(2)) -- OLAP-partitioned
Short-code generation is 5 nested decisions: alphabet (base62), length (6-7 chars), approach (random / counter / hash / KGS / Snowflake), coordination (local vs central vs regional), collision handling (retry vs pre-generate). Approach evolves 10K → 1B RPS. KGS is the scale winner.
- What's the character space math for base62 6-char codes?
- What's the birthday-paradox collision probability at 1B URLs?
- Why does counter+base62 leak growth rate?
- How does KGS remove collision from the hot path?
- How does the short-code strategy evolve 10K → 1B RPS?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The pattern behind Snowflake IDs — how you get monotonic-ish ordering across nodes without a global clock. The theory that makes L7 KGS-per-region work.
The intuition behind coordination-free short-code generation — allocate 1M code blocks to each region, no coordination on the hot path.
The ring behind Redis Cluster shard assignment when KGS lives in Redis (Chapter 6.5). Also the algorithm behind sharded-DB placement in Chapter 7.
Chapter 5: assemble the L4 MVP. Now that you know how to generate the short_code (Approach 1 or 2 at this scale), we put the pieces together — LB + app servers + MySQL + short-code generation logic. And here, for the first time, I'll show you where every number in the design comes from — CPU %, buffer pool, IOPS, all derived from first principles.
Where every RPS number comes from
Little's Law + M/M/1 queueing + per-component derivations — so you never memorize a capacity number again
Every previous chapter has quoted numbers at you. "10K RPS is a single MySQL + 3 app servers." "At 100K RPS the DB primary saturates at ~85% CPU." "1M RPS needs sharding."
If you're paying attention, you've been asking the right question: how do you KNOW that? Where do those numbers come from?
That's the question this chapter answers, once, from first principles. Not with a memorized table you'll forget in a week — with a framework you can re-derive on a whiteboard in front of an interviewer for ANY system.
Interviewers probe this specifically. When they ask "how do you know it's 10K RPS and not 30K?" they aren't looking for you to recall a Medium article. They're looking for you to say: "It's derived. Here's the math." Then walk them through it.
By the end of this chapter you'll be able to:
- Compute app-server RPS from CPU, latency, and concurrency
- Compute MySQL primary QPS from buffer pool hit rate and IOPS
- Compute Redis single-node QPS from memory bandwidth
- Identify which component is the bottleneck at any RPS
- Defend your capacity numbers using Little's Law + M/M/1 queueing theory
- Recognize when you've hit the queueing cliff (~80% CPU)
This is the math foundation that makes every subsequent chapter defensible.
## Section 1 — The 5 questions every capacity claim must answer
Before I quote any number, I answer these 5. If someone quotes a capacity number without answering all 5, they're guessing.
text══════════ THE 5 QUESTIONS ══════════ 1. WORKLOAD What does ONE request do? (Redirect = 1 DB point-lookup + base62 decode + HTTP 302 Checkout = 5 API calls + payment provider + inventory check) Different workloads have different per-request cost. 2. HARDWARE What box am I running on? (m5.large = 2 vCPU + 8 GB RAM + gp3 3000 IOPS is very different from m5.2xlarge = 8 vCPU + 32 GB RAM + io2 20000 IOPS) 3. LATENCY TARGET What P99 do users tolerate? (< 10 ms for redirect vs < 500 ms for checkout — the target constrains throughput more than raw CPU does, via queueing) 4. CPU UTILIZATION TARGET Where does steady-state land? (60% is the standard target. 80% is the queueing cliff. Section 3 shows why.) 5. SAFETY MARGIN What multiplier for the "oh no" moments? (1.5× for viral spikes, 2× for AZ failover absorption. 1.0× — no headroom — is negligence.)
Rule: any capacity number you cite without answering all 5 is superstition. Interviewers hear the difference immediately.
Section 2 — Little's Law (the ONE equation you must know)
This is the single most important equation in system design. Memorize it. Use it in every interview.
text══════════ LITTLE'S LAW ══════════ L = λ × W L = number of requests in the system at any moment (concurrency) λ = throughput (requests per second) W = latency (seconds per request) Rearranged: λ = L / W or W = L / λ
Little's Law is a queueing-theory identity — it holds for ANY stable system, regardless of arrival distribution, service distribution, or scheduling. It works for a Starbucks queue. It works for a Node.js app server. It works for MySQL.
Applied to a URL Shortener app server
- Each redirect takes 10 ms end-to-end (W = 0.01 s). This includes the DB round-trip.
- One app server, sized to keep CPU at 60%, can handle 30 concurrent requests without degradation (L = 30).
- Throughput per app server = L / W = 30 / 0.01 = 3,000 RPS.
One box, three thousand RPS. That's the derivation.
Now watch what happens when latency changes:
- If latency doubles to 20 ms (something got slow — a slow DB, a GC pause, a bad query), throughput per box HALVES to 1,500 RPS.
- If latency drops to 5 ms (a warm cache path), throughput per box DOUBLES to 6,000 RPS.
This is why P99 latency and throughput are the same conversation. You cannot improve one without affecting the other. Interviewers love candidates who see this.
Applied to MySQL
- A point-lookup query (
SELECT ... WHERE short_code = ?) with a warm buffer pool takes ~50 microseconds of CPU. - MySQL can run ~40 such queries in parallel on a 2-vCPU box (each vCPU handles ~20 simultaneous "active" queries at 60% utilization).
- Throughput = 40 / 0.00005 = ~800,000 QPS theoretical on pure CPU.
- BUT: real queries have DB round-trip + connection overhead + logging. Realistic ceiling: ~20-25K QPS for warm-hit point lookups (see Section 5 for the full derivation with IOPS).
The interview soundbite
"I use Little's Law to derive throughput. L = λ × W. On my app tier with 10ms latency and 30 concurrent requests, that's 3,000 RPS per box at 60% CPU. If I need 10K RPS, I need 4 boxes (round up from 3.3 for safety). That's why I picked 3 boxes for L4 — I have 10ms latency and a small safety margin."
That soundbite ends interviews well.
Section 3 — Why 80% CPU is the queueing-theory cliff
Every senior engineer knows "don't run servers above 80% CPU." Junior engineers assume it's a rule of thumb. It's not — it's queueing theory.
For an M/M/1 queue (Poisson arrivals, exponential service times, single server), the mean response time relates to utilization U as:
text══════════ THE UTILIZATION CLIFF ══════════ mean_response_time = base_service_time × 1 / (1 - U) U = 0.50 (50% CPU) → response = 2× base (fine) U = 0.60 (60% CPU) → response = 2.5× base (the target) U = 0.70 (70% CPU) → response = 3.3× base U = 0.80 (80% CPU) → response = 5× base (danger zone) U = 0.90 (90% CPU) → response = 10× base (P99 starts melting) U = 0.95 (95% CPU) → response = 20× base (users complain) U = 0.99 (99% CPU) → response = 100× base (dead) Plotted, this is a HOCKEY STICK. The knee is at ~70-80%.
This is why senior engineers target 60% CPU steady state — you have room for a 2x traffic spike before you cross the cliff. If you steady-state at 80%, one bad rollout or one AZ failover pushes you into 100x baseline latency and everything melts.
Real-world manifestation
The M/M/1 approximation is optimistic (real systems have variance beyond exponential — GC pauses, disk seeks, network jitter — so the cliff is actually WORSE than the formula suggests). Reality: at 85% CPU, real Java/Go services often exhibit 20-50x P99 latency compared to 60% CPU. This is what you see in every real incident post-mortem: "we were fine at 60% CPU, then traffic doubled and P99 went from 20ms to 3 seconds."
The interview probe
Interviewer: "Why do you target 60% CPU and not 80%?"
You: "M/M/1 queueing says mean response time is base × 1/(1-U). At 60% I'm at 2.5x base and my P99 is stable. At 80% I'm at 5x base and one hiccup — a GC pause, a slow query, an AZ failover — pushes me over the cliff. Real systems have more variance than M/M/1, so 80% in production behaves like 95% on paper. 60% gives me a 2x buffer before the cliff. That's my viral-spike headroom."
Reference: Kleinrock, Queueing Systems Vol. 1 (1975) — the foundational textbook. All the pop-science "80% CPU" wisdom traces back to this.
Section 4 — Deriving app-server RPS from scratch
Everything is a derivation. Never memorize; always compute.
Step 1 — quantify per-request CPU
For a URL Shortener redirect (GET /r/{code} → DB lookup → 302 response):
- Parse HTTP request + decode headers: ~10 µs CPU
- Await DB round-trip (5 ms, mostly wall-clock wait, ~5 µs CPU cost for context switch): ~5 µs CPU
- Deserialize DB response + build 302 with Location header: ~50 µs CPU
- Emit access log + metric: ~30 µs CPU
- Framework overhead + kernel + TCP: ~100 µs CPU
Total per request: ~200 µs CPU on a modern Go/Node/Java service.
Where does 200 µs come from? Empirical. TechEmpower Round 22 benchmarks Go on m5.large at ~30K RPS for a "single query" benchmark. 30K RPS × 200 µs = 6 vCPU-seconds/second across 2 vCPUs = 300% utilization → the benchmark runs at ~100% CPU. Dividing back: at 100% CPU, per-request cost is ~66 µs; add production overhead (logging, metrics, TLS termination) and you land at 200 µs realistic.
Step 2 — apply Little's Law
Given:
- 2 vCPUs (m5.large)
- 60% target utilization
- ~200 µs per request
CPU-bound ceiling: 2 cores × 60% ÷ 200 µs = 6,000 RPS theoretical.
Step 3 — but wait, we need concurrency to fill CPU
Each request BLOCKS on the DB round-trip (5 ms wall-clock wait). If the app were single-threaded, we could handle only 200 requests/sec (1 second ÷ 5 ms). We need concurrency.
With modern async runtimes (Node event loop, Go goroutines, Java Loom), we can hold ~25-30 concurrent requests on 2 vCPUs before CPU becomes the bottleneck.
Applying Little's Law:
- L = 30 concurrent
- W = 5 ms (DB) + 5 ms (CPU work + network) = 10 ms
- λ = L / W = 3,000 RPS realistic
Step 4 — the safety margin
At 3,000 RPS realistic with 60% CPU, we have ~40% CPU headroom. A viral spike doubling traffic pushes us to 6,000 attempted RPS → we hit the CPU wall → autoscaling kicks in and adds more boxes. Meanwhile latency spikes to ~5x baseline briefly (M/M/1 says 80% → 5x base). Users experience 50 ms P99 for 90 seconds until scaling completes. Recoverable.
Number to remember
~3,000-5,000 RPS per m5.large app server for redirect-type workloads at 60% CPU, with healthy P99 latency.
For a heavier workload (checkout with 5 downstream calls), this drops to ~500-1,500 RPS per box. Always derive from your specific workload — never assume 3K.
Section 5 — Deriving MySQL primary QPS from scratch
Now the interesting derivation. This is what breaks at 100K RPS in Chapter 6.
Step 1 — the query pattern
For URL Shortener, 100% of reads are the point lookup:
sqlSELECT long_url FROM urls WHERE short_code = ?
The primary key is short_code. In MySQL InnoDB, the primary key IS a clustered index — the table's rows are STORED IN the primary-key B-tree. A PK lookup = 1 disk I/O (or 1 buffer pool hit for warm data). This is a slight but important advantage over MySQL, where PK is a separate B-tree pointing to a heap (2 I/Os per lookup).
Step 2 — cost per query
Warm hit (row in InnoDB buffer pool):
- B-tree traversal (3-4 levels for ~1B rows): ~5-10 µs
- Row extraction + result serialization: ~20 µs
- Network + connection overhead: ~20 µs
- Total: ~50 µs CPU per warm hit
Cold miss (row must come from disk):
- NVMe read: ~100-200 µs wall-clock (mostly I/O wait, ~5 µs CPU)
- Plus the same 40 µs of processing
- Total: ~200 µs wall-clock, ~45 µs CPU per cold miss
Step 3 — the two ceilings
MySQL is constrained by whichever hits first: CPU or IOPS.
CPU ceiling (assuming 100% warm-hit):
- 2 vCPUs (db.m6i.large) × 60% utilization ÷ 50 µs = ~24,000 QPS
IOPS ceiling (assuming 100% cold-miss):
- gp3 EBS baseline: 3,000 IOPS. That's 3,000 QPS.
- io2 provisioned: up to 20,000 IOPS. That's 20,000 QPS.
- Local NVMe (i3/i4 instances): 100,000+ IOPS. That's 100K+ QPS.
In production with a Zipfian access pattern:
- Top 10% of short_codes serve 80% of traffic (long-tail distribution)
- InnoDB buffer pool holds the hot set warm
- Realistic hit rate: 90-95% warm
Effective QPS = 0.9 × 24,000 (warm) + 0.1 × 3,000 (cold on gp3) = ~22,000 QPS
Number to remember: ~20-25K read QPS per MySQL db.m6i.large primary at 60-70% CPU, assuming Zipfian access + gp3 storage. If you provision io2, you can push into 50K+ range on the same box.
Step 4 — the write side
Writes are different beasts. Every INSERT must:
1. Acquire row lock: ~5 µs
2. Write to redo log buffer: ~10 µs
3. Flush redo log to disk (sync commit): ~500 µs on gp3, ~50 µs on local NVMe
4. Update the primary key B-tree: ~20 µs
5. Multi-AZ semi-sync replication round-trip: ~5 ms if in same-region cross-AZ, ~50 ms cross-region
For URL Shortener with Multi-AZ RDS (semi-sync to standby):
- ~5 ms per commit dominated by cross-AZ replication
- Single-connection write throughput: 1000 ms / 5 ms = 200 writes/sec per connection
- With 32 parallel connections (typical pool size): ~6,000-8,000 writes/sec total
Number to remember: ~5-10K write QPS per MySQL db.m6i.large primary with Multi-AZ sync commit.
Applied to URL Shortener at each tier
- 10K RPS = 9,900 reads + 100 writes → reads at 40% of CPU capacity (9,900 / 24,000), writes at 1% (100 / 8,000). Comfortable.
- 50K RPS = 49,500 reads + 500 writes → reads at 200% of one primary! Would need cache OR read replicas. This is L5 territory.
- 100K RPS = 99K reads → 4× a single primary. Even with 3 read replicas we're at 25K per box → 100% saturated. Cache is mandatory.
- 1M RPS = 990K reads → impossible on any single-primary architecture. Sharding + cache + replicas required.
The bottleneck sequence in the URL Shortener journey isn't invented — it's derived from these ceilings.
Section 6 — Component-limit cheat sheet (with derivations)
The single-page reference every engineer should carry mentally. Each row is DERIVABLE from Sections 4-5.
text══════════ COMPONENT CEILINGS ══════════ Component Peak RPS/QPS Ceiling reason ────────────────────────────────────────────────────────── App server 3-5K RPS CPU + concurrency (m5.large 2 vCPU) at 60% target ────────────────────────────────────────────────────────── App server 12-20K RPS Linear scale with cores (m5.2xlarge 8 vCPU) ────────────────────────────────────────────────────────── MySQL primary reads 20-25K QPS CPU at 60% + 90% warm (m6i.large 2 vCPU) buffer pool hit rate ────────────────────────────────────────────────────────── MySQL primary writes 5-10K QPS Multi-AZ sync commit (m6i.large 2 vCPU) dominates (~5 ms/commit) ────────────────────────────────────────────────────────── MySQL read replica 20K reads Same as primary reads (per node) (async replication for isolation) ────────────────────────────────────────────────────────── Redis single-node 100K-1M QPS CPU (single-threaded) + network bandwidth (10 Gbps NIC ceiling) ────────────────────────────────────────────────────────── Redis cluster 10M+ QPS Linear scale across (16-node example) shards; shard-key distribution matters ────────────────────────────────────────────────────────── L7 Load Balancer (ALB) 500K-1M RPS AWS docs; can go higher with pre-warming request ────────────────────────────────────────────────────────── L4 Load Balancer (NLB) 10M+ RPS Kernel-bypass path; near-zero per-packet CPU ────────────────────────────────────────────────────────── CDN edge (per POP) 100K+ RPS Practically limitless (CloudFront, Cloudflare) per POP; global fleet is billions of RPS
Rule: memorize the RANGES, not the exact numbers. Derive to the exact number for your workload during the interview. Interviewers respect derivation over recitation.
Section 7 — The URL Shortener bottleneck sequence
Now the payoff. Every architecture evolution in this journey is derivable from the ceilings in Section 6:
text══════════ URL SHORTENER BOTTLENECK SEQUENCE ══════════ RPS What breaks first Why What we add ──────────────────────────────────────────────────────────────────────────────────── 1K Nothing. 3 apps × 3-5K each = 9-15K RPS Nothing yet capacity. DB at 4% of ceiling. 10K App tier is the tight one Need 3-5 boxes to hit 10K RPS Autoscaling at 60% CPU target. DB at 40%. group; no other changes needed. 50K MySQL reads hit ceiling One primary maxes at ~25K reads. Read replicas Would need 2 primaries or (2-3 async replicas + cache. replicas). 100K Replicas hit IOPS ceiling Replicas share the same disk- Cache-aside on IOPS distribution as primary. Redis with 90% Random reads on cold data melt hit rate gp3 EBS. (offloads 90% of reads from DB). 500K Regional ALB caps out AWS ALB soft limit ~500K RPS Multi-region per LB. Cross-continent latency Route 53 latency > 100ms doesn't fit our SLO. routing + CDN. 1M Cross-region write throughput Global writes with sync Async cross-region replication add 100ms P99. replication + No single-primary write side eventual can absorb 100K writes/sec. consistency. 10M+ Speed of light Even with edge caches, cross- Move redirect continent RTT is ~100ms compute to CDN baseline. Can't be less. edge workers (Cloudflare, EC).
Every transition is derivable from a component's ceiling being crossed. Not memorized. Derived.
Show this table to an interviewer and walk them through the derivations. That is what "senior" looks like.
Section 8 — Interview technique — "walking the numbers" out loud
The skill isn't knowing the numbers. It's SAYING the numbers in a way that shows you derived them.
Practice each of these out loud until you can do it in 30 seconds without pausing:
For 10K RPS (L4)
"At 10K RPS with a 100:1 read:write ratio, I have 9,900 read QPS and 100 write QPS. My app tier is 3 × m5.large: each box does ~3-5K RPS at 60% CPU with 10ms latency by Little's Law (L=30 concurrent × 10ms). My DB is one MySQL m6i.large primary. Reads at 9,900 QPS ÷ 25K ceiling = 40% CPU. Writes at 100 QPS = 1%. Both well under queueing-theory cliff. This is the correct MVP."
For 100K RPS (L5)
"At 100K RPS my app tier scales linearly — 30 boxes across 3 AZs. But my DB hits the reads ceiling: 99K QPS ÷ 25K per primary = need 4 primaries or read replicas. Even 3 replicas + 1 primary = 4 × 25K = 100K capacity, which puts each at 100% utilization — over the cliff. Cache is mandatory here. Redis at ~1M QPS single-node capacity absorbs 90% of reads (Zipfian) → DB drops to 10K QPS at 40% utilization. This is the L5 architecture."
For 1M RPS (L6)
"At 1M RPS my regional ALB caps out (~500K per LB). I go multi-region with Route 53 latency routing. Reads: 990K QPS × 90% cache hit = 99K on DB, needs sharded MySQL (Vitess) across 4-8 shards to stay under 25K per primary. Writes: 10K writes/sec fits one primary; 100K writes/sec at extreme tail needs sharding by short_code hash. Cross-region async replication accepts eventual consistency in exchange for regional read latency < 20ms. This is L6."
Rehearse these until you can say them without notes. In real interviews you might get 60-90 seconds per tier — that's enough for the derivation IF you've rehearsed. Otherwise you'll skip the math and it will show.
The interview meta-lesson
The interviewer never cares about the exact number. They care whether you:
- Understand which component is the bottleneck (you named it: app tier, DB reads, DB writes, LB, network)
- Can derive the number (you cited Little's Law, showed the math, referenced the ceiling)
- Know the queueing cliff (you mentioned 60% target, not 80%, and you explained M/M/1)
- Chose the right mitigation (cache before sharding, replicas before microservices, CDN before edge compute)
If you can do those 4 things, you can defend any capacity claim they push back on. That's what separates Staff-level from Senior-level in the system-design loop.
References (33 items)
- Kleinrock, L. (1975). Queueing Systems, Volume 1: Theory. Wiley. — Foundational reference for M/M/1 utilization math.
- Little's Law — Little, J. D. C. (1961). A Proof for the Queuing Formula: L = λW. Operations Research. — The original 1961 proof.
- Kleppmann, M. (2017). Designing Data-Intensive Applications. O'Reilly. Chapter 1 covers reliability + throughput + latency; Chapter 7 covers transactions/replication for the MySQL derivation.
- TechEmpower Framework Benchmarks — techempower.com/benchmarks — where the "Go on m5.large does ~30K RPS" empirical number comes from.
- MySQL 8.0 Reference Manual — InnoDB Buffer Pool — dev.mysql.com/doc/refman/8.0/en/innodb-buffer-pool.html — for the buffer-pool warm-hit derivation.
- AWS Aurora + RDS MySQL performance benchmarks — the real-world reference for the 20-25K read QPS ceiling on m6i.large.
- AWS ALB limits — docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-limits.html — where the 500K-1M RPS-per-LB ceiling comes from.
Interview soundbite for this chapter as a whole:
"I derive every capacity number I quote. Little's Law gives me throughput from latency and concurrency. M/M/1 tells me the utilization cliff is at 80% CPU so I target 60% steady-state. Component ceilings — 3-5K RPS per app box, 20-25K QPS per MySQL primary, 500K-1M RPS per ALB — flow from that same math applied to specific hardware. When I say '10K RPS is a single MySQL,' I mean the read side is at 40% of the 25K ceiling and I have 60% headroom for viral spikes."
Ship this and you never get pushed back on capacity claims again.
Assumptions we made (5)
- 100M redirect requests/day → ~1,200 RPS avg, ~10K peak
- 1M new URLs/day → ~10 RPS avg, ~100 peak
- Read:write ratio ~100:1
- Average URL length: 100 bytes; short code 8 bytes; total row ~120 bytes
- Retention: 1 year
Drag the inputs. See what the URL shortener actually costs.
2025 AWS US-East public pricing. Every line item is defensible — hover over it to see the assumption.
| Category | Assumption | Monthly | % of total |
|---|---|---|---|
| Compute | 3× EC2 t3.medium app servers (2 vCPU · 4 GB) — $30/mo each | $90 | 20% |
| Load balancer | 1× AWS ALB — $22/mo + $8/LCU-hour (~$28 at this traffic) | $50 | 11% |
| Primary database | RDS Postgres db.t3.medium Multi-AZ — $100/mo + $50 storage | $150 | 33% |
| Bandwidth | Data-out: 1500 GB/mo × $0.09/GB (AWS egress) | $135 | 30% |
| Ops surface | S3 backups + CloudWatch logs + Route 53 — $30/mo | $30 | 7% |
| Total (monthly) | $455 | ||
Boring works. A single Postgres, a load balancer, 3 app nodes. This is the L4 MVP — costs a couple hundred dollars a month and serves 10K peak RPS reliably. Half the internet runs at this scale forever. You'd design this in an 8-week interview timeline.
Every capacity number derives from Little's Law (L = λ × W) + M/M/1 queueing theory (response time = base × 1/(1-U)). App server ~3-5K RPS on m5.large at 60% CPU. MySQL primary ~20-25K read QPS on m6i.large (buffer pool hit rate + IOPS). Write ceiling ~5-10K QPS (Multi-AZ sync commit). 80% CPU is the queueing cliff — target 60% steady. Bottleneck sequence at each URL Shortener tier is derivable from these ceilings, not memorized.
- What are the 5 questions every capacity claim must answer, and why does missing any of them make the claim superstition?
- What is Little's Law (L = λ × W), and how do you use it to derive app-server RPS from latency and concurrency?
- Why is 80% CPU the queueing-theory cliff — and what does the M/M/1 formula response = base × 1/(1-U) predict at each utilization level?
- How do you derive ~3-5K RPS per m5.large app server from CPU cycles + concurrency?
- How do you derive ~20-25K read QPS per MySQL m6i.large primary from CPU and buffer-pool hit rate + IOPS?
- Why do MySQL writes cap at ~5-10K QPS on Multi-AZ, and what's the sync-commit math?
- Given the URL Shortener bottleneck sequence, why is cache mandatory at 100K RPS but optional at 50K?
- How do you defend a capacity claim under pushback — cite the ceiling, cite the math, cite the mitigation?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The one queueing-theory identity that lets you derive throughput from latency and concurrency for any stable system. Used on every whiteboard interview.
Why response time = base × 1/(1-U) — and why senior engineers always target 60% CPU, never 80%. Foundational for capacity planning.
The data structure behind MySQL InnoDB's clustered index. Understanding B-tree traversal cost (~50 µs warm) is the basis for the 20-25K QPS derivation.
The Little's Law relationship in intuitive terms — why halving latency doubles throughput for a fixed concurrency budget.
Chapter 5: assemble the L4 MVP. Now you can defend every number in the architecture — CPU %, buffer pool hit rate, IOPS budget, safety margin. Chapter 5 stops explaining the math and starts building. Every capacity claim in Ch 5 uses the derivations from THIS chapter.
The scale evolution — 10K → 1B RPS in one interactive view
Drag the slider. Watch the architecture morph across every scale tier before we build it chapter by chapter.
The next four chapters (5 → 5.5 → 6 → 6.5 → 7 → 7.5 → 7.6 → 8) walk you through building this system tier by tier — L4 MVP → L5 viral → L6 global → L7 platform. Each chapter takes 20-30 minutes.
But before you dive in linearly, play with the slider below for 5 minutes. See what components appear when you cross each 10× scale boundary. Watch the bottleneck migrate from single-DB → cache → CDN → sharded DB → edge as traffic grows.
The pattern to notice: architecture doesn't get rewritten at each tier — it gets extended. The L4 stack stays inside the L7 stack. That's the whole discipline of scale evolution: you don't throw away what worked, you add a layer in front of it.
Ten thousand requests per second is comfortable for a well-tuned MySQL. The whole system is a load balancer, a small pool of app servers, and one database. Correctness first; scale later.
Bottleneck
- None yet — The design is comfortable at 10K RPS.
Solutions
- Add a database read index on `short_code` for O(1) lookup.
- Pool DB connections via ProxySQL (or RDS Proxy) to avoid connection-storm at burst.
Trade-offs
- Single-region means a region outage = full outage. Acceptable for 99.9%.
- No cache means every redirect is a DB hit — fine here, painful later.
Failure modes
- DB down → whole service down. Mitigation: warm standby + semi-sync redo log replication.
- App server crash → LB removes it from the pool; capacity drops by 33%.
At 10K RPS with a 100:1 read:write ratio, we're doing ~9,900 reads and ~100 writes per second. A well-tuned MySQL on a single m5.2xlarge box handles this comfortably. The right answer is often the simple answer — resist the urge to introduce Redis before you need it.
You now have visual context for every tier. Chapter 5 (below) builds L4 from scratch — a boring Postgres stack that handles 10K RPS. Chapter 6 adds Redis when reads spike. Chapter 7 goes global. Chapter 8 turns the whole thing into a $500M/year platform. Each chapter shows what BROKE at the previous tier and what you had to add. Never a rewrite. Always an evolution.
The MVP architecture (L4)
What ships in week 8 — boring, correct, cheap
This is the chapter that separates senior candidates from junior ones — and the answer is counterintuitive.
The right architecture for launch is the simplest possible thing that works. Not the most impressive. Not the most future-proof. The simplest.
Here's why: you have 8 weeks and 3 engineers. Every additional component you add is a new failure mode, a new deployment, a new monitoring surface, a new library to learn. Complexity has a cost, and at the MVP stage, the cost is time to market — which is your only real KPI.
So what's the MVP architecture?
- 1 load balancer (AWS ALB, ~$25/mo)
- 3 stateless app servers (t3.large in an ASG, ~$180/mo)
- 1 MySQL (RDS db.m6i.large Multi-AZ, ~$170/mo)
Total: ~$400/mo. Handles 10K RPS comfortably. Ships in 8 weeks.
Why 3 app servers — the derivation (this is what interviewers drill into)
"3 app servers" sounds arbitrary. It isn't. It's the minimum of three independent floors — you take whichever is highest.
Floor 1 — Raw throughput math (from [Chapter 4.75, Section 4](#chapter-4-75-bottleneck-derivation)).
Per-box capacity for the URL Shortener redirect workload on a t3.large (2 vCPU, 8 GB RAM):
- Per-request CPU cost: ~200 µs (parse HTTP + await 5ms DB round-trip + serialize 302 + emit log)
- Per-box throughput at 60% CPU: 2 cores × 0.6 ÷ 200 µs = 6,000 RPS theoretical
- Realistic with async runtime concurrency (Little's Law with L=30, W=10ms): ~3,000 RPS/box sustained
At 10K peak RPS: 10,000 ÷ 3,000 = 3.33 boxes → round up to 4 for pure throughput.
But wait — that's peak. Average RPS is lower (~2-3× ratio). And we have to price in safety margin. Recompute at the correct point:
- Average RPS: 10K ÷ 2.5 (peak factor) = 4,000 RPS
- Boxes at average: 4,000 ÷ 3,000 = 1.33 → 2 boxes
- With 1.5× safety margin for viral spikes: 3 boxes
Floor 2 — Multi-AZ high-availability floor.
AWS Well-Architected Reliability Pillar requires you to distribute across ≥2 availability zones for the 99.9% SLA that RDS Multi-AZ promises. Concretely:
- 2 AZs is the minimum for automatic failover on AZ loss (single-AZ = full outage during the ~1 AZ event per year, which happens; see AWS us-east-1 Dec 2021 outage postmortem).
- With 2 AZs, if you run 2 boxes (one per AZ), a single-AZ failure drops you to 1 box → 50% capacity. That's not a "brief blip," it's an outage during the AZ failure duration (typically 30 min - 2 hr).
- With 3 boxes across 3 AZs, single-AZ loss drops you to 2 boxes = 67% capacity. Absorbable for most workloads.
Floor 3 — Rolling-deploy floor.
You will deploy ~5-10 times/week. During a rolling deploy, one box is being replaced (~90s in a well-tuned ASG). If you run 2 boxes:
- Deploy takes one box out → 1 box left → 50% capacity mid-deploy
- If a spike hits during that 90s window, you drop requests
- Also violates the AWS Well-Architected guideline of "no single point of failure at any moment"
With 3 boxes, rolling deploy leaves you with 2 boxes at any moment (67% capacity). Absorbable.
The final answer: max(Floor 1, Floor 2, Floor 3) = max(3, 3, 3) = 3 app servers
Not 2 (fails Floor 2 + Floor 3). Not 4 (over-provisioned for L4). Exactly 3.
References for this derivation:
- AWS Well-Architected Framework — Reliability Pillar — the "distribute across AZs" guidance.
- AWS Auto Scaling best practices — rolling deployments — 90s replacement time and N-1 capacity during refresh.
- AWS us-east-1 December 2021 outage postmortem — real-world evidence of ~1 major AZ event per year at AWS.
- Google SRE Book — "Handling Overload" — the queueing-theory basis for the 60% CPU steady-state target.
- Kleinrock, L. (1975). Queueing Systems Vol 1. Wiley. — the M/M/1 math behind Little's Law and the 80% utilization cliff (also cited in Chapter 4.75).
Why NOT 5 or 10 app servers?
Real question junior engineers ask: "wouldn't 5 or 10 servers be safer?"
5+ boxes at L4 wastes money without adding meaningful availability. You've already covered:
- AZ failure (3 AZs, quorum survives loss of 1)
- Rolling deploys (2 of 3 always healthy)
- Peak spikes (1.5× safety margin baked in)
- Individual box crash (ASG replaces in 90s, other 2 boxes absorb)
Adding a 4th or 5th box protects against... what exactly? Two simultaneous AZ failures? That's the "region loss" scenario, which requires multi-region (L6), not more app servers. Two simultaneous box crashes in the SAME AZ? Uncorrelated failures at 3 boxes have vanishing probability (~10^-6 per year). Not worth the money.
The economics: 5 boxes = ~$300/mo, 3 boxes = $180/mo. That $120/mo × 12 = $1,440/year gets you 0 improvement in your 99.9% SLA. It buys you comfort, not availability.
When the number changes
3 is right for L4. It changes at L5 and L6:
- L5 (100K RPS, [Chapter 6](#chapter-6-l5)): horizontal scaling to ~10-30 boxes across 3 AZs, still stateless
- L6 (1M RPS, [Chapter 7](#chapter-7-l6)): 30 boxes per region × 3 regions = 90 total, with regional-level failure handling
Each transition has its own Floor 1/2/3 math. Never memorize the count — always derive it from the current traffic + reliability requirements.
Why db.m6i.large for MySQL? (the DB sizing derivation)
Same interviewer probe as "why 3 app servers?" — "why the m6i.large and not m6i.xlarge or db.t3.medium?" The answer is the same 3-floor discipline. This time the floors are: CPU capacity, memory (buffer pool), and disk IOPS.
Floor 1 — CPU capacity for our target QPS.
At 10K RPS with 100:1 read:write = 9,900 reads/sec + 100 writes/sec. Per Chapter 4.75, Section 5:
- Read CPU cost with 90% InnoDB buffer pool warm hit rate: 145 µs wall (50 µs CPU)
- Total CPU per second: 9,900 × 50 µs + 100 × 100 µs (writes) = 505 ms/sec CPU
- On a 2-vCPU db.m6i.large (2000 ms/sec CPU budget), that's 25% utilization
- With safety margin (peak factor 2.5× + AZ failover absorption 1.5×), we still stay well under 80% queueing cliff
Floor 1 says: db.m6i.large (2 vCPU) is sufficient. Cheaper options?
- db.t3.medium (2 vCPU BURSTABLE) — CPU credits system means sustained 30 min at 100% credits drop to 20% baseline. UNACCEPTABLE for a redirect service. Rule: never use T-family for anything with sustained CPU > 20% baseline.
- db.t3.large (2 vCPU BURSTABLE) — same problem.
db.m6i.large is the cheapest non-burstable general-purpose 2-vCPU option. Floor 1 = db.m6i.large.
Floor 2 — Memory for the InnoDB buffer pool.
Working set: at 10K RPS with Zipf α ≈ 1, the top 10% of URLs get 90% of traffic. Estimating 100M URLs stored (10 years × 1M/day retention math), top 10% = 10M URLs × 220 bytes = ~2.2 GB working set. Add InnoDB internal overhead (data dictionary, adaptive hash index, undo tablespaces) → ~3-4 GB actual buffer pool need.
- db.m6i.large — 8 GB RAM. Setting
innodb_buffer_pool_size = 6 GB(70-80% of RAM per MySQL 8.0 best practice) → 6 GB buffer pool ≈ 3× the working set. Comfortable warm-hit rate 92-95%. - db.m6i.xlarge (16 GB RAM, 2× the cost) — overkill at L4, wastes budget.
- db.t3.medium (4 GB RAM) — 3 GB buffer pool ≈ 1.4× working set. Marginal warm-hit rate ~85%. Hits IOPS ceiling faster on cold misses (see Floor 3).
Floor 2 = db.m6i.large (or larger, but m6i.large is enough).
Floor 3 — Disk IOPS.
RDS db.m6i.large ships with gp3 EBS at 3,000 baseline IOPS + throughput 125 MiB/s. Our cold-miss rate: 10% × 9,900 reads = ~990 IOPS on cold reads. Well under the 3,000 baseline. Writes: 100/sec × ~2 IOPS/write (redo + binlog + tablespace) = ~200 IOPS.
- gp3 3,000 IOPS baseline = 3× headroom. Comfortable.
- io2 (16K IOPS provisioned) — overkill at L4, but the right upgrade path at L5+ when cold-miss traffic scales to 3K+ IOPS.
Floor 3 = gp3 storage on db.m6i.large is sufficient.
The final answer: max(Floor 1, Floor 2, Floor 3) = db.m6i.large + gp3
- 2 vCPU: 25% CPU at 10K RPS (Floor 1 comfortable)
- 8 GB RAM: 3× working set headroom (Floor 2 comfortable)
- gp3 3K IOPS: 3× headroom on cold-miss volume (Floor 3 comfortable)
- Cost: ~$170/mo Multi-AZ (with $0.10/GB-mo × 100 GB storage) — the number that appears in Chapter 5's cost table
Why NOT skip Multi-AZ to save $85/mo?
Same reason we don't skip Multi-AZ on the app tier: single-AZ = ~1 outage/year at AWS (us-east-1 historical postmortems). At 10K RPS, one 4-hour AZ outage means ~144M failed redirects. For a $85/mo saving on infra, you'd need the product to tolerate ~2% annual downtime. No consumer product does.
When does the DB size upgrade?
Trigger: CPU sustained above 60% OR cold-miss IOPS above 2,000 (75% of gp3 baseline).
Upgrade path:
- First: add read replicas at L5 (see Chapter 6) to offload read CPU. Buys ~10× read headroom.
- Second: scale UP the primary to db.m6i.2xlarge (8 vCPU, 32 GB) when writes cross ~5K/sec. Buys ~4× write headroom.
- Third: shard the primary at L6 (see Chapter 7) when writes cross ~10K/sec sustained. Buys unlimited horizontal headroom (Vitess).
References for this derivation:
- MySQL 8.0 Reference Manual — InnoDB Buffer Pool sizing — 70-80% of RAM best practice
- AWS EC2 burstable performance credits docs — why T-family isn't right for a DB
- AWS EBS gp3 pricing/performance — 3K baseline IOPS + $0.08/GB-mo
- AWS RDS pricing calculator — Multi-AZ premium is ~$85/mo for db.m6i.large
- AWS us-east-1 outage postmortems — real historical evidence for Multi-AZ ROI
Here's what that L4 architecture looks like on a whiteboard:
5 boxes. Watch live traffic route through them. Try killing nodes.
ALB round-robins across 3 app nodes. Apps pool connections to Postgres Multi-AZ. Kill an app or the DB primary to see failover.
- · 3 apps × 3 AZs = tolerates 2 AZ failures
- · ASG replaces failed apps in ~2-5 min
- · Multi-AZ Postgres failover in 30-60s
- · ALB health checks every 10s auto-remove sick nodes
- · ALB · $25/mo
- · 3× t3.large apps · $180/mo
- · RDS db.m6i.large Multi-AZ · $170/mo
- · S3 backup + CloudWatch · $25/mo
- Total · ~$400/mo
Total: $400/mo · 5 boxes · 3 engineers hold it all in their heads.
Every arrow above is a hop that costs latency. Every box above is a failure mode. L4 is the minimum count of arrows and boxes that solves the problem. If you can draw fewer boxes and still meet the requirements, use fewer boxes.
No cache. No Kafka. No microservices. No Kubernetes. No. These are all wrong choices for the MVP because they don't solve any problem you have at 10K RPS.
I know this hurts. You spent 2 years learning distributed systems and I'm telling you to build a monolith. But look — every FAANG company started as a monolith. Facebook was a PHP monolith until ~2010 (The Facebook Engineering Blog — "HipHop for PHP"). Amazon was a Perl monolith until Bezos wrote the "services or fired" memo. The reason to introduce complexity is that a real problem forces you to. Never before.
## The "what you deliberately did NOT build" visual
Here's the diagram they never show you in FAANG blog posts, but the one that actually matters at L4 — the components you chose not to add, and the reason each rejection is defensible. Try the interactive comparison below — toggle each "overbuilt" component to see cost + timeline slip in real time:
Toggle "over-built" components ON to see cost + timeline slip.
Left: the correct L4 MVP (5 boxes · $400/mo · 8 weeks). Right: what you could over-engineer. Every toggle shows how much time + money you'd waste.
Every arrow is a hop that costs latency. Every box is a failure mode. L4 is the minimum count of arrows and boxes that solves the problem. If you can draw fewer boxes and still meet the requirements, use fewer boxes. Facebook was a PHP monolith until 2010. Amazon was a Perl monolith until Bezos wrote the 'services or fired' memo. The reason to introduce complexity is that a real problem forces you to. Never before.
How to read the two columns side-by-side: each ADDITIONAL box in the over-built version corresponds to a specific problem-you-do-not-yet-have. The table below defends every rejection with a concrete threshold — read it against the diagram:
| Component | Why it does not belong here |
|---|---|
| CDN | 10K RPS handles fine at origin. Adds 1 more DNS hop, 1 more billing surface, 1 more failure mode. (Right answer at L5, when hot 10% dominates.) |
| WAF | ALB has basic security groups + AWS Shield Std (free, DDoS L3/4 protection). WAF ($5/mo/rule + $0.60/million requests) buys L7 protection you do not need until you have real enemies. |
| API Gateway | ALB does path-based routing already. AWS API GW adds ~$3.50/million requests + 20ms latency + 1 more deployment surface. Zero benefit at L4. |
| Auth Service | We do not have logged-in users at MVP. Anonymous URL creation + optional email capture. When you need OAuth, add Cognito or Auth0 as a managed dep. |
| Redis Cache | MySQL is at 30% CPU with Zipfian hits already giving you a 90% InnoDB buffer pool hit rate — you already have a cache, it is called MySQL's InnoDB buffer pool. Adding Redis adds cache-invalidation (2 hard problems in CS, per Karlton) for no gain. |
| Kafka Queue | 100 writes/sec fits in a MySQL INSERT with 0 backpressure. Kafka's 3-broker minimum is $200/mo + 40 hours of learning + on-call burden. Add it when writes actually queue up (L5+ analytics). |
| Microservices | 3 engineers, 8 weeks. Every network hop between services is a distributed-systems problem. Ship a monolith with clear module boundaries. Extract services when a service actually deploys independently (per Fowler's "Monolith First"). |
| Kubernetes | EC2 + ASG + Multi-AZ gives you exactly what you would build in K8s (rolling deploy, replica count, health checks) with 1/10th the operational load. K8s is not "modern EC2" — it is a distributed OS that solves multi-team, multi-tenant scheduling. You do not have those problems at L4. |
| Elasticsearch | You do not have search yet. When you do (L6+), you have real signal for what to index. |
| BigQuery / Snowflake | 1M events/day fits in MySQL with a simple analytics_events table. When you cross 100M/day (L6), THEN you invest in an analytics warehouse. |
RULE: every box you add must delete a problem you actually have. If you add a box to prevent a problem you MIGHT have someday, you added complexity you MUST maintain today. That trade is bad.
Interview soundbite for this visual: "The senior-engineer skill is not knowing what to add. It's knowing what to not add and being able to defend the omission. At L4 I omitted CDN, WAF, API Gateway, Auth Service, Redis, Kafka, microservices, K8s, Elasticsearch, and BigQuery — and I can name a specific reason for each. If someone challenges me on any one of them, I have a metric-based threshold at which I would add it. That threshold is L5 for some, L6 for others, L7 for a few."
Why this matters for the newbie: every engineer starts by wanting to add cool tech. FAANG interviews specifically probe the opposite instinct: "you don't need this yet." Practice out loud: "I considered a CDN. I chose not to add it because at 10K RPS MySQL is at 30% CPU. When the hot 10% of URLs starts dominating requests, I'd add CloudFront in front. Threshold: p99 origin latency > 100ms." Do that for every rejected box.
Where does "30% CPU" come from? (the derivation you must own)
Every number I quote in this chapter is derived, not memorized. Here's the MySQL-CPU math for 10K RPS on a db.m6i.large (2 vCPU, 32 GB RAM):
Step 1 — the query pattern. We split 10,000 RPS into 9,900 reads (SELECT by short_code) and 100 writes (INSERT). That's the 100:1 read:write ratio a URL shortener has by definition.
Step 2 — cost per query. A point lookup on an indexed column in MySQL:
- Warm hit (row is in the innodb_buffer_pool_size pool): ~50 microseconds of CPU
- Cold hit (row must be fetched from disk): ~1 millisecond wall-clock (mostly NVMe IO wait, ~50µs CPU)
Step 3 — the hit rate. innodb_buffer_pool_size is 25% of 32GB = 8GB. Our full row set is 800GB (10 years' worth). The buffer pool can only hold ~1% of data. But URL popularity is Zipfian — the top 10% of URLs get ~90% of traffic. Even if our buffer pool holds only 1% of data, it holds the hot 1%, so the warm-hit rate settles around 90% once the cache is warmed.
Step 4 — the CPU math.
- 9,900 reads/sec × 90% warm × 50µs = 445 ms CPU/sec (on reads)
- 9,900 reads/sec × 10% cold × 50µs = 49 ms CPU/sec (CPU part of cold; IO wait doesn't count as CPU)
- 100 writes/sec × 100µs (redo log + B-tree insert) = 10 ms CPU/sec
- Total: ~504 ms CPU/sec used out of 2000 ms available (2 vCPU) = ~25% CPU
Step 5 — the honest bound. I rounded aggressively. Add ~5% for connection pool overhead, ~5% for background InnoDB purge/InnoDB purge thread, and you land at ~30-35% CPU sustained at 10K RPS. That's the number I quote in the design.
Step 6 — the safety margin. At ~30% CPU with 2-3x peak factor, we have ~70% headroom before saturation. That headroom is what absorbs viral events, backup pressure, and misconfigured queries. Do not run steady-state above 50% CPU on a single-primary MySQL — the queueing theory wall (utilization × wait time) melts your p99 above ~70% sustained.
Interview probe: "How do you know it's 30% and not 60%?" Answer: I derived it. Give them the 4-step math above. Interviewers love this — it proves you're not repeating a Medium article.
Below you'll find the L4 design in full detail — every decision, every alternative I considered, every reason why I picked what I picked. Read it carefully. This is the reference architecture. When someone asks you "how would you design a URL shortener?" in an interview, this is your first answer. Then, if they push, you evolve it. That's Chapter 6.
At L4, the interviewer wants to see that you can correctly design ONE thing end-to-end without going off the rails on scope. They do NOT expect microservices, sharding, multi-region, or Kafka. What they DO expect: a clean API contract, a sensible data model, a working single-service architecture, and honesty about where it breaks. Overengineering is punished — an L4 candidate who introduces Redis at 100 QPS 'just because' loses more points than one who says 'a single MySQL handles this and here's when we'd change it'.
A good L4 answer proposes the boring, correct architecture. A great L4 answer does that AND: names the specific EC2 instance / RDS class it would run on, gives a rough $/month estimate, calls out the 302 vs 301 trade-off proactively, mentions rate-limiting creation to prevent abuse, and states explicitly 'this handles 10K RPS comfortably; the first bottleneck at ~100K would be database reads, and here's what I'd add then'. That last sentence — showing you know what changes at the NEXT level — is what promotes an L4 candidate to leveling-borderline-L5.
How a request actually flows at L4
Two sequence diagrams — the READ path (redirect, hot) and the WRITE path (create, rare). At each level the sequence adds participants as the architecture evolves.
READ path — redirect at L4 (10K RPS)
Boring 3-tier: LB → app → MySQL. No cache. Every read hits the DB directly.
WRITE path — create at L4 (10K RPS)
POST → app → generate random code → INSERT → return short URL.
Scale evolution at a glance
The same problem, four scales. Each column shows what the architecture looks like AT that scale + the bottleneck that forces evolution to the NEXT one. Read left → right to trace the evolution.
Single region, single service, single database
Ten thousand requests per second is comfortable for a well-tuned MySQL. The whole system is a load balancer, a small pool of app servers, and one database. Correctness first; scale later.
- • Add a database read index on `short_code` for O(1) lookup.
- • Pool DB connections via ProxySQL (or RDS Proxy) to avoid connection-storm at burst.
Add a distributed cache and read replicas
Ten× the traffic. The database becomes read-bound around 100K RPS. Introduce Redis in front of the read path (cache-aside pattern) and add MySQL read replicas. Writes still go to the primary.
Redis — Single Redis becomes a hot spot; also a single point of failure.
- • Cluster Redis for read scale and high availability.
- • Add a small in-process LRU on app servers for the hottest short codes.
- • Rate-limit new URL creation per IP to blunt abuse.
Shard writes, cluster the cache, async analytics
At a million RPS, the primary database write path becomes the bottleneck. Shard writes by `short_code` hash. Redis becomes a cluster. Analytics move to a Kafka + stream-processing pipeline so the redirect path stays lean.
Hot shard — A viral URL's shard sees disproportionate traffic.
- • Consistent-hash sharding by `hash(short_code)` for even distribution.
- • For hot keys, promote the entry to an in-memory tier on every app server (LRU) with a short TTL.
- • Rate-limit URL creation per API key; provide bulk-create as an async job.
Global edge, multi-region active-active, distributed KV
A billion RPS means the redirect must complete at the edge in most cases — the origin sees only cache misses and writes. Storage moves from sharded MySQL to a globally distributed KV (DynamoDB-style). Writes are replicated across regions.
Global write replication — Cross-region replication adds tens of ms of latency and creates a consistency window.
- • For the 'create then share' hot path, return the short URL only after the KV write has been acked by ≥2 regions (quorum).
- • For redirects, tolerate eventual consistency — worst case is a 404 for a few hundred ms, gracefully retried.
- • Edge cache negative results (404s) with a short TTL to avoid origin storms.
L4 interviewer will probe you at every scale — here's how to answer at each
Four scale tiers, one interview level. The mentor tells you at each scale: how deep to go, what to say, what to skip, and what will kill your answer.
This is where L4 lives — go deep here
Single region, single service, single database
Walk through requirements → estimation → API → data model → the boring 3-tier architecture (LB + 3 app servers + MySQL). Explicitly say why you're NOT adding cache/queue/microservices at this scale. Cover the DB indexing choice, the redirect status code (302 vs 301), the short-code generation, and basic abuse prevention.
Restraint. Correctness of the fundamentals. Awareness of what's boring but right. Ability to defend 'no cache' as a positive choice, not laziness.
Adding Redis/Kafka/microservices at 10K because 'it's best practice.' The interviewer will interpret this as junior-thinking — you don't understand that complexity is a cost.
Ten thousand requests per second is comfortable for a well-tuned MySQL. The whole system is a load balancer, a small pool of app servers, and one database. Correctness first; scale later.
- Add a database read index on `short_code` for O(1) lookup.
- Pool DB connections via ProxySQL (or RDS Proxy) to avoid connection-storm at burst.
- Single-region means a region outage = full outage. Acceptable for 99.9%.
- No cache means every redirect is a DB hit — fine here, painful later.
- DB down → whole service down. Mitigation: warm standby + semi-sync redo log replication.
- App server crash → LB removes it from the pool; capacity drops by 33%.
At 10K RPS with a 100:1 read:write ratio, we're doing ~9,900 reads and ~100 writes per second. A well-tuned MySQL on a single m5.2xlarge box handles this comfortably. The right answer is often the simple answer — resist the urge to introduce Redis before you need it.
You should have a confident sketch here
Add a distributed cache and read replicas
'At 100K RPS, my MySQL primary saturates at ~85% CPU. I'd add Redis with cache-aside for the 92% hit-rate on hot URLs, plus 2 read replicas to route miss reads. I'd NOT shard yet — sharding is a solution to a problem I don't have at 100K writes/sec.' Then stop. Don't over-explain unless probed.
You recognize the bottleneck (DB read CPU) and pick exactly the right mitigation (cache first, replicas second). You explicitly reject premature sharding.
Jumping to sharding at 100K RPS. Or listing 5 different mitigations without picking one. Interviewers want you to make a decision.
One-sentence sketch — do not fake depth
Shard writes, cluster the cache, async analytics
'At 1M RPS I'd introduce sharding (by hash of short_code), multi-region with CDN at the edge for 95% offload, and Snowflake IDs to avoid cross-region write coordination. But 1M is L5+ interview territory, so I'll defer the deep design of that unless you want me to go there.'
Awareness that 1M exists and has different problems. Honesty that this is above your primary competency.
Trying to bluff depth at 1M when you're an L4 candidate. Interviewers detect this in 30 seconds and it's disqualifying.
Acknowledge and defer
Global edge, multi-region active-active, distributed KV
'1B RPS is platform-scale — it becomes a business and org-design problem, not a technical one. Own CDN, regional stacks, capital allocation. That's L7 conversation; I don't want to fake depth I don't have there.'
Self-awareness. Willingness to say 'I don't know at this depth' rather than fake it.
Making up numbers or claiming familiarity you don't have. Honest deferral is stronger than fake confidence.
The L4 design walkthrough in full
Now that you know how to speak across scales, here's the full L4 answer — walkthrough, alternatives considered, key decisions, common mistakes, and what separates a good L4 answer from a great one.
At L4, the interviewer wants to see that you can correctly design ONE thing end-to-end without going off the rails on scope. They do NOT expect microservices, sharding, multi-region, or Kafka. What they DO expect: a clean API contract, a sensible data model, a working single-service architecture, and honesty about where it breaks. Overengineering is punished — an L4 candidate who introduces Redis at 100 QPS 'just because' loses more points than one who says 'a single MySQL handles this and here's when we'd change it'.
Scale context
The interviewer will typically anchor the problem around 10,000 requests/sec (peak), ~100M redirects/day, ~1M new URLs/day, ~100:1 read-to-write ratio, single region, 99.9% availability (~9 hours/year downtime). Storage growth is ~120 bytes × 1M writes × 365 days ≈ 44 GB/year — completely comfortable for one MySQL.
Clarifying questions to ask
Ask these before drawing anything.
- 1What's the read-to-write ratio? (Confirms that this is a read-heavy caching-friendly workload.)
- 2Do we need custom aliases like /my-brand, or is auto-generated fine? (Custom aliases require conflict handling on write.)
- 3Do we need analytics (click counts, geo, referrer)? (If yes, define whether real-time or batch.)
- 4What's the availability target? (99.9% vs 99.99% changes the redundancy story significantly.)
- 5What's the URL retention? Forever, or does it expire? (Retention drives storage growth math.)
- 6Is this for a specific tenant/company or general public? (Public means abuse prevention matters.)
Design walkthrough
The story of the design, one section at a time.
1. Start with the simplest thing that could possibly work
At 10K RPS with a 100:1 read:write ratio, the entire system is: one load balancer, a small pool of stateless app servers (3 for HA), one MySQL database. That's it. No cache. No queue. No microservices. This is not a compromise — it's the RIGHT answer for this scale, and stating so with confidence signals seniority.
2. The API — three endpoints, no more
POST /urls with body { long_url, alias? } returns 201 { short_url, short_code }. GET /{short_code} returns 302 with Location header. GET /urls/{short_code}/stats returns { clicks_total } — protected by API key. Three endpoints. Idempotency on POST is achieved with a client-provided idempotency-key header, mapped to the DB via a unique constraint. Version the API through the URL: /v1/urls.
3. The data model — one table
urls (short_code CHAR(7) PRIMARY KEY, long_url VARCHAR(2048) NOT NULL, created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), created_by BIGINT, expires_at TIMESTAMP(3) NULL). Add an index on created_at for retention cleanup. Optionally a clicks table (short_code, ts, ip_hash) — but only if analytics is in scope. Data model complexity is a red flag at L4.
4. Short-code generation — random, and here's why
Generate a random 8-character base62 string (62^8 ≈ 218 trillion combinations). Store to DB — if unique-constraint violation, retry with a new string. At 1M writes/day, collision probability is negligible for years. Do NOT introduce a Snowflake ID or distributed counter at L4 — random+retry is textbook and correct.
5. Redirect logic — 302 not 301
Use 302 (Temporary Redirect), not 301 (Permanent). 301 lets browsers cache the redirect, so subsequent clicks never reach your origin — which means your click analytics undercount. 302 always hits origin, so analytics are accurate. This is a well-known trade-off. Bitly's official docs claim 301 as their default, but a fresh empirical test (curl -I on a newly-created bit.ly link) usually returns 302 — because Bitly treats editable links as temporary. Either answer is defensible; we pick 302 because analytics accuracy matters for our MVP.
6. Deployment — the boring answer
3 stateless app-server instances behind an AWS ALB, each t3.large. Managed MySQL (RDS db.m5.large) with automated backups, Point-in-Time Recovery (PITR), and a single synchronous standby for HA. That's the whole architecture. Total monthly cost: <$500. If the interviewer pushes on 'what if it grows?', pivot to the L5 answer.
Alternatives we considered (and why we didn't pick them)
For each alternative: why we rejected it, and when it would be the right choice.
MySQL instead of MySQL
Both are excellent at this scale — MySQL isn't 'wrong'. But MySQL wins for: cleaner JSON support if we later want to add flexible fields, stronger transactional isolation defaults (Read Committed vs MySQL's REPEATABLE READ quirks), and CTEs / window functions if we want richer analytics queries later. If the team is already on MySQL and MySQL would require training, use MySQL — no scale reason to switch.
MySQL wins if you plan to scale with Vitess later (Vitess is more mature than MySQL's Citus), if your team is already a MySQL shop, or if you need MySQL's superior read-replica lag behavior at scale.
Redis as primary storage
Redis is memory-first. A power loss or eviction can lose data. URL shortener data is durable-by-contract — a link the user shared cannot disappear. RDB snapshots + AOF give some durability but with a lag window. Redis as primary is a category error here.
Redis is the RIGHT choice for the cache tier at L5+ scale — but not for the source of truth, ever, for durable data.
DynamoDB from day one
At 10K RPS, DynamoDB works but is overkill. You pay for provisioned or on-demand capacity, learn the composite-key data-modeling paradigm, and lose the ability to run ad-hoc SQL. Cost: ~$500-1000/mo for this scale, vs ~$150 for a MySQL. Justified if you're already an AWS shop with DynamoDB expertise and expect to scale to 1M+ RPS; premature otherwise.
If you know you'll hit 1M+ RPS soon and don't want a migration; if your team's default is serverless / AWS-native.
Serverless (Lambda + DynamoDB + API Gateway)
Fine for very low or spiky traffic, but at sustained 10K RPS the Lambda cost + cold-start latency + API Gateway overhead beats a fleet of EC2 instances. Also the 'monolith is boring but works' story is more L4-appropriate than 'let me build a serverless architecture'.
MVP / prototype phase where traffic is unknown and you want zero server operations. Great for L4-adjacent hackathons; wrong choice for anything with predictable 10K+ RPS.
No load balancer, just DNS round-robin
DNS round-robin has no health-check awareness. If an app server dies, ~1/3 of traffic hits the dead one for the DNS TTL (usually 60s+). At 10K RPS, that's 200K failed requests. LB provides sub-second failover — for $20/mo, it's worth it.
Truly single-server deployment (dev, hobby); or geographic load balancing at the DNS layer complementing an LB below (like AWS Route 53 latency-based routing).
Key decisions and their reasoning
Load balancer — which product, which layer, how to avoid it becoming a SPOF
AWS Application Load Balancer (ALB) — Layer 7, multi-AZ deployment
Client resolves your DNS name (short.ly) → gets an ALB DNS name (multiple A records, one per AZ). Client picks one A record (DNS-level distribution). ALB in that AZ terminates TLS, inspects HTTP headers, picks a healthy target from the target group using a routing algorithm (default: least-outstanding-requests). Forwards HTTP request to target. Target's response goes back through ALB.
ALB is deployed across 3 Availability Zones. Each AZ has its own ALB node with its own IP. Route 53 returns all 3 IPs as A records with 60s TTL. If one AZ dies, its ALB node is unresponsive → client's TCP connect fails → browser retries the next A record → traffic seamlessly shifts. AWS's fleet management ensures each AZ's ALB node is itself redundant (N+2). Total: no single machine, no single AZ, no single region is a SPOF at this layer.
- •Deployment: internet-facing, IPv4 + IPv6 dualstack
- •Availability Zones: us-east-1a, 1b, 1c (multi-AZ mandatory)
- •Health check: GET /health every 30s, healthy threshold=2, unhealthy=3
- •Idle timeout: 60s (default) — plenty for our sub-100ms API
- •Routing algorithm: least-outstanding-requests (fairer than round-robin under variance)
- •TLS: ACM cert with auto-renewal, TLS 1.2+ only, HTTP/2 enabled
- •Sticky sessions: DISABLED (our servers are stateless, don't waste routing)
- •Access logs: enabled → S3 for debugging
Managed, HTTP-aware routing, WAF/ACM integration, cheap at low scale
AWS-only, no TCP/UDP support
Ultra-low latency, handles millions of RPS, static IP, TCP/UDP
No HTTP awareness (can't route by path), no WAF, more expensive at low RPS
Full config control, cheap, portable across clouds
You manage failover, patches, HA config, scaling
Global anycast, DDoS protection included, DNS + LB in one product
External dependency, pricing tiers, less AWS-native integration
At 10K RPS with HTTP-based traffic, ALB is the sweet spot. Managed by AWS (no LB to patch), integrated with Route 53 + ACM for HTTPS + WAF, auto-scales with traffic, includes health checks + sticky sessions. Named Layer 7 because it can route based on path/host/header — useful for /v1/urls vs /v1/analytics routing later. Total cost: ~$25/month + $0.008/LCU-hour (~$50/month at 10K RPS).
Application server — instance type, autoscaling, health checks
3× t3.large (2 vCPU, 8 GB) EC2 instances behind an ASG, min=3, max=6
ASG monitors the target group's CloudWatch metrics. When avg CPU >70% for 5 min OR request count/target >1000 for 3 min, launches new instance from Launch Template. New instance boots (~90s), registers with target group, passes health check, receives traffic. On scale-down, ASG stops instances with graceful connection drain (ALB stops sending new requests, drains for 30s).
Instances span 3 AZs (ASG places them evenly). If one instance dies: ALB health check fails after 90s → ALB stops routing → ASG detects EC2 status check fail → terminates + relaunches. Client-side: retry logic handles the failed request. If an entire AZ dies: 2 instances in remaining AZs absorb the load (that's why we sized for ~30% headroom). ASG auto-launches 1 more in a healthy AZ within 3 min.
- •Instance: t3.large (2 vCPU / 8 GB RAM, burstable, $60/mo/instance)
- •ASG: min=3, max=6, desired=3, cooldown=300s
- •Scaling policy: target-tracking on CPU >70% (5 min sustained)
- •Health check: HTTP GET /health, interval=30s, timeout=5s, unhealthy=3, healthy=2
- •Deployment: rolling with 1 in-service at a time (blue/green for zero downtime)
- •Runtime: Node.js 20 / Java 21 / Go 1.22 (any works, stateless)
- •Connection pool to DB: 10 per instance × 3 instances = 30 total (DB max_connections=200)
At 10K RPS with a stateless HTTP service, 3 t3.large instances handle it with ~30% CPU headroom. ASG (Auto Scaling Group) adds capacity on CPU >70% for 5 min. Fargate adds container overhead cost + slower cold starts for ~zero benefit at L4. EKS is operational overhead at this scale.
Primary database — engine, version, instance class, replication
Amazon RDS for MySQL 8.0 on db.m6i.large with Multi-AZ synchronous standby
Every write commit is synchronously replicated to standby in another AZ before returning success to app. Read/write goes to primary; standby is passive. If primary dies: RDS control plane detects (30s), promotes standby (DNS switch: same endpoint now points to promoted instance), app reconnects. Total failover: ~60-90 seconds. RDS also does daily automated backups + PITR (Point-in-Time Recovery) with 35-day retention.
Primary is NOT single-AZ. Standby is in a different AZ. Automated failover on primary death. Backups stored in S3 (multi-AZ by design). PITR lets us restore to any second in the last 35 days if someone drops the table. Even if BOTH AZs die (extraordinary AWS-wide event), we can restore from a backup in a different region. This is 3 layers of protection.
- •Engine: MySQL 8.0.2 (minor auto-upgrade enabled)
- •Instance: db.m6i.large (2 vCPU, 8 GB, $170/mo including Multi-AZ)
- •Storage: 100 GB gp3 SSD (baseline 3000 IOPS, autoscales to 500 GB)
- •Multi-AZ: ENABLED (synchronous standby in different AZ)
- •Backup: automated daily, 35-day retention, PITR enabled
- •Encryption: AES-256 at rest (KMS), TLS 1.2 in transit
- •Parameter tuning: max_connections=200, innodb_buffer_pool_size=2GB, effective_cache_size=6GB
- •Monitoring: Enhanced Monitoring + Performance Insights (free)
Rich SQL, strong ecosystem, ACID, JSON, easy vertical scale
Single-primary write bottleneck (L5 needs sharding or Aurora)
Ubiquitous, Vitess for sharding, tunable
Weaker JSON, quirky isolation defaults, weaker constraints
Infinite scale, no ops, single-digit ms latency
Composite-key learning curve, no ad-hoc SQL, $$$ at low RPS
Auto-scaling storage, 6-way replication, faster failover than RDS
3x cost of RDS at this size
MySQL 8.0 is stable, has excellent connection pooling via ProxySQL, mature JSON, strong SQL. db.m6i.large (2 vCPU, 8 GB) handles ~5K QPS comfortably with proper indexes. Multi-AZ synchronous replication (RDS-managed) gives sub-30s automatic failover. Aurora is better for higher scale (L5+) but 3x the cost.
Redirect HTTP status code
302 Found (temporary redirect)
Server responds with HTTP 302 status + Location: <long_url> header + Cache-Control: no-cache. Browser reads Location, issues a fresh GET. Because no-cache is set, browser will re-hit our server on the next click of the same short link.
- •Status: 302 Found
- •Location: <target URL>
- •Cache-Control: no-cache, no-store
- •X-Robots-Tag: noindex (don't let Google index the redirect endpoint)
302 keeps every click flowing through origin → accurate analytics. 301 lets browsers cache the redirect for months → analytics undercount by 50%+. 307 is semantically cleaner (preserves method) but no browser treats redirect chains from POST specially in practice.
Short-code generation strategy
Random 8-character base62 + retry-on-collision (max 3 retries)
Generate 8 random bytes via crypto.randomBytes, encode as base62 (result is 8 chars from [A-Za-z0-9]). Attempt INSERT with unique constraint on short_code. If duplicate-key error: retry with new random string. At 1M writes/day for 10 years = 3.6B URLs vs 62^8 = 218 trillion combinations = 0.0016% chance of collision on any single insert.
- •Length: 8 base62 chars (adjust to 6 for shorter URLs, 10 for longer)
- •Alphabet: A-Z + a-z + 0-9 (62 chars). Exclude 0/O/I/l for readability if desired
- •Storage: CHAR(7) PK — small overhead if length changes
- •Retry policy: max 3 attempts, then return 500 (never happens in practice)
- •Random source: crypto.randomBytes / SecureRandom (NOT Math.random)
Random+retry needs zero coordination, works up to hundreds of billions of URLs before collision probability matters, and produces friendly 8-char URLs. Auto-increment requires a distributed sequence (Redis INCR or ZooKeeper) — coordination overhead. Snowflake leaks creation time.
Abuse prevention — rate limiting + safe browsing
Token-bucket rate limit per IP (100 URL creates/hour) + async Google Safe Browsing API check
Rate limit: middleware maintains Redis counter per (IP or API key) with 1-hour TTL. Increments on each POST /urls. If >100, return 429 with Retry-After header. Safe Browsing: on POST /urls, enqueue check → mark URL 'pending review' → return short_code but flag in DB. Async worker calls Safe Browsing API v4. If bad, mark URL 'blocked' → redirect endpoint returns 410 Gone.
Rate limiter uses Redis with AOF persistence — losing a few counters is acceptable. Safe Browsing API is external; on failure, we fail-open (allow) and log for later review — don't want the entire URL creation to depend on an external API.
- •Rate limit: 100/hr per IP for anonymous, 1000/hr per API key
- •Safe Browsing: v4 API, threatTypes = MALWARE + SOCIAL_ENGINEERING
- •429 response: Retry-After header, JSON body { error: 'rate_limited' }
- •Blocked URL response: 410 Gone with 'This URL was flagged as malicious'
Public URL shorteners are heavily abused for phishing. 'Ignore' means Google flags your entire domain as malicious → all your links become useless. Rate limit filters bots; Safe Browsing detects known-bad URLs before they go live.
Common mistakes at this level
- Introducing Redis, Kafka, microservices, or sharding at 10K RPS — huge overengineering signal
- Skipping the API design entirely and diving into architecture — interviewer can't verify your data model without it
- Using UUID as short_code (22 chars in base62 — nobody wants a 22-char short URL)
- Choosing 301 for redirects without explaining the analytics trade-off
- Not addressing abuse / malicious URL prevention — public URL shorteners are heavily abused
- Vague answers to 'what happens when the DB goes down?' — L4 needs at least 'synchronous standby with automatic failover'
- Ignoring backups and PITR — auditor asks 'what if someone drops the table?' and there's no answer
What separates good from great at this level
A good L4 answer proposes the boring, correct architecture. A great L4 answer does that AND: names the specific EC2 instance / RDS class it would run on, gives a rough $/month estimate, calls out the 302 vs 301 trade-off proactively, mentions rate-limiting creation to prevent abuse, and states explicitly 'this handles 10K RPS comfortably; the first bottleneck at ~100K would be database reads, and here's what I'd add then'. That last sentence — showing you know what changes at the NEXT level — is what promotes an L4 candidate to leveling-borderline-L5.
Why this design fits L4 — and what will break it
Why this design works AT L4
Single MySQL db.m6i.large handles ~15K QPS on properly-indexed lookups (short_code is the primary key). We do 10K RPS with ~100:1 read:write, so 9,900 read QPS and 100 write QPS. MySQL uses <30% CPU at this load. 3 app servers × ~3,500 RPS each = 10,500 RPS with 30% headroom. Multi-AZ synchronous replication adds <5ms per write.
$400/month = $4,800/year. If we're a 3-engineer startup with $500K seed funding, this is 1% of runway. If we're a Fortune 500 pilot, it's a rounding error. Cost-per-request: $0.000001 — orders of magnitude cheaper than the value we're creating.
3 engineers can debug this system at 3am. One load balancer. One database. Three stateless app servers. Total moving parts: 5. Each engineer can hold the entire system in their head. That's the actual limit on complexity, not the tech.
- App server dies → ALB health check removes it, ASG replaces in 90s
- AZ fails → Multi-AZ MySQL promotes standby (30-90s), other AZs absorb traffic
- Bad deployment → rolling deploy strategy, easy rollback
- DB slow query → we have connection pooling + query timeout, worst case: one query fails
At 10K RPS with 100:1 R:W, we're doing 9,900 reads and 100 writes per second. A properly-indexed MySQL handles this at 30% CPU. Adding a cache here would introduce a cache-invalidation problem for zero performance benefit — the DB isn't the bottleneck yet.
Why this design breaks at the NEXT scale tier
MySQL primary CPU saturation on read path. Even with an index on short_code, at ~15K read QPS on a single m6i.large, you hit ~85% CPU. Beyond that, latency p99 climbs from 5ms → 50ms → 500ms non-linearly. This is the queueing-theory wall — utilization × wait time explodes near 100%.
MySQL CPU utilization sustained >70%. Also: p99 latency on GET /r/{code} exceeding 100ms.
Slow redirects. Clicks feel laggy. Some 500 errors during peak lunch traffic. Twitter starts trending #YourSiteIsBroken.
You can't just add more MySQL primaries — writes need a single source of truth. You could vertically scale the instance (m6i.large → m6i.4xlarge → r6i.8xlarge) but you're paying 8x more for 3x throughput. And you still have a single AZ failure domain. At 10x traffic (100K RPS = ~99K read QPS), no single MySQL box will handle it. This is when the design MUST evolve.
Interviewer will ask: 'What if you just scale up the MySQL box?' Your answer: 'Vertical scaling buys me ~3x. I need 10x. Also, above 30GB innodb_buffer_pool_size the marginal QPS gains flatten. So vertical is a delaying tactic. The right answer is to cache the hot 20% of URLs — 90%+ of reads become sub-millisecond and the DB sees only the long tail. That's L5.'
The trigger to evolve to the next tier
MySQL primary CPU utilization + p99 GET latency
CPU >70% sustained for 15 min OR p99 >100ms
Have the L5 upgrade plan ready 3 months BEFORE you hit these numbers
L4 = LB + 3 app servers + MySQL. $400/mo, 10K RPS, 8 weeks to ship. Every added component must justify its complexity cost. Simple architectures win MVPs.
- What's the L4 architecture for a URL shortener?
- Why NOT use microservices at L4?
- How much does the L4 stack cost?
- What's the 8-week deployment plan?
- When would you evolve past L4?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
Why MySQL Multi-AZ beats DynamoDB / Cassandra / MongoDB for the L4 MVP — the pick-the-right-database framework we applied.
Why AWS ALB (L7 HTTP-aware) beats NLB (L4 packet-level) at the MVP stage — the trade-off between throughput and header intelligence.
The two axes that pull opposite. Optimize P50 latency and you sacrifice max RPS. Understanding this is why we're not premature-optimizing at L4.
Chapter 5.5: the database decision. I picked MySQL above. In an interview, the very next question is 'why not MySQL? why not DynamoDB?' If you can't defend the choice against 3 alternatives with specific numbers, you don't own the decision. This chapter does the head-to-head so you never bluff again.
Why MySQL — and how the design changes if you pick PostgreSQL, Cassandra, or DynamoDB
The database decision — with head-to-head numbers, not vibes
Here's what always happens in an interview: you say "MySQL," and the interviewer immediately asks:
"Why not PostgreSQL? Why not DynamoDB? What if I told you we're a Postgres shop — how does your design change?"
If you shrug or say "MySQL has better JSON support," you've failed the probe. The right answer walks through requirements → workload shape → each option's fit → the specific reason THIS workload picks THIS database.
I'm going to give you all four candidates head-to-head, with the exact math and the exact "if you picked X, here's how the design evolves" branches. When you're done reading this, you can defend any choice against any interviewer.
Step 1: what does the URL shortener actually ask the database to do?
Requirements — spelled out so we can grade each option against them:
- Point lookup by primary key on the read path:
SELECT long_url FROM urls WHERE short_code = ?— 99% of read traffic - Insert with PK uniqueness check on the write path:
INSERT IGNORE INTO urls ...— 1% of traffic - Very few range scans — the hot path is entirely random-access
- Read-your-writes consistency for the URL creator (they just clicked "shorten," they expect the redirect to work immediately)
- Eventual consistency acceptable for analytics (5-minute lag OK)
- Row size ~220 bytes, mostly small strings
- Storage growth: 800GB in 10 years at 1M writes/day
- Multi-region strategy needs an answer at L6+
- Cost matters — the database is 60% of our infrastructure bill at L5+
Now grade the four candidates.
MySQL (the default recommendation)
The internals you MUST know:
- InnoDB MVCC (Multi-Version Concurrency Control) via undo tablespaces: readers never block writers, writers never block readers. Great for a shortener where reads dominate.
- Clustered B+tree index on the primary key: MySQL InnoDB stores the row data INSIDE the primary-key B+tree.
SELECT long_url FROM urls WHERE short_code = ?traverses the tree (3-4 levels for 3.6B rows) and the row data is IN the leaf. One I/O per warm lookup. Compare to Postgres: separate heap + PK index = 2 I/Os per cold lookup. - Redo log (InnoDB's Write-Ahead Log): every write goes to the redo log first, then to disk. Durability tunable via
innodb_flush_log_at_trx_commit(1 = full ACID, 2 = trade small durability window for throughput). - Buffer pool:
innodb_buffer_pool_size= 70-80% of RAM (~24GB on m6i.large's 32GB). InnoDB does all its caching in-process; the OS page cache is largely bypassed. Very different tuning philosophy from Postgres. - Query optimizer: cost-based since 5.7. Statistics-driven, gets better every major release.
- InnoDB purge thread: reclaims old row versions from undo logs. Runs automatically; can lag under heavy churn but rarely a problem.
Throughput math (this is the derivation you should own):
- Point lookup with warm buffer pool: ~40µs CPU (slightly faster than Postgres because clustered index means no heap dereference).
- Point lookup with cold buffer pool (disk hit): ~1ms wall (NVMe SSD)
- 2 vCPU on m6i.large = 2000ms of CPU per second
- At 90% buffer pool hit rate: 90% × 40µs + 10% × 1ms = 136µs per lookup wall time (IO dominates)
- CPU-only capacity: 2000ms / 40µs = 50,000 QPS ceiling (if 100% warm hits)
- Realistic sustained: ~20-25K QPS at 50-60% CPU with 90% warm-hit rate (see Chapter 4.75 for the full derivation from first principles)
Multi-region story:
- L4/L5: single primary, Multi-AZ synchronous standby (RDS-managed failover in 30-90s)
- L6: async binlog replication to secondary regions (5-500ms lag), OR Aurora MySQL Global Database (managed, <1s lag)
- L7: sharded across regions with Vitess — YouTube-tested horizontal sharding at billions-of-rows scale. This is the killer feature vs Postgres.
Cost:
- L4 (Multi-AZ RDS m6i.large): $170/mo
- L5 (m6i.4xlarge + 2 replicas): $2,400/mo
- L6 (r6i.8xlarge Aurora MySQL Global × 3 regions): $18,000/mo
- L7 (Vitess cluster 32 shards × 3 regions): $80,000/mo
Wins: rich SQL, mature tooling, familiar to every engineer, JSON column type (5.7+) with functional indexes, native full-text search (InnoDB FTS), extensive ecosystem, MVCC snapshot isolation "just works," excellent monitoring via performance_schema + sys schema, best-in-class Vitess for sharding, Aurora MySQL is a well-loved AWS-managed variant.
Loses: Postgres has richer types (arrays, hstore, PostGIS, tsvector); Postgres's SQL is slightly more standard-compliant; JSONB with GIN indexes in Postgres is more mature than MySQL JSON with functional indexes.
Verdict for URL Shortener: MySQL wins at L4-L7 by default. The clustered index is a slight but real edge for point-lookup workloads (1 I/O vs 2). Vitess is the L6+ killer feature.
PostgreSQL (the "we're a Postgres shop" alternative)
Same MVCC concept, same B-tree indexes, but with important differences:
- MVCC via xmin/xmax: Postgres stores per-row transaction IDs. Old row versions accumulate in the same table (called "bloat") until VACUUM reclaims them. Different implementation than InnoDB's undo tablespaces, same visibility guarantees.
- Heap-organized tables + separate PK index: the primary key is a B-tree that points INTO a heap file. A cold PK lookup = index I/O + heap I/O = 2 I/Os (vs MySQL's 1 with clustered index). For URL shortener where 100% of lookups are PK-based, this is a small but real disadvantage.
- WAL (Write-Ahead Log): same purpose as InnoDB redo log. Sync via
synchronous_commitandwal_sync_method. - shared_buffers: 25% of RAM (~8GB on m6i.large's 32GB). Postgres relies on the OS page cache for the other 75% — different philosophy from InnoDB. This is why sizing recommendations differ so dramatically.
- VACUUM / autovacuum: reclaims dead tuples from bloat. Runs automatically; can lag under heavy churn and cause table bloat if misconfigured.
- Extensions: PostGIS (geospatial), pg_stat_statements (query analysis), hstore, tsvector (full-text), pgvector (embeddings) — the extension story is Postgres's biggest edge.
Throughput math:
- Point lookup with warm buffer pool: ~50µs (slightly slower due to heap dereference)
- Cold hit: ~1ms (same NVMe latency)
- 2 vCPU capacity: ~40K QPS ceiling, realistic ~15-20K sustained (vs MySQL's ~20-25K)
"If you picked PostgreSQL, how does the design evolve?" — this is the interview question the user asked. Here's the branch:
- L4 (10K RPS): identical architecture — LB + 3 app servers + Postgres Multi-AZ (RDS or Aurora Postgres). Same $170/mo. Same 8-week timeline. Nothing changes except the DB engine and the buffer pool tuning knob.
- L5 (100K RPS): still identical — cache-aside with Redis in front, async streaming-replicated read replicas. Reads scale linearly with replicas. For read-your-writes on the create path, use synchronous_commit=on with a nearby standby.
- L6 (1M RPS): this is where MySQL wins by default. Postgres path requires Citus (Postgres extension for horizontal sharding, Microsoft-owned since 2019, powers Azure Cosmos DB for Postgres). Citus is mature but has less production battle-testing than Vitess at billions-of-rows scale. Alternative: CockroachDB (Postgres-wire-compatible NewSQL, ~2x cost but built-in horizontal scale + globally distributed).
- L7 (1B RPS): Citus + Redis cluster + edge caching. Or CockroachDB for multi-region active-active. Both work; Vitess ecosystem is more mature at this scale.
When PostgreSQL beats MySQL for a URL shortener specifically:
- Your team is already on Postgres (organizational fit dominates technical fit)
- You need JSONB with GIN indexes for a companion analytics workload
- You need PostGIS (geo queries on click origin)
- You need the richer SQL standard (window functions, CTEs — MySQL has them but Postgres's are more mature)
- Your team hires from the data-analyst pool (Postgres is dominant in analytics)
When MySQL still wins:
- Vitess at L6+ (this is the biggest single reason)
- Clustered index for point-lookup-dominant workloads
- Simpler default configuration for a small ops team
- Aurora MySQL is best-in-class among managed MySQL variants
Cassandra (the "write-heavy globally distributed" pick — wrong for us, know why)
Cassandra is the right answer for a different problem.
- Wide-column store, tunable consistency (per-query), no single leader
- Writes scale linearly with node count (each node accepts writes for its token range)
- Reads at eventual consistency are the sweet spot; strong consistency requires quorum reads (RF=3, R=2, W=2)
- No joins, no complex queries — you denormalize at write time
Point lookup latency: ~5-10ms p99 (across the network to at least one replica). MySQL is ~1ms local.
For URL shortener:
- Read path: fine. Cassandra can handle 99% point lookups at scale.
- Write path: overkill — at 100K writes/day (1 write/sec avg) we don't need write-parallel scaling.
- Operational cost: 5+ nodes minimum for RF=3 with even one AZ failure tolerance. That's $2K+/mo minimum before you have any workload. MySQL does the same job at $170/mo at L4.
"If someone forces Cassandra on us, how does the design evolve?"
- L4: 3 Cassandra nodes (RF=3), $2K/mo. Overkill but works. Cache-aside still valuable.
- L5-L6: benefits materialize — no read-replica routing (all nodes serve reads), automatic rebalancing, no manual sharding.
- L7: strong contender. Discord, Netflix use Cassandra at billions-of-rows scale for exactly this workload shape.
When Cassandra actually wins for URL Shortener:
- 500M+ URLs, write-heavy pattern (unusual for shorteners)
- Multi-region ACTIVE writes with tunable consistency
- Team already operates Cassandra (no learning curve)
When it loses: everything below L7. Operational overhead + latency cost don't pay back until you're at write-heavy planet-scale.
DynamoDB (the "just don't operate a database" pick)
Managed KV, pay per read/write, auto-scales.
- Point lookup: ~5-10ms p99 in region (single-digit ms with DAX cache)
- Write: ~5-10ms p99, strongly consistent by default (waits for majority of storage nodes)
- Auto-sharding: partition-key-based, invisible to you
- Global tables: multi-master multi-region, ~1s cross-region propagation
Cost math (this is the derivation the interviewer will ask):
DynamoDB On-Demand pricing (us-east-1, as of 2025):
- Read Request Unit (RRU): $0.25 per million (eventually consistent) or $0.50 per million (strongly consistent)
- Write Request Unit (WRU): $1.25 per million
- Storage: $0.25/GB/month
At 10K RPS with 100:1 R:W:
- 9,900 read/sec × 86,400 = 855M reads/day = 25.7B reads/month
- Cost: 25,700 × $0.25 = $6,425/month (eventually consistent, 4KB items)
- Write cost: 100/sec × 86,400 × 30 = 259M writes/month = $323/month
- Storage 800GB: $200/month
- Total: ~$7,000/month at L4 vs $400/mo for MySQL.
But at L6 (1M RPS):
- 990K read/sec = 2.57 TRILLION reads/month
- Cost: $642,500/month. Yes, that's real.
- Provisioned capacity with reserved pricing brings it down ~50%, so $321K/mo.
- Compare MySQL L6: $18K/mo.
DynamoDB is 15-30x more expensive at high scale. In exchange:
- Zero operational burden (no upgrades, no failover, no capacity planning)
- Multi-region built-in (Global Tables)
- Auto-scaling on hot partitions
"If your interviewer forces DynamoDB, how does the design evolve?"
- L4: LB + 3 app servers + DynamoDB. NO MySQL, NO Redis (DAX is optional). Simpler diagram, higher $/RPS.
- L5: add DAX (managed cache) — $0.30/hour per t3.small × 3 nodes = ~$650/mo. Cache hit rate 90%+ cuts DynamoDB read cost ~10x. Now the math is competitive.
- L6: Global Tables handle multi-region. No sharding logic in the app. Cost is still 15-30x MySQL, but ops team is 1 person instead of 5.
- L7: at 1B RPS the cost is astronomical ($6M+/mo). Only chosen if AWS-committed and can pay for the ops relief.
When DynamoDB wins:
- All-in on AWS, lock-in acceptable, ops budget < $200K/yr
- Truly bursty traffic (auto-scaling saves money vs provisioning peak)
- Team is 3 engineers, none want to operate a database
When it loses:
- Cost sensitivity — MySQL is 15-30x cheaper at scale
- Multi-cloud portability required
- Complex queries needed (DynamoDB is KV; joins/aggregations require Redshift/Athena on the side)
The decision tree (memorize the branches, not the answers)
Walk through the interactive tree yourself — answer Q1-Q4 in order and see which DB you land on. Try different answers to explore each branch:
Answer Q1-Q4 in order. Land on the DB that's right for your team.
Memorize the branches, not the answers. First YES wins — same tree Bit.ly / Shopify / GitHub applied to end up on Postgres.
Does the team run Postgres already?
Team fit dominates every other consideration at L4-L5. If you already run Postgres, adding another DB creates operational surface for zero technical gain.
Rule: Q1-Q4 are asked IN ORDER. First YES wins. Fallthrough default = Postgres. Every branch is defensible with a specific team + scale profile.
Reading this tree:
- Q1-Q4 are asked IN ORDER. First "yes" wins.
- Fallthrough default = MySQL (proven, cheap, familiar, Vitess-ready).
- Every branch is defended in the sections above.
Interview soundbite
When the interviewer asks "why MySQL?" your answer is:
"For our workload shape — point lookups on a small row with occasional writes at 100:1 R:W — MySQL, PostgreSQL, DynamoDB, and Cassandra all correctly handle the read path. The differences show up at three axes: operational cost (MySQL and Postgres cheapest, DynamoDB most expensive at scale), scale story (MySQL has Vitess, Postgres has Citus, DynamoDB has Global Tables, Cassandra scales natively), and team fit (whichever your team already runs). I picked MySQL because it's the default recommendation when no team-fit signal exists — the clustered index on InnoDB gives us 1 I/O per PK lookup vs Postgres's 2, and Vitess is the most battle-tested sharding solution at billions-of-rows scale. If you told me we're a Postgres shop, I'd swap to Postgres and pull in Citus at L6. If you told me we're on AWS with a small ops team, I'd swap to DynamoDB and eat the cost. The architecture pattern is the same either way — the DB is a substitutable component in this design."
That's the answer that gets you moved to the next round.
References (15 items)
- MySQL 8.0 Reference Manual — InnoDB Buffer Pool: dev.mysql.com/doc/refman/8.0/en/innodb-buffer-pool.html. Source for the 70-80% RAM buffer-pool recommendation.
- MySQL 8.0 Reference Manual — InnoDB MVCC: dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html. Authoritative reference for MVCC via undo tablespaces.
- MySQL 8.0 Reference Manual — INSERT ... ON DUPLICATE KEY UPDATE: dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html. For the upsert syntax used in the write path.
- Kleppmann, Martin (2017) — Designing Data-Intensive Applications (O'Reilly). Chapter 3 (Storage & Retrieval) covers B-tree vs LSM-tree; Chapter 6 (Partitioning) covers sharding strategies; Chapter 7 (Transactions) covers isolation levels. The single most-cited textbook in system-design interviews.
- Vitess documentation: vitess.io/docs. Includes the YouTube case study — production shard count, VSchema examples, resharding-without-downtime workflow.
- YouTube on Vitess — talks and case studies indexed at vitess.io/user-stories. Documents the migration from single-primary MySQL to sharded Vitess at billions-of-rows scale.
- PostgreSQL Documentation — MVCC (for the alternative branch): postgresql.org/docs/current/mvcc.html. Explains how Postgres implements MVCC via xmin/xmax and VACUUM.
- Citus documentation (Postgres sharding): docs.citusdata.com. For the Postgres L6+ story.
- Amazon DynamoDB Pricing: aws.amazon.com/dynamodb/pricing. Source for the $0.25/M read + $1.25/M write on-demand rates cited in the cost math.
- DeCandia et al. (2007) — "Dynamo: Amazon's Highly Available Key-Value Store," SOSP '07. dl.acm.org/doi/10.1145/1294261.1294281. The foundational paper on Dynamo's design that DynamoDB descends from.
- Lakshman & Malik (2010) — "Cassandra: a decentralized structured storage system," SIGOPS OSR 44. dl.acm.org/doi/10.1145/1773912.1773922. Original Cassandra design paper.
- Corbett et al. (2012) — "Spanner: Google's globally-distributed database," OSDI '12. research.google/pubs/pub39966/. For the "NewSQL" alternative that combines SQL with horizontal scale.
- Verbitski et al. (2017) — "Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases," SIGMOD '17. dl.acm.org/doi/10.1145/3035918.3056101. For Aurora MySQL Global Database (L6 multi-region story).
- RocksDB (Facebook) — rocksdb.org. The LSM-tree engine that underpins Cassandra, TiDB, CockroachDB. Referenced when contrasting B-tree (MySQL InnoDB) vs LSM-tree (Cassandra).
- RFC 8878 — Zstandard Compression (2020): rfc-editor.org/rfc/rfc8878. For the compression algorithm both MySQL (
mysqldump/ MyDumper) and Cassandra use for storage efficiency at the row-size math step.
Short-code generation
- Simple
- No coordinator needed
- Collisions grow with volume; requires retry-on-conflict
- No collisions
- Compact
- Needs a distributed counter (ZooKeeper, Redis INCR, DB sequence)
- Distributed, no coordinator
- Time-ordered (useful for TTL cleanup)
- Codes are longer (~12 chars) and can reveal time
Redirect status code
- Browsers cache; less origin load
- Analytics undercount (subsequent visits skip origin)
- Every click hits origin — accurate analytics
- More origin load
MySQL wins by default for point-lookup workloads with low writes and cost sensitivity. Clustered PK index gives 1 I/O per lookup vs Postgres's 2. Vitess is the L6+ killer feature. PostgreSQL is an equally-good alternative with different strengths (JSONB, PostGIS, richer SQL standard); pick it if team-fit signals. DynamoDB is 15-30x more expensive at scale but zero-ops. Cassandra is over-engineered below L7. Always defend the pick against 2 alternatives with specific numbers.
- Why does MVCC matter for a read-heavy URL shortener, and how do InnoDB and Postgres implement it differently?
- Why does the clustered index give MySQL an edge on cold PK lookups vs Postgres's heap+index?
- How much does DynamoDB cost at 1M RPS vs MySQL?
- What is Vitess and why is it MySQL's L6+ killer feature vs Postgres+Citus?
- How does the design evolve if you pick PostgreSQL instead of MySQL?
- When does Cassandra become the right choice?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The theorem that constrains every database choice — read this before you say 'DynamoDB is AP' or 'MySQL is CP'. Foundation for every trade-off in this chapter.
Two philosophies of correctness under contention — MySQL (ACID) vs DynamoDB (BASE) is a choice between these philosophies.
Linearizable, sequential, causal, eventual — the vocabulary for talking about what your DB actually guarantees. Every DB choice implies a consistency model.
Repeatable read vs serializable — the trade-off MySQL makes at the row level, and why DynamoDB's transactions cost 2× the RCU.
The mechanism inside MySQL that makes read replicas nearly free — and why write amplification hurts DynamoDB (LSM) less than MySQL (B-tree) at 1M writes/sec.
The data structure inside every relational database — why range queries are fast and why write amplification matters at scale.
The write-optimized alternative to B-trees — why Cassandra and DynamoDB beat MySQL on write throughput, and the compaction cost you pay.
Three topologies with three trade-off profiles — MySQL Multi-AZ (leader-follower) vs DynamoDB Global Tables (leaderless) vs Cassandra (multi-leader).
Range vs hash vs consistent-hash — the choice we make in Chapter 7 for the short_code table. Preview here.
Chapter 6: viral event, 100K RPS. We add a cache. And again, in an interview, the very next question is 'why Redis and not Memcached, or DAX, or application LRU?' Chapter 6.5 will give you the head-to-head. First, let's watch the fire itself.
You go viral (L5)
100K RPS. The DB is on fire. What now?
Congratulations. You launched. Marketing pushed it hard. TechCrunch wrote about it. You went from 10K RPS to 100K RPS overnight.
Now MySQL CPU is at 95%. Latency p99 is 2 seconds. Users are complaining.
Here's the mental model I want you to internalize: you don't scale an architecture; you evolve it. You don't wake up one day and redesign from scratch. You look at what's on fire, add exactly the thing that stops the fire, and move on. Then you wait for the next fire.
At 100K RPS the fire is the read-heavy database. URL shorteners are 100:1 read:write (every shorten creates 100 redirects). MySQL is doing 100K SELECTs/sec, most of them on the same hot codes. That's wasted work.
The evolution is obvious: cache the reads. Redis in front of MySQL. Cache-aside pattern. 90%+ hit rate on hot URLs. DB load drops 10x. Fire out.
That's it. That's the L5 upgrade. NOT sharding. NOT Kafka. NOT microservices. Just Redis, in front, cache-aside.
## Where does "92% cache hit rate" come from? (the Zipf derivation)
Here's the number every candidate quotes without proof. Let me prove it.
URL popularity follows Zipf's law. This is not a guess — it's an empirical property observed across every URL shortener that's ever published stats (Bit.ly, Cloudflare, and Twitter's t.co have all confirmed it). Zipf says: if you rank URLs by popularity, the k-th most popular URL gets access proportional to 1/k^α (usually α ≈ 1.0 for web URLs).
What that means numerically: the top 10% of URLs get ~90% of traffic. The top 1% get ~50%. This is the "hot-cold" split every senior engineer implicitly assumes.
Now the cache math. Suppose we hold the top N URLs in Redis (LRU eviction naturally keeps the hot set warm).
| Cache holds top N URLs | Fraction of traffic served from cache (Zipf α=1) |
|---|---|
| Top 1% (100K URLs) | ~50% |
| Top 5% (500K URLs) | ~80% |
| Top 10% (1M URLs) | ~90% |
| Top 20% (2M URLs) | ~92-93% |
| Top 50% (5M URLs) | ~97% |
Memory footprint. Each URL entry in Redis is ~300 bytes (short_code + long_url + overhead). To hold top 20% = 2M entries × 300 bytes = 600 MB. Fits on a single Redis node. Cheap.
So the 92% hit rate is: what you get when you provision Redis with enough memory to hold the top 20% of URLs, which is trivial to do. This is not a stretch goal; this is default Zipfian behavior.
How to verify in production: measure the cache hit rate on day 1. If it's below 85%, either (a) your working set is bigger than you think, (b) LRU is churning through evictions (buy more memory), or (c) Zipf α is closer to 0.5 (a less-hot distribution — you need to hold more of the tail).
Interview probe: "How do you know 92% hit rate? Show me." Answer: Zipf α ≈ 1, cache holds top 20%, gives 92%. Reference: any URL shortener's published cache-hit dashboard.
Where do "3 nodes at cache.r6g.large" come from? (the Redis sizing derivation)
This is the "how did you get 3 Redis nodes?" question every senior interviewer drills into. Same 3-floor pattern as the app tier (see Chapter 5, "Why 3 app servers") — take the max of throughput, HA, and rolling-deploy floors.
Floor 1 — Throughput math (per-node QPS ceiling).
Redis is single-threaded on the command dispatcher (Redis docs — "Why is Redis single-threaded?") — throughput is CPU-bound on ONE core. Per-node ceiling for GET/SET:
- Single-node GET: ~100K QPS at 60% CPU, ~1M QPS at burst — benchmarks from Redis Labs
- With production overhead (pipelining not always available, network jitter, mgmt overhead): ~150-200K QPS/node sustained
At 100K RPS × 92% cache hit = 92K QPS to Redis. Fits comfortably on ONE node (46% utilization).
So Floor 1 = 1 node.
Floor 2 — Multi-AZ HA floor.
Same argument as the app tier: single-node Redis = single point of failure. AWS ElastiCache Multi-AZ requires ≥1 primary + ≥1 replica across ≥2 AZs (ElastiCache Multi-AZ docs). Automatic failover in ~30-60s on primary loss.
Minimum for HA: 2 nodes (1 primary + 1 replica across 2 AZs).
Floor 3 — Read scaling + rolling-deploy floor.
Even though 1 primary can absorb our 92K QPS, we route replicas as read scale-out AND rolling deploy insurance:
- During AZ failure: 2 nodes → 1 node = 100% load on survivor. Absorbable at 46% baseline, breaches 92% on failover → risky.
- 3 nodes (1 primary + 2 replicas across 3 AZs): AZ loss → 2 survivors at 46% CPU. Comfortable.
- Rolling ElastiCache patch: instance refresh takes 1 node offline for ~90s (ElastiCache maintenance docs). With 3 nodes → 2 nodes at 46% → 69% CPU during patch. Manageable.
Floor 3 = 3 nodes.
Final: max(1, 2, 3) = 3 Redis nodes.
Why cache.r6g.large specifically?
Memory sizing floor: 600 MB working set × 2× headroom (fragmentation, key-value overhead, ephemeral expiration) = 1.2 GB minimum.
cache.t4g.small(0.5 GB) — too small for our 1.2 GB requirement.cache.m6g.large(6.4 GB) — general-purpose, no persistence, ~$80/mo per node.- `cache.r6g.large` (13.1 GB memory-optimized, ~$130/mo per node) — 10× headroom for growth, memory-tier priced. Best cost per GB of hot cache capacity at this size.
cache.r6g.xlarge(26 GB) — overkill until working set crosses ~10 GB.
Total: 3 × cache.r6g.large × ~$130/mo = ~$400/mo. That's the line-item in the L5 cost table.
References for this derivation:
- Redis single-threaded architecture docs
- Redis benchmarks — GET/SET throughput per instance
- AWS ElastiCache for Redis pricing
- ElastiCache Multi-AZ with Automatic Failover docs
- ElastiCache maintenance window docs
- Redis Cluster spec — hash slots + sharding threshold
When does the design cross to Redis Cluster?
Single-primary Redis (with N replicas for read scale + HA) works until you cross ~1M QPS OR ~13 GB working set. Beyond that, you need Redis Cluster — horizontal sharding across multiple primaries via 16,384 hash slots.
- At L5 (100K RPS, 92K QPS to Redis, 600 MB working set): 1 primary + 2 replicas. NOT Redis Cluster. Cluster adds complexity (client-side slot routing, cross-shard MGET/pipelining doesn't work) without any benefit.
- At L6 (1M RPS, 920K QPS to Redis, 6 GB working set): approach Cluster threshold. Depends on hot-key distribution.
- At L7 (10M+ RPS OR 100M+ URLs, 30+ GB working set): Redis Cluster mandatory. Shard by short_code.
Adding Cluster at L5 is the #1 premature optimization mistake in URL Shortener interviews. Reserve it for the actual write-scale problem.
Where does "MySQL CPU at 95%" come from? (the 10× math)
Same derivation as Chapter 5, scaled up:
- At 100K RPS with 100:1 R:W = 99K read/sec + 1K write/sec
- 99K reads × 90% warm × 50µs = 4,455 ms CPU/sec on reads
- Available CPU on m6i.large: 2,000 ms/sec (2 vCPU)
- We need 2.5× more CPU than we have. Queue length grows unboundedly. p99 melts.
That's why the fire is CPU. Not disk, not network, not memory — CPU on the read path.
Adding Redis reduces read CPU by 12.5×:
- 8% of 99K reads = 7,920 reads miss cache and hit MySQL
- 7,920 × 90% warm × 50µs = 356 ms CPU/sec (MySQL reads)
- Add 1K writes × 100µs = 100 ms CPU/sec
- Total after cache: ~456 ms CPU/sec on m6i.large = ~23% CPU
Fire out. The cache didn't magically add capacity — it moved 92% of the work to a fundamentally cheaper compute (in-memory hash-map lookup vs indexed disk-backed row store).
The cache-aside flow, visualized
Cache-aside has TWO paths: a hot read path (99% of traffic) and a cold write path (1% of traffic). Toggle between them in the interactive step-through below — animated participants, per-step latency, and cache-HIT vs cache-MISS branching:
Toggle between paths. Step through each Redis / Postgres round-trip.
READ path is 99% of traffic. WRITE path is 1%. 92% of reads hit cache (no DB touch); the miss path uses SETNX single-flight to prevent stampedes.
Read path: 92% cache hit → 0 DB touches. Miss uses SETNX single-flight → 1 winner reads DB + 9,999 losers wait for cache to warm. Write path: Insert to Postgres, then invalidate (not update) the cache. Race-free — next reader forces a fresh DB read that always wins.
Two things a newbie should notice:
- The read path has NO DB call 92% of the time. That's the entire economic value of the cache — 92% of requests never touch MySQL. MySQL load drops 10× exactly because of that.
- The write path invalidates, doesn't update. Populating the cache on write looks tempting but introduces races (writer #1 puts value A into cache, writer #2 puts value B into DB but doesn't reach cache yet — reader sees stale A). Invalidating instead means the next reader forces a fresh DB read that always wins.
Now — the details matter. How you introduce Redis is where senior candidates shine:
- Cache key format?
url:{short_code}— includes namespace to allow future keys. - TTL? 24 hours with ±20% jitter to prevent mass expiration.
- Stampede protection? SETNX-based distributed lock when repopulating a hot key.
- Cache-miss fallback? Fetches from MySQL, writes to cache with jittered TTL.
- Cache eviction policy? allkeys-lru — evict the coldest 1% when memory is full.
What SETNX actually prevents — the cache stampede visualized
I listed "SETNX-based distributed lock" as a bullet. A newbie reading that thinks: "OK, some locking thing. Moving on." Then you ship without it, one hot key expires at 3AM, and MySQL melts. Every cache-aside system fails this way if you skip stampede protection. Let me show you exactly what happens with and without it — because you need to feel the disaster to remember the fix.
Scenario: our short_code aB3xY9 is a viral URL currently being clicked 10,000 times per second. Its cache entry hits the 24-hour TTL and expires at T=0. Here is what happens in the next 3 milliseconds — as a live interactive animation. Toggle between the two scenarios (WITHOUT SETNX = thundering herd; WITH SETNX = single-flight rescue) and watch the DB meters + cache state update per phase:
Watch the same 10,000-request flood destroy the DB (WITHOUT SETNX) — or barely register (WITH SETNX).
Every cache-aside system fails this way if you skip stampede protection. Toggle between scenarios and step through the phases.
The cache entry for the viral URL aB3xY9 hits its 24h TTL. It disappears from Redis.
What the sequence diagram makes obvious that the prose hides: the losers do TWO Redis round-trips (SET NX fail → GET after sleep). That's ~200 microseconds of extra latency per loser, negligible. The winner does ONE MySQL round-trip. The whole cache-miss handling window closes in <30ms with SETNX vs 12 minutes without.
Newbie mentor commentary — read this three times:
- The disaster isn't rare — it's inevitable. Every URL shortener that survives to L5 will hit this exact failure at some point unless SETNX is in place from day one. It's the L5 rite of passage. Ship without it and you will have this incident.
- The fix is 5 lines of code. SETNX (SET if Not eXists) is one Redis command. A short retry loop on the client side is another 3 lines. The total code footprint for stampede protection is smaller than a single test method. There is no reason not to have this.
- The "sleep and retry" pattern is called single-flight. The name comes from Google's Go standard library (
[golang.org/x/sync/singleflight](https://golang.org/x/sync/singleflight)). Same idea, different implementation: coalesce duplicate requests for the same key so only one hits the backend. Every language has an equivalent library.
- The lock TTL of 1s is deliberate — it's a safety net. If the winner crashes mid-fetch (say, MySQL is slow), the lock auto-expires in 1 second and another request takes over. Without the TTL, a crashed winner would leave the lock forever and losers would spin infinitely. Every lock in a distributed system must have a TTL. This is the Chubby (Burrows 2006) lesson applied to Redis.
- This pattern applies far beyond URL shorteners. Any read-through cache in front of any expensive backend — LLM API calls with prompt caching (see A3 RAG journey), rendered page fragments, computed authorization results, expensive analytics queries — needs the same single-flight protection. Learn it once here, apply it everywhere.
Interview soundbite: "For hot-key cache stampede I use SETNX single-flight — the first request to see the miss acquires a Redis lock, fetches from MySQL, populates the cache, releases the lock. Concurrent requests retry after 10-20ms and hit the now-populated cache. This turns a potential 10,000-QPS stampede on a single row into 1 DB read + 9,999 cache reads. The lock TTL is 1 second to auto-recover if the winner crashes. Without this, the L5 architecture is one hot-key expiry away from a full cascade."
References for stampede protection:
- golang.org/x/sync/singleflight — the canonical Go implementation of single-flight coalescing.
- Redis SET NX documentation — redis.io/commands/set — the primitive that all Redis-based locks use.
- Redlock algorithm (Antirez 2016) — redis.io/docs/latest/develop/use/patterns/distributed-locks — the more paranoid multi-Redis-node variant when a single Redis is unacceptable.
- Chubby paper (Burrows 2006, OSDI) — research.google/pubs/pub27897 — the foundational distributed-lock service that established "every lock needs a lease/TTL."
- Grok's SRE book references cache stampedes as one of the top 5 outage patterns.
You also add read replicas on the MySQL side so writes hit primary but reads hit replicas. That handles the remaining 10% of reads that miss cache.
Read the full L5 design below. Watch how every decision has a reason — not "because best practices," but "because at this scale, this specific problem exists, and this specific thing solves it." That's the alternatives-first thinking. Chapter 7 goes global.
At L5, the interviewer wants end-to-end design with explicit trade-offs and evidence of scale intuition. They expect you to introduce a cache and read replicas — but they also expect you to say WHY, name the specific choice, and defend it against alternatives. Trade-offs must be stated explicitly ('we picked X because Y, at the cost of Z'). If you skip the 'at the cost of' part, you lose points.
A good L5 answer introduces cache-aside and read replicas correctly. A great L5 answer does that AND: proactively addresses cache stampede with coalescing + jitter; discusses the read-your-write anomaly with a concrete mitigation; names specific instance sizes and rough monthly cost; states 3 SLOs with alert thresholds; discusses abuse prevention (rate limits + Safe Browsing); and — most importantly — explicitly says 'the next bottleneck at 1M RPS will be primary write throughput and single-Redis capacity, and here's what changes'. Showing you know the boundaries of your design is the L5→L6 signal.
How a request actually flows at L5
Two sequence diagrams — the READ path (redirect, hot) and the WRITE path (create, rare). At each level the sequence adds participants as the architecture evolves.
READ path — redirect at L5 (100K RPS)
Redis cache-aside in front of MySQL + read replicas. 92% cache hits.
WRITE path — create at L5 (100K RPS)
Writes go through primary; cache invalidated. Async abuse-check via SQS.
Scale evolution at a glance
The same problem, four scales. Each column shows what the architecture looks like AT that scale + the bottleneck that forces evolution to the NEXT one. Read left → right to trace the evolution.
Single region, single service, single database
Ten thousand requests per second is comfortable for a well-tuned MySQL. The whole system is a load balancer, a small pool of app servers, and one database. Correctness first; scale later.
- • Add a database read index on `short_code` for O(1) lookup.
- • Pool DB connections via ProxySQL (or RDS Proxy) to avoid connection-storm at burst.
Add a distributed cache and read replicas
Ten× the traffic. The database becomes read-bound around 100K RPS. Introduce Redis in front of the read path (cache-aside pattern) and add MySQL read replicas. Writes still go to the primary.
Redis — Single Redis becomes a hot spot; also a single point of failure.
- • Cluster Redis for read scale and high availability.
- • Add a small in-process LRU on app servers for the hottest short codes.
- • Rate-limit new URL creation per IP to blunt abuse.
Shard writes, cluster the cache, async analytics
At a million RPS, the primary database write path becomes the bottleneck. Shard writes by `short_code` hash. Redis becomes a cluster. Analytics move to a Kafka + stream-processing pipeline so the redirect path stays lean.
Hot shard — A viral URL's shard sees disproportionate traffic.
- • Consistent-hash sharding by `hash(short_code)` for even distribution.
- • For hot keys, promote the entry to an in-memory tier on every app server (LRU) with a short TTL.
- • Rate-limit URL creation per API key; provide bulk-create as an async job.
Global edge, multi-region active-active, distributed KV
A billion RPS means the redirect must complete at the edge in most cases — the origin sees only cache misses and writes. Storage moves from sharded MySQL to a globally distributed KV (DynamoDB-style). Writes are replicated across regions.
Global write replication — Cross-region replication adds tens of ms of latency and creates a consistency window.
- • For the 'create then share' hot path, return the short URL only after the KV write has been acked by ≥2 regions (quorum).
- • For redirects, tolerate eventual consistency — worst case is a 404 for a few hundred ms, gracefully retried.
- • Edge cache negative results (404s) with a short TTL to avoid origin storms.
L5 interviewer will probe you at every scale — here's how to answer at each
Four scale tiers, one interview level. The mentor tells you at each scale: how deep to go, what to say, what to skip, and what will kill your answer.
You know this cold — 60 seconds max
Single region, single service, single database
'10K RPS is a single MySQL + 3 stateless app servers behind an ALB. No cache, no sharding. This is the correct MVP. I'd stand this up in 8 weeks.' Move on.
You don't waste time on the easy stuff. You demonstrate that 'boring' is a conscious choice.
Spending 10 min on 10K when the interviewer wanted to test you at 100K+. Read the room.
This is where L5 lives — go deep here
Add a distributed cache and read replicas
Deep on cache-aside (key format, TTL with jitter, stampede protection via SETNX, negative caching). Deep on read replicas (routing strategy, replica lag handling, read-your-writes fallback to primary). Explicit trade-off: cache invalidation complexity accepted for 10x DB load reduction. Cover a real failure mode (Redis primary dies) end-to-end.
Fluency with the cache-aside pattern's edge cases. You've clearly built this before, not just read about it.
Hand-waving the cache invalidation story. Not knowing what happens on cache miss during Redis failover.
Ten× the traffic. The database becomes read-bound around 100K RPS. Introduce Redis in front of the read path (cache-aside pattern) and add MySQL read replicas. Writes still go to the primary.
- Redis — Single Redis becomes a hot spot; also a single point of failure.
- Cluster Redis for read scale and high availability.
- Add a small in-process LRU on app servers for the hottest short codes.
- Rate-limit new URL creation per IP to blunt abuse.
- Cache-aside means stale reads briefly after a URL is updated — acceptable, updates are rare.
- Read replicas are eventually consistent (~ms). Redirects tolerate this; write-then-read on the same page would need session stickiness.
- Redis down → cold-cache stampede on DB. Mitigation: circuit breaker in the service to shed load if DB p99 exceeds threshold.
- Primary DB down → failover to a replica (~30s outage of writes).
The database read path is now the bottleneck. We introduce Redis in front (cache-aside) and read replicas behind. This is the first architecture where the interviewer expects you to talk about cache invalidation and replication lag.
Confident sketch with trade-offs
Shard writes, cluster the cache, async analytics
'At 1M RPS I move to sharded MySQL (hash by short_code, 4-8 shards), Redis cluster (with hash-slot routing), CDN at edge for 95% offload on redirects, and multi-region API endpoints with async cross-region replication. The trade-off I accept: cross-region read-your-writes is no longer guaranteed — a new URL is visible in its home region in <10ms, globally in <1s.'
You can lay out the multi-region architecture confidently AND name the consistency trade-off you're accepting.
Adding multi-region without explaining what consistency guarantee you gave up. Every senior engineer sacrifices something at multi-region; if you can't say what, you haven't done it.
One-sentence architectural sketch
Global edge, multi-region active-active, distributed KV
'1B RPS is where the technical problem shifts to a business problem — own-CDN vs commercial-CDN economics ($50M/year decision), regional legal-entity operations for GDPR/PIPL, and org-design for a 200-engineer team. I can gesture at the tech (Netflix Open Connect-style edge, tiered storage), but the deep answer is L7 strategy.'
You know 1B has technical AND strategic dimensions. You don't need to solve it — you need to identify what shifts.
Treating 1B as just 'more of the same' architecturally. It isn't.
The L5 design walkthrough in full
Now that you know how to speak across scales, here's the full L5 answer — walkthrough, alternatives considered, key decisions, common mistakes, and what separates a good L5 answer from a great one.
At L5, the interviewer wants end-to-end design with explicit trade-offs and evidence of scale intuition. They expect you to introduce a cache and read replicas — but they also expect you to say WHY, name the specific choice, and defend it against alternatives. Trade-offs must be stated explicitly ('we picked X because Y, at the cost of Z'). If you skip the 'at the cost of' part, you lose points.
Scale context
The interviewer typically anchors around 100,000 RPS (10× L4). This is where a single MySQL primary starts hurting on reads — even with proper indexes, a well-tuned instance handles ~10K read QPS gracefully. So the workload shifts: introduce a cache tier and read replicas. Storage is still ~44 GB/year — one shard, easy.
Clarifying questions to ask
Ask these before drawing anything.
- 1L4 questions plus: What's the acceptable staleness window? (Drives cache TTL and replication topology.)
- 2What's the hot-key distribution? (A viral URL means we need per-key protection.)
- 3How real-time do the analytics need to be? (Real-time = writes to a fast counter; batch = daily aggregation.)
- 4Do we have global users? (Not yet at L5 — that's L6/L7 territory.)
- 5What's the acceptable failover window? (30 seconds? 5 minutes? Changes replication strategy.)
Design walkthrough
The story of the design, one section at a time.
1. Start from the L4 design and evolve, don't redesign
Draw the L4 architecture, then add layers. Interviewers appreciate the continuity — 'we're not throwing anything away, we're extending'. Add: a Redis tier in front of the DB for reads (cache-aside), read replicas for the DB (2-3 async replicas), and split the API into 'redirect service' (hot path) vs 'management API' (create URL, view stats) if desired for clarity.
2. Cache tier: cache-aside with Redis
Introduce Redis in front of the read path. Cache-aside pattern: on a redirect request, first check Redis. Hit → return the long_url. Miss → read from a MySQL replica, populate Redis with 24-hour TTL, return. TTL is a trade-off — shorter means fresher (accommodates URL updates) but lower hit rate. Since URLs are effectively immutable after creation, a longer TTL is safe.
3. Cache invalidation strategy (the hard part)
URLs are almost immutable — the only mutation is expiration. So we don't need active invalidation. TTL handles staleness. If we DID need to support 'update this URL's destination', we'd invalidate the cache entry on write (double-write). We name this as the trade-off explicitly.
4. Read replicas and write topology
One MySQL primary + 2 async read replicas. Writes go to primary. Reads go to replicas — via a proxy layer (ProxySQL / RDS Proxy) or client-side routing. Replicas lag by 10-100ms usually. Trade-off: read-your-writes anomaly — user creates URL, immediately opens it, might hit a replica that hasn't replicated yet. Mitigation: read from primary within N seconds of a write for the same key, or serve create response from cache directly.
5. Hot-key protection
One viral URL might get 10K RPS on its own. Redis single-node handles that, but there's still risk: if that key expires simultaneously across all app servers, we get a cache stampede on the DB. Solutions: (a) request coalescing at the app layer (all concurrent misses share one DB fetch), (b) jittered TTL (add ±20% random to prevent sync expiration), (c) refresh-ahead (proactively refresh keys near expiration).
6. Rate limiting and abuse (elevated concern at L5)
At 100K RPS scale, we're a target. Rate-limit creation (per-IP + per-API-key), enforce URL scanning (Safe Browsing), and add per-key redirect limits to prevent redirect-bombing. Rate limits stored in Redis (INCR + EXPIRE). This is now a real service, not a hobby project.
7. Monitoring and SLOs
SLO: 99.9% of redirects complete in <100ms. Alert on: p99 latency > 200ms sustained, Redis hit rate < 90%, replication lag > 5s, error rate > 0.1%. Golden signals dashboard. Runbook for each alert.
Alternatives we considered (and why we didn't pick them)
For each alternative: why we rejected it, and when it would be the right choice.
Memcached instead of Redis
Both work at this scale. Memcached is simpler and slightly cheaper. Redis wins for: persistence option (RDB/AOF as insurance), pub/sub if we need it later, and richer data types (sorted sets for click counters). At 100K RPS we may want rate limiting via Redis INCR — Memcached's atomic counters are less rich.
Pure LRU cache with no need for atomic increments or persistence — Memcached shines for absolute simplicity.
In-process LRU cache (each app server has its own)
Zero-hop for cache reads (faster than Redis). But: each server has an independent cache, so hit rate is 3× lower with 3 servers (thundering herd on cold start of any server). Also, cache warmup on new server requires primary hits.
Complements Redis for the very hottest 1000 keys — a small in-process LRU on each server saves the Redis hop for hot keys. Common belt-and-suspenders pattern at L6.
Write-through cache (write to cache first, sync to DB)
For a URL shortener, writes are rare compared to reads. Write-through adds cache write on every create — extra latency for negligible benefit. Cache-aside is the industry standard here.
Write-heavy workloads where cache and DB must stay in sync (leaderboards, session updates).
Sharded database instead of read replicas
At 100K RPS with 100:1 read ratio, we're doing 1K writes/sec. MySQL primary handles this easily — no need for write sharding yet. Sharding brings big operational complexity (routing, cross-shard queries, rebalancing). Only justified at 10-50K writes/sec sustained.
1M+ RPS territory (L6+), or when a single primary can't hold your dataset.
CDN in front of the shortener
A CDN could cache 302 redirects at the edge, saving even Redis hops. But: (1) redirect analytics are broken (CDN doesn't hit origin), (2) real-time URL updates propagate slowly through CDN. If analytics accuracy matters, don't CDN redirects. If you're at the extreme scale where analytics are secondary or delivered via a separate pixel/beacon, CDN wins.
Analytics is best-effort; latency to end-user is critical; scale is global. This is L7 territory.
Key decisions and their reasoning
Cache strategy
Cache-aside with Redis + jittered TTL + request coalescing
Cache-aside gives us full control, works transparently for a service, and lets us reason about the failure mode (miss → DB read). Read-through would work but requires cache-library integration. Write-through/back is overkill for a read-heavy immutable dataset.
Cache TTL
24 hours with ±20% jitter
URLs are immutable-in-practice, so freshness doesn't drive TTL. Longer TTL = better hit rate + less DB load. 24 hours with jitter avoids stampede on synchronized expiration. Infinite TTL with LRU eviction is another option; TTL is simpler to reason about.
Read-replica topology
1 primary + 2 async replicas, reads via connection proxy
Async replicas give us read scale and HA for a cost of ~10-100ms lag. Sync replicas hurt write latency significantly. Multi-primary invites conflict resolution — not worth it until 1M+ RPS. No replicas means we can't scale reads.
Cache-miss protection against stampede
Request coalescing + jittered TTL + refresh-ahead for top 100 keys
Coalescing means N concurrent misses on the same key share one DB fetch — cheap, effective. Jitter prevents synchronized expiration. Refresh-ahead handles the true hot keys proactively. This trio handles 99% of stampede cases.
Rate limiter storage
Redis INCR + EXPIRE per (IP, API key, endpoint)
Redis INCR is atomic, fast (~1ms), and works globally across app servers. Per-key expiry via EXPIRE. In-process is fast but 3 servers × 100 QPS = 300 QPS actual instead of intended 100 — accuracy matters for anti-abuse.
Common mistakes at this level
- Sharding at 100K RPS — that's still L6 territory; you're overengineering
- Skipping the read-your-write consistency discussion — interviewer will probe this
- Not explaining WHY cache-aside vs write-through — just saying 'use Redis'
- Forgetting jittered TTL — showing you don't understand stampede mechanics
- Vague SLO ('should be fast') instead of a concrete number ('p99 < 100ms')
- Not discussing rate limiting — at 100K RPS, you're a target for abuse
- Ignoring the failure story: what if Redis dies? What if a replica falls behind? These will be asked.
What separates good from great at this level
A good L5 answer introduces cache-aside and read replicas correctly. A great L5 answer does that AND: proactively addresses cache stampede with coalescing + jitter; discusses the read-your-write anomaly with a concrete mitigation; names specific instance sizes and rough monthly cost; states 3 SLOs with alert thresholds; discusses abuse prevention (rate limits + Safe Browsing); and — most importantly — explicitly says 'the next bottleneck at 1M RPS will be primary write throughput and single-Redis capacity, and here's what changes'. Showing you know the boundaries of your design is the L5→L6 signal.
Why this design fits L5 — and what will break it
Why this design works AT L5
At 100K RPS with 100:1 R:W = 99K read QPS + 1K write QPS. Redis cache holds hot 20% of URLs (~200K keys in ~50MB memory — trivial). Cache hit rate: 92% (empirically Zipf-distributed URL access). Redis absorbs 91K QPS at ~0.5ms latency. MySQL primary sees 1K writes + ~8K miss reads = 9K QPS — well within its 15K QPS envelope. Add 2 MySQL read replicas to route half the miss traffic there = each replica handles ~4K QPS at <40% CPU.
~$1,600/month. Breakdown: 3 app servers → 6 c6i.xlarge ($900), Redis cluster ElastiCache 3-node cache.r6g.large ($400), MySQL primary + 2 replicas m6i.2xlarge ($300 total). At 1B redirects/day, we're at $0.05 per million redirects — commodity pricing for a Series A company.
Team is now 5-8 engineers. Added components: Redis (1 new failure mode + monitoring), read replicas (1 new failure mode: replica lag). Total operational surface: manageable. One SRE hire around 100K RPS is standard.
- Redis primary dies → sentinel promotes replica in ~15s, cache warms in 5 min
- Cache stampede on hot key expiry → SETNX-based single-flight prevents DB pileup
- MySQL replica lag → route reads to primary as fallback, alert if >5s
- Cache miss on very cold URL → still fast (MySQL read on indexed lookup)
At 100K RPS I added Redis with cache-aside — 92% hit rate cuts DB load 10x. Read replicas handle the remaining reads. I did NOT shard because a single MySQL primary at 1K writes/sec is still 10x under capacity. Sharding is a solution to a problem I don't have.
Why this design breaks at the NEXT scale tier
At 1M RPS you hit THREE walls simultaneously: (1) Redis cache tier maxes out — single-shard Redis peaks at ~200K QPS/node. Need 5-node cluster or multi-cache tier. (2) MySQL primary write throughput ceiling — even at 10K writes/sec sustained, redo log flush becomes the bottleneck. (3) Cross-region latency — users in Tokyo hitting us-east-1 wait 200ms just for RTT.
Redis CPU >70% OR MySQL write latency p99 >20ms OR user-facing latency p99 >150ms outside of your primary region.
Redirects fast for US users, slow for international. Cache misses spike during viral events (celebrity URL hits). Occasional Redis timeouts during rebalance.
The single-region assumption fundamentally breaks. You can't cache your way past the speed of light — a Tokyo user hitting us-east-1 waits 180ms for RTT even before your service processes anything. This forces edge caching (CDN) + multi-region API + globally-coordinated ID generation. Also: 10K writes/sec against a single MySQL primary starts hitting redo log flush limits (~15-20K writes/sec is the ceiling on gp3 SSD). You need sharding OR distributed DB.
Interviewer will ask: 'Why can't you just add more Redis nodes?' Your answer: 'Adding Redis nodes helps with cache CPU but not with cross-region latency. Speed-of-light means a Tokyo user hitting us-east-1 is >180ms RTT no matter how fast my backend is. So I need geographic distribution: CDN at the edge (Cloudflare/CloudFront) with 5-min TTL on redirects, AND regional API endpoints. This is L6.'
The trigger to evolve to the next tier
Cross-region p99 latency + MySQL write throughput + Redis single-node CPU
International p99 >200ms OR MySQL writes >8K/sec OR Redis CPU >70%
L6 rollout takes 6 months (multi-region infra + team + runbooks). Start planning at 500K RPS.
L5 = L4 + Redis cache + MySQL read replicas. 90%+ cache hit rate reduces DB load 10x. Stampede protection is mandatory. You didn't shard. You didn't add Kafka. You added exactly the thing that stops the fire.
- What's the L5 architecture for a URL shortener?
- Why did we add Redis and not shard the DB?
- How do you prevent a cache stampede?
- How do you set TTL correctly?
- When would you need read replicas?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
Fire-and-forget analytics + cache-refresh in the background are sync-vs-async decisions. Read this to know exactly when to pick which.
Cache TTL forces a specific consistency model on you — eventual with bounded staleness. Know the vocabulary before you defend it in an interview.
The ring algorithm behind Redis Cluster — coming next chapter (6.5) but preview it here since sharded cache is the L5→L6 evolution.
Chapter 6.5: the cache decision. I picked Redis. In an interview, the very next question is 'why not Memcached? Why not application LRU? Why not DAX?' Same drill as the database chapter — you defend against alternatives with specific numbers, or you lose credibility. Read this before Chapter 7.
Why Redis — and when Memcached, DAX, or application LRU wins instead
The cache decision — with the tiered architecture the big shops actually use
Same drill as the database chapter. The interviewer will ask:
"You said Redis. Why Redis? What's wrong with Memcached? Have you looked at DAX? Why isn't a local LRU in each app process enough?"
If you answer "Redis has more data types," you've lost. The right answer walks through the workload — hot key distribution, latency tolerance, persistence needs, failure semantics — and picks the cache that fits.
I'll show you the four candidates head-to-head, plus the tiered hybrid Bitly actually uses (which no tutorial explains).
Step 1: what does the URL shortener actually ask the cache to do?
Requirements — spelled out:
- Point lookup by `short_code` — 99% of cache reads
- Latency <5ms p99 to justify the added complexity vs going straight to MySQL
- Cache-aside pattern: cache is populated on miss, invalidated on write
- Failure survival: if the cache is down, the DB must not fall over — cache must be optional
- Hot-key concentration: Zipfian distribution means 90% of reads target 10% of URLs — the cache is designed to catch that head
- Memory footprint: 10M hot URLs × 300 bytes = 3GB — fits in one Redis node comfortably
- Cross-region visibility: at L6+, we need cache coherence across regions (or accept staleness)
Redis (the default recommendation)
Internals you should know:
- Single-threaded per shard: one core does all commands. Sounds bad, is actually great — no locking, no context switching, predictable latency.
- Cluster mode: 16,384 hash slots distributed across shards. Clients maintain a slot-to-node map (cached, TTL'd).
- Persistence: AOF (append-only file, durable) or RDB (snapshot, faster). For a cache, disable both — restart re-warms from DB.
- Data types: strings, hashes, lists, sets, sorted sets, streams, HyperLogLog, geospatial. You'll use "strings" for URL shortener. The rest is overkill you're paying operational cost for.
- Pub/sub: for cache invalidation across app instances.
- Scripting (Lua): atomic multi-key operations. Great for stampede protection.
Throughput math:
- Single Redis shard: ~100K ops/sec (single-threaded, ~10µs per op)
- 3-node cluster (RF=2): ~300K ops/sec aggregate reads
- Latency: ~500µs p99 in-region, sub-microsecond in-process (via Unix sockets or shared memory tricks)
Cost:
- L5 (t3.small × 3, RF=2): $150/mo
- L6 (m6i.large × 6 across regions): $700/mo
- L7 (r6i.4xlarge × 24 sharded cluster global): $18K/mo
Wins:
- Rich data types when you need them (queue for rate limiting, sorted set for leaderboards, geohashes for location)
- Cluster mode is production-mature
- Massive community, extensive tooling
- Lua scripting for atomic operations
- Pub/sub for cross-instance events
Loses:
- Single-threaded per shard limits per-shard throughput vs Memcached's multi-thread
- Cluster mode adds operational complexity
- Uses more memory per key than Memcached (metadata overhead)
Verdict for URL Shortener: Redis wins by default because URL Shortener will grow into needing rate limiting (uses Redis sorted sets), analytics counters (Redis HyperLogLog), and cross-instance invalidation (Redis pub/sub). Picking Redis at L5 avoids adding a second cache tier later.
Memcached (the "we only need strings" answer)
Internals:
- Multi-threaded per instance: 4 threads on a 4-core box can serve ~4x Redis's per-instance throughput on pure string GETs
- No persistence: cache dies with the process
- String KV only: no data types, no scripting
- Client-side hashing: consistent hashing via ketama or libmemcached — no server-side cluster mode
Throughput math:
- Single node: ~200K ops/sec (multi-threaded, ~5µs per op)
- 3-node cluster: ~600K ops/sec aggregate
- Latency: ~300µs p99 (slightly better than Redis due to multi-thread)
Cost:
- Comparable to Redis, roughly $130/mo at L5
Wins:
- Higher raw throughput per node on strings
- Simpler mental model
- Lower memory overhead per key (~50 bytes vs Redis's ~90 bytes)
Loses:
- No persistence — cold restart hammers the DB until warm again (this is a real production concern; can be mitigated with warm-up scripts)
- No cluster mode (client picks the shard, so client-side complexity)
- No data types beyond strings — if you later need rate limiting or analytics counters, you add a second cache tier
"If you picked Memcached, how does the design evolve?"
- L4: identical — LB + 3 app servers + MySQL. Add Memcached at L5 instead of Redis.
- L5: cache-aside works identically. Client library handles consistent hashing across nodes. Stampede protection via app-level distributed lock (in a separate ZooKeeper or DB row). More work than Redis's SETNX.
- L6: multi-region gets harder. No native cluster mode means you shard by application-level logic. You'll build a Redis anyway for pub/sub and rate limiting — now you're operating TWO cache systems.
- L7: rare — Memcached at planet-scale is unusual (Facebook did it, but that's ~10 years of specialized ops work).
When Memcached beats Redis:
- Truly hot single-key workload where per-shard throughput matters
- Team already runs Memcached
- You don't want data types (you're sure you'll never use them)
When Redis wins:
- You'll grow into needing rate limiting, pub/sub, or atomic multi-key operations
- You want persistence (even AOF fsync-per-sec is durable enough for cache warmup)
- Cluster mode's operational maturity matters
DAX — DynamoDB Accelerator (the "AWS everything" pick)
If you picked DynamoDB in Chapter 5.5, DAX is what you'd add here.
- Managed in-memory cache transparently in front of DynamoDB
- Fully API-compatible with DynamoDB SDK — zero code changes to add
- Sub-millisecond in-region latency
- Cluster mode with automatic failover
Cost:
- t3.small × 3 cluster: $650/mo
- t3.medium × 3: $1,300/mo
- Reduces DynamoDB read cost by 10-100x depending on hit rate
Wins:
- Zero code changes — literally point the SDK at the DAX endpoint
- Managed — no ops burden
- Perfect fit if you're already on DynamoDB
Loses:
- Only works with DynamoDB (no MySQL, no anything else)
- Closed-source, AWS-only
- Cache invalidation is TTL-based (you can't precisely invalidate on write)
- More expensive than self-managed Redis/Memcached at the same footprint
When DAX wins: you picked DynamoDB in Chapter 5.5 and want the zero-ops cache to match.
When it loses: anything else. Also, cost — Redis on EC2 is cheaper.
Application-level LRU (Caffeine / Guava — the "no network hop" pick)
In-process cache on each app server.
- Sub-microsecond access (no network hop, just a hashmap lookup)
- Per-instance, not shared: each app server has its own copy
- LRU eviction: LinkedHashMap-based or off-heap
- Small footprint: sized in-JVM heap, typically 256MB-1GB per instance
Trade-offs:
- Cache stampede across instances: if 3 app servers all miss the same URL simultaneously, all 3 hit the DB
- No cross-instance invalidation: on write, all N app servers must be notified. Usually via Redis pub/sub (so now you have Redis anyway) or event bus.
- Cold restarts: new instance starts empty. Warmup via preload or accept degraded first minute.
Throughput math:
- ~10M ops/sec per instance (in-process hashmap)
- No network cost
Cost: zero incremental infrastructure (uses existing app server memory)
Wins for URL shortener:
- The 100 hottest URLs (Zipfian head) get hit millions of times — a per-instance LRU catches them at zero latency and zero infrastructure
- Reduces Redis load 5-10x for the ultra-hot tail
Loses:
- Stampede across instances (mitigated with jittered warmup)
- Invalidation complexity
- Per-instance memory sizing
The Bitly-style tiered cache (this is what real production looks like)
No single cache tier does it all. Real shops layer them.
Here's the actual layering pattern Bitly / Reddit / Cloudflare use for URL shortener-style workloads:
```
[Client Redirect Request]
│
┌───────▼────────┐
│ CDN (L0) │ ← 30s cache, ~40% offload at edge
│ TTL 30s │ (only for top 1000 URLs — very hot)
└───────┬────────┘
│ cache miss
┌───────▼────────┐
│ Load Balancer │
└───────┬────────┘
│
┌───────▼────────────────┐
│ App Server │
│ ┌────────────────────┐ │ ← Application LRU (L1)
│ │ Caffeine LRU │ │ Sub-µs access, catches top 100 URLs
│ │ 100 hot entries │ │ ~50% of miss traffic from L0
│ │ 5min TTL │ │
│ └────────┬───────────┘ │
│ │ miss │
└──────────┼──────────────┘
│
┌──────────▼─────────┐
│ Redis (L2) │ ← Distributed cache, catches top 10M URLs
│ ~500µs latency │ ~95% of miss traffic from L1
│ 24h TTL + jitter │
└──────────┬─────────┘
│ miss
┌──────────▼─────────┐
│ MySQL (L3) │ ← Cold DB, catches the long tail
│ ~1ms warm hit │ ~1% of total traffic reaches here
│ Read replicas │
└────────────────────┘
```
End-to-end hit-rate math:
- L0 (CDN): 40% of all reads served here → 60% miss to L1
- L1 (App LRU): 50% of L0 miss → 30% of total served here → 30% miss to L2
- L2 (Redis): 95% of L1 miss → 28.5% of total served here → 1.5% miss to L3
- L3 (MySQL): serves the remaining 1.5% of total traffic
So MySQL only sees 1.5% of read traffic even at 1M RPS = 15K QPS to the DB. Which is exactly the number an m6i.large handles at 50% CPU. The tiered cache is what makes the design fit in a single-region DB even at planet scale.
"If you picked X, how does the design evolve?"
Same drill:
- Memcached everywhere: works at L5. At L6, you're building a Redis anyway for pub/sub — end up with both.
- Redis only, no LRU: fine at L5. At L6, the ultra-hot 100 URLs create Redis hot-shard pressure — you add per-app LRU to shed load.
- DAX + DynamoDB: elegant zero-ops story. Higher $/RPS at scale. Best if AWS-committed.
- No cache at all: works only at L4 (10K RPS). Above that, MySQL saturates fast.
Interview soundbite
When the interviewer asks "why Redis?" your answer is:
"Redis by default because the URL shortener will grow into needing rate limiting (Redis sorted sets), pub/sub invalidation, and Lua scripting for stampede protection — all of which Memcached lacks. A single-tier Redis catches the Zipfian head at 92% hit rate. But if the interviewer wants max throughput per node on pure string GETs, I'd argue Memcached with client-side hashing — accepting we'll add Redis later for the growing feature set. At L6+ the honest answer isn't 'Redis' or 'Memcached' — it's a tiered cache: CDN for top 1000 URLs, per-app LRU for top 100, Redis for top 10M, MySQL for the tail. Each layer catches 90%+ of what missed the layer above. That's how the DB sees only 1.5% of reads even at 1M RPS."
The tier evolution across scales
| Scale | Cache tiering |
|---|---|
| 10K RPS (L4) | No cache. DB handles it. Adding cache introduces invalidation complexity for zero perf benefit. |
| 100K RPS (L5) | Single Redis tier. Cache-aside. 92% hit rate. DB load drops 10x. |
| 1M RPS (L6) | Two tiers: App LRU + Redis. LRU catches top-100 hot keys (Zipfian head). Redis catches the warm 10M. Regional replication for cross-region visibility. |
| 1B RPS (L7) | Four tiers: CDN + App LRU + Redis + DB. CDN offloads 40% at edge. MySQL sees only 1.5% of total traffic. |
References (13 items)
- Redis documentation — redis.io/docs. Authoritative reference for cluster mode (16,384 hash slots), single-threaded model, data types, and persistence options (AOF vs RDB).
- Redis benchmarks — redis.io/topics/benchmarks. Source for the ~100K ops/sec-per-shard figure.
- Memcached documentation — memcached.org/about. Source for multi-threaded design and ~200K ops/sec-per-instance ceiling.
- Nishtala et al. (2013) — "Scaling Memcache at Facebook," NSDI '13. usenix.org/conference/nsdi13/technical-sessions/presentation/nishtala. The canonical paper on running Memcached at planet-scale; explains lease-based stampede protection and mcrouter's role.
- Amazon DynamoDB Accelerator (DAX) docs — docs.aws.amazon.com/amazondynamodb/latest/developerguide/DAX.html. Reference for DAX pricing, cluster mode, TTL semantics.
- Caffeine cache (Java) — github.com/ben-manes/caffeine and Manes's papers on W-TinyLFU. Reference for the application-LRU tier.
- Guava LoadingCache docs — guava.dev/releases/snapshot/api/docs/com/google/common/cache. Google's LRU implementation predating Caffeine.
- Cloudflare Engineering Blog — Cache Reserve and multi-tier caching: blog.cloudflare.com — search "cache reserve" and "tiered cache". Reference for the CDN-tier hit rates cited (40% edge offload).
- Bit.ly Engineering Blog — word.bitly.com. Multi-tier cache architecture from a production URL shortener perspective.
- Zipf, George Kingsley (1949) — Human Behavior and the Principle of Least Effort. The origin of the Zipf α ≈ 1 assumption used to derive the 92% hit rate.
- Adamic, Lada A. & Huberman, Bernardo A. (2002) — "Zipf's law and the Internet," Glottometrics 3. arxiv.org/abs/cond-mat/0210146. Empirical validation of Zipf α ≈ 1 across web-scale link distributions.
- RFC 9111 — HTTP Caching (2022): rfc-editor.org/rfc/rfc9111. Cache-Control semantics used in the CDN tier — max-age, no-cache, s-maxage, stale-while-revalidate.
- RFC 5861 — HTTP Cache-Control Extensions for Stale Content (2010): rfc-editor.org/rfc/rfc5861.
stale-while-revalidateused at the CDN tier.
Short-code generation
- Simple
- No coordinator needed
- Collisions grow with volume; requires retry-on-conflict
- No collisions
- Compact
- Needs a distributed counter (ZooKeeper, Redis INCR, DB sequence)
- Distributed, no coordinator
- Time-ordered (useful for TTL cleanup)
- Codes are longer (~12 chars) and can reveal time
Redirect status code
- Browsers cache; less origin load
- Analytics undercount (subsequent visits skip origin)
- Every click hits origin — accurate analytics
- More origin load
Redis by default (rich types, future-proof), Memcached for pure-string max throughput, DAX if committed to DynamoDB, application LRU as an L6+ addition for Zipfian head. Real production uses a 3-4 tier cache — CDN + LRU + Redis + DB — so the DB sees only 1-2% of read traffic even at planet scale.
- Why does Redis beat Memcached for a growing product?
- What's the end-to-end hit-rate math for a 4-tier cache?
- When does application-level LRU pay for itself?
- How does the cache tiering evolve 10K → 1B RPS?
- Why does DB only see 1.5% of read traffic in a real L7 design?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The algorithm behind Redis Cluster's slot-based sharding — the ring visualization + why only ~1/N keys move when you add/remove a node. Core to the L5→L6 evolution.
Redis Sentinel and Redis Cluster both need quorum for failover — the R+W>N formula demystified. Why 3 nodes tolerate 1 failure, not 2.
How Sentinel elects a new primary when the current one dies — the algorithm behind Redis HA. Why 30-second detection windows are hard to reduce.
Chapter 7: L6 architecture. Global users, 1M RPS, multi-region. This is where the tiered cache above becomes mandatory, not optional. And we introduce CDN, sharding, Snowflake IDs, and the first hard consistency trade-off.
Global scale (L6)
1M RPS across every continent
Welcome to the room where senior architects earn their salary.
You're at 1 million RPS. Your users are on every continent. A user in Tokyo hits your us-east-1 endpoint and waits 200ms just for the network — before any computation. That's unacceptable.
The L6 chapter is about multi-region architecture — and I need to warn you: this is the first place where you make hard trade-offs. There is no free lunch here. Every consistency guarantee you keep costs latency. Every latency win costs consistency. You have to pick.
Here's the framing that Staff+ engineers use:
The 3 hard problems at global scale:
- Latency at the edge. You can't fix speed-of-light. You have to serve users from geographically-close data. That means CDN at the edge (Cloudflare, Fastly, AWS CloudFront) with a 5-10 minute TTL on redirects. First hit is slow; every hit within 10 min is <20ms.
- Write coordination across regions. If a marketing team in London creates a short URL, when can a user in Tokyo see it? Options: (a) sync writes globally (slow, 200ms+ per POST), (b) async replicate (fast writes, but new URL isn't immediately visible everywhere), (c) region-local writes with global lookup (best of both, but complex). Each has a different consistency story.
- Global ID generation. You can't have 5 regions all issuing the same short_code. You need a coordinated ID space. Options: (a) pre-allocate ranges per region (simple, wastes IDs), (b) Snowflake IDs with region prefix (elegant, no coordination), (c) global counter service (bottleneck), (d) UUID-derived (long codes).
The L6 design below picks specific answers to each. But — and this is the part that separates senior from staff — you should be able to argue the OTHER answers too. In an interview, when they push you ("why not do it the other way?"), you need to know exactly what breaks.
One more thing: at L6 you also add rate limiting, DDoS protection, WAF, and multi-region failover runbooks. These aren't nice-to-haves; they're the tabletop scenarios your CTO will be asked about in the SOC-2 audit.
Read the full L6 design. Notice the multi-region diagram. Watch how the data flows. This is what a real global product looks like architecturally.
## Where does "CDN absorbs 95%" come from? (the derivation)
Same Zipf math as Chapter 6, applied to CDN instead of Redis:
Setup. At 1M RPS, we have 100M short_codes in service (10 years of accumulated URLs). CDN cache TTL is 5 minutes (300 seconds).
Working set per 5-min window. Even at 1M RPS × 300 sec = 300M redirects per window, Zipfian distribution means the top 1% of URLs receive ~50% of traffic. That's 1M unique short_codes in the CDN cache = 300 MB of storage (1M × 300 bytes) — trivial for any CDN's edge cache.
Hit rate derivation. With a 5-min TTL and Zipf α ≈ 1.0:
- Top 0.1% of URLs (100K codes) get ~30% of traffic
- Top 1% (1M codes) get ~50%
- Top 10% (10M codes) get ~90%
The CDN edge cache holds the top ~1M codes (its physical memory is petabytes but per-URL LRU eviction naturally keeps the hot set). That gives us 90-95% hit rate at the CDN tier.
Why 95% specifically? After the CDN's own hit-rate optimization (positive caching + negative caching for 404s + prefetching from Origin Shield tier), Cloudflare and AWS CloudFront both publish real-world hit rates around 95% for URL-shortener-like workloads. Reference: Cloudflare's Cache Hit Rate dashboard documentation (developers.cloudflare.com/cache/how-to/hit-rate) and AWS CloudFront's Cache Statistics API.
Impact on origin load. 1M RPS × (1 - 0.95) = 50K RPS hits origin. Distributed across 3 regions = 17K RPS per region — exactly what a single-tier Redis + sharded MySQL handles at ~40% CPU (from Chapter 5's derivation × ~2×).
Which CDN? Head-to-head at L6
Same drill as the DB and cache chapters. Interviewer asks: "You said Cloudflare. Why not CloudFront? Fastly? Akamai?" Here's the answer.
| CDN | Pricing per 100M requests | Latency (p95) | Edge locations | Best for |
|---|---|---|---|---|
| Cloudflare Free/Pro | $0 (free tier) → $200 (Pro) | ~30ms | 320+ cities | The default. Free tier alone handles most URL shorteners. |
| AWS CloudFront | ~$100 (first 10 TB) | ~40ms | 400+ POPs | AWS-native shops, integration with S3/ALB/Shield |
| Fastly | ~$150 (10 TB) + $0.50/M req | ~25ms | ~90 POPs | Extreme customization (VCL), edge compute, best p99 latency |
| Akamai | Enterprise pricing (~$5K/mo min) | ~30ms | 4,000+ POPs | Enterprise + compliance + human support |
| Bunny CDN | ~$5 per TB | ~40ms | 100+ POPs | Cost-sensitive, small teams, hobby projects |
| Google Cloud CDN | ~$80 (first 10 TB) | ~35ms | 100+ POPs | GCP shops, integration with GCLB |
Recommendation for URL Shortener at L6: Cloudflare (free tier at small scale, Pro/Business at real scale). Reasoning:
- Best free tier of any provider (many shorteners never leave it)
- Built-in WAF, rate limiting, and DDoS protection (see Chapter 7.6)
- Workers edge-compute lets you route redirects with logic (A/B testing, geo-routing) at the edge, saving origin RTs
- Bandwidth is unmetered on paid plans — CloudFront and CloudFront charge per GB, painful at high traffic
When AWS CloudFront wins: you're already all-AWS, want tight integration with S3/ALB, and want AWS Shield Advanced for enterprise DDoS.
When Fastly wins: you need custom edge logic (VCL programming) and are ready to pay for the p99 latency wins.
When Akamai wins: you're a bank or a government, and you need a human on the phone during an incident.
References: Cloudflare pricing (cloudflare.com/plans), AWS CloudFront pricing (aws.amazon.com/cloudfront/pricing), Fastly pricing (fastly.com/pricing), Akamai (akamai.com — enterprise sales), Bunny (bunny.net/pricing), Google Cloud CDN (cloud.google.com/cdn/pricing). Prices as of 2025 — always verify at time of design.
Global topology — what it actually looks like
The L6 architecture is 3 regional stacks connected by async replication + a global CDN edge. Explore the interactive world map below — click any region to see its stack, watch live traffic particles routing from users to their nearest region, and trigger a viral write to see cross-region replication ripple out:
Watch users hit their nearest region. Click a region to explore its stack.
Users route via GeoDNS + Anycast to the closest region. Writes go to us-east primary; async replication fans out to eu-west and ap-northeast.
- Ingress path: Client → GeoDNS → nearest CDN PoP → miss → regional ALB
- Write path: POST → us-east primary → async WAL fanout to eu-west + ap-northeast
- Consistency: Read-your-writes locally · eventual globally (<1s)
- Cost: ~$18K/mo total ($3K CDN + 3× $5K regional stacks)
Read this diagram carefully. Notice:
- Users hit their nearest CDN PoP; 95% never talk to your origin
- Each region is architecturally identical — same code, same tuning, different data
- MySQL shards replicate CROSS-REGION — that's what makes reads work locally
- Writes stay in-region (us-east-1 is the write primary) — cross-region write coordination would cost 200ms per write
The write flow — the same in Mermaid (interactive)
The above topology shows the box structure. For the actual write-request flow through a single region, here's the same information as a Mermaid sequence — the browser renders this as clean SVG:
Watch the write commit locally, then async-replicate across the Atlantic.
User sees 201 in ~15ms locally. Meanwhile WAL streams to us-east-1 replica with ~500ms lag. Eventual consistency in action.
- · User sees 201 in ~15ms Berlin end-to-end · sync path is fully local
- · Cache invalidation before response · never populate on write (race-free · see Ch 6)
- · Cross-region WAL is async · US replica catches up in ~500ms · eventual consistency
- · Read-your-writes locally · Berlin user reads their own URL immediately from eu-west-1
- · Global consistency ~1 second · Tokyo reader sees the new URL after replication
Both diagrams describe the same architecture from different angles: the ASCII shows spatial topology, the Mermaid shows temporal flow. Learn both — interviewers will draw one or the other on the whiteboard.
Why 4 shards? (the sharding math — 3-floor derivation)
Sharding count is derived, not guessed. Same 3-floor pattern as the app tier (Chapter 5) and Redis (Chapter 6) — take the max of throughput, HA, and storage floors, then round to a power of 2 for rebalance economics.
Floor 1 — Write throughput math (usually the binding floor).
At 1M RPS with 100:1 read:write = 10K writes/sec globally. Distributed across 3 regions (see multi-region topology above) = ~3,300 writes/sec per region. Peak factor 2× (traffic isn't uniform across a day) → ~7K writes/sec per region at peak.
Per-shard MySQL write ceiling from Chapter 4.75, Section 5: ~5-10K writes/sec sustained on m6i.large Multi-AZ (bottleneck is redo log fsync + B-tree insert + cross-AZ semi-sync replication). At the conservative 5K/shard end:
- 7K writes/sec ÷ 5K per shard = 1.4 shards minimum
- Add 50% safety margin (viral event, spike) → 2.1 shards → round to 3 shards per region for pure throughput math
Floor 2 — Read throughput on the create + immediate-share path.
Reads mostly hit the cache tier (92% offload from Chapter 6), so write shards see ~8% of read traffic. At 1M RPS × 1/3 regions × 8% miss × 92% Zipf = ~25K reads/sec landing on write shards per region. Per-shard read ceiling ~20-25K QPS (Chapter 4.75) → ~1 shard is enough for reads. Not the binding constraint.
Floor 3 — Storage + operational floor.
Working set per region: 100M new URLs × 3 years × 220 bytes = ~66 GB per region. Split across 3-4 shards = ~20 GB/shard — comfortably fits gp3 EBS at cheap tier without needing io2 provisioned IOPS.
Operational constraint: each shard adds one more (backup schedule, replication monitor, patch window, connection pool). Rule of thumb: never have more shards than the SRE team can hold in their head. At L6 that's typically ≤8 per team.
Final: max(3, 1, storage-ok) = 3 shards raw minimum. Round to next power of 2 = 4 shards per region.
3 regions × 4 shards = 12 shards total across the fleet. Each shard is a MySQL m6i.large primary + 1 replica + Multi-AZ standby.
Why round to power of 2?
Consistent hashing with power-of-2 shard counts makes resharding cheap. Doubling from 4 → 8 shards means each new shard takes half of one existing shard's range — 50% of data moves. Going from 4 → 5 shards would require a full re-hash (~87% of data moves). At scale, that's an operations nightmare.
Foundational reference: Karger et al. (1997) "Consistent Hashing and Random Trees" — the paper that introduced consistent hashing. Also see the Amazon DynamoDB paper (2007) §4.2 for how DynamoDB does resharding.
Why not 3 shards? (skipping the power-of-2)
Technically works — the throughput floor said 3 is enough. But if traffic doubles in 12 months (typical viral growth), you'd reshard 3 → 6 (not a power of 2, requires expensive full re-hash) OR go straight to 4 → 8. Starting at 4 delays your first reshard event by 1× your growth cycle. That's usually worth the ~30% overhead cost.
Why not 8 shards? (over-provisioning trap)
At 4 shards you have 20K writes/sec capacity per region vs 7K peak = ~3× headroom. Going to 8 gives you 6×, doubling cost + ops for a headroom you won't use for years.
The scale-out trigger: reshard 4 → 8 when sustained write load crosses 15K/sec per region (75% utilization). At current growth trajectories, that's ~18 months of runway. Rearchitect BEFORE hitting the ceiling, not after.
Rule of thumb: shard count = ceil(peak_write_qps / per-shard capacity) × 1.5, rounded up to next power of 2.
Why 4 per region and not 12 globally?
Global sharding (12 shards without regional partitioning) would work for writes but breaks the regional data-residency model:
- User in EU expects their data in EU (GDPR)
- User in US expects EU data invisible (regulatory)
- Sharding globally would mix regions inside single shards
Regional sharding: 4 shards inside each region, all shards in the same jurisdiction. Cross-region replication is async (see Chapter 7 async replication section).
References for this derivation:
- MySQL 8.0 Reference Manual — Sharding via Vitess/Fabric or app-level — MySQL native docs; note the "no native sharding" pain that Vitess solves
- Vitess docs — Resharding workflows — YouTube-tested resharding without downtime
- Karger et al. (1997) — Consistent Hashing paper
- DeCandia et al. (2007) — Dynamo paper §4.2 for the reshard economics
- Kleppmann DDIA Ch 6 (Partitioning) — the canonical textbook chapter on sharding strategies
- AWS EBS gp3 pricing/performance — for the per-shard storage IOPS budget
The shard-key decision — the visual that separates Senior from Staff
You said "4 shards, hash the short_code." That answers how many. It does not answer the deeper question the interviewer will drill in on: why did you pick `hash(short_code)` as the shard key and not one of the other three plausible choices?
There are exactly 4 shard-key candidates for a URL shortener, and every Staff+ engineer should be able to defend all of them. Instead of a static picture, here's an interactive visualization — pick a strategy, add URLs, watch the distribution unfold in real time. Try the "viral URL" and "whale user" scenarios and watch shards go red:
Pick a strategy. Add URLs. Watch the distribution unfold.
The 4 candidates side-by-side is a table. What no table shows is what happens when you add data. Try the presets — you'll feel why hash(short_code) wins.
Uniform hash produces even distribution. This is our production choice.
The lesson the interactive visualizer proves: only hash(short_code) avoids structural hot shards because it distributes purely on a hash of an already-random key. Every other candidate has a specific structural reason to skew — time-range concentrates writes on the latest quarter, geography concentrates on US (which produces ~30-40% of internet traffic), user-hash concentrates on whichever bucket contains your whale customer.
The decision matrix
| Criterion | hash(short_code) | range(created_at) | geographic | hash(user_id) |
|---|---|---|---|---|
| Distribution evenness | ✅ EVEN | ❌ SKEWED | ❌ SKEWED | ❌ SKEWED |
| Hot-partition risk | 🟢 LOW | 🔴 HIGH | 🟡 MEDIUM | 🔴 HIGH |
| Point lookup /r/{code} speed | ⚡ 1 hash | 2 hops | 2 hops | 2 hops |
| Range scans by time | ALL shards | ✅ 1 shard | ALL shards | ALL shards |
| Range scans by user | ALL shards | ALL shards | ALL shards | ✅ 1 shard |
| Rebalance cost (adding shards) | ✅ 1/N moves | ❌ 50% moves | ⚠️ 25% moves | ✅ 1/N moves |
| Regulatory locality (GDPR) | N/A | N/A | ✅ NATIVE | N/A |
| Fit for URL shortener workload | ★★★★★ | ★★☆☆☆ | ★★★☆☆ | ★★☆☆☆ |
OUR CHOICE: `hash(short_code)` — because our #1 query pattern is point-lookup on short_code (99% of traffic is redirects). Everything else is a tolerable second-class concern.
Reading the matrix (mentor commentary). A URL shortener's workload is dominated by point lookups on short_code — the /r/{code} redirect endpoint is 99% of traffic (from Chapter 2's estimation). If your shard key doesn't make THAT query a 1-hop operation, you have chosen wrong. Every other query pattern is tail volume.
Why range(created_at) is a classic wrong answer. It's tempting because "URLs get created over time" sounds like a natural axis. But time-based sharding is a canonical anti-pattern for any workload where NEW writes are hot — you get a "current shard on fire, old shards idle" imbalance. Use it only when you're time-series-heavy AND writes to old partitions are truly cold (e.g., IoT sensor data, log aggregation). URL shortener is neither.
Why geographic sounds right but usually isn't. For a URL shortener the creator's region does not predict where clicks come from. A Berlin marketer creates a URL that goes viral among US TikTok users — the shard in eu-west now serves 100M clicks from us-east. The SHORT_CODE has no region carrier. Geographic sharding is right for products where data has strong regional affinity (Uber trips, WhatsApp regional chat groups) — it is wrong for a global-consumer redirect service.
Why hash(user_id) is dangerous. The celebrity problem: one enterprise customer or influencer will have 50-100× more URLs and clicks than the median user. Sharding by user concentrates their traffic on one shard, which then becomes the whole system's bottleneck. Twitter/X famously hit this with tweet sharding by user_id in the early 2010s — they now shard by tweet_id (a hash-like key) for exactly this reason. Reference: Manhattan: Twitter's real-time database (blog.twitter.com/engineering/en_us/a/2014/manhattan-our-real-time-multi-tenant-distributed-database-for-twitter-scale).
When would we revisit this? Two futures force the question open again:
1. Enterprise pivot with per-tenant SLAs. If we sell "your company's URLs on your dedicated shards for compliance," we'd add a composite key: hash(tenant_id) at the outer layer, hash(short_code) within the tenant. This is a 2-level shard scheme — see the CockroachDB / Vitess literature on hierarchical sharding for prior art.
2. GDPR data-residency mandate. If regulators require EU URLs on EU-only hardware, we'd move to geographic at the region layer and hash(short_code) within each region. That's a hybrid strategy — the outer axis handles compliance, the inner axis handles distribution.
Interview soundbite for this visual. "I picked hash(short_code) because 99% of my traffic is point-lookup on short_code — I want that to be a 1-hop operation. I considered range(created_at) — rejected because new writes would create a hot shard. I considered geographic — rejected because short_code has no region signal, and viral URLs from one region get clicked from another. I considered hash(user_id) — rejected because of the celebrity-tenant problem. If we pivot to enterprise per-tenant SLAs, I'd add a 2-level shard: hash(tenant_id) outer, hash(short_code) inner. That's the exact reasoning at a Staff level."
Snowflake IDs at L6 — inline recap
Chapter 4.5 covered Snowflake in depth. Here's the L6-specific application: region-prefixed Snowflake so no two regions can accidentally hand out the same code.
Layout (from Chapter 4.5's truncated Snowflake):
- 3 bits region (8 regions max — we use 3)
- 17 bits timestamp (relative to epoch, ~36 hours before wrap)
- 8 bits node ID (256 nodes per region)
- 12 bits sequence (4K creates/ms/node)
- Total: 40 bits = 7 base62 chars
Two regions cannot collide because bits 61-63 are different (region 1 = 001, region 2 = 010, region 3 = 011). This makes the coordination-free multi-region property mathematically guaranteed.
Reference: Chapter 4.5 above; original Twitter Snowflake blog (blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake) for the algorithmic origin.
The region-failover timeline — what actually happens when us-east-1 dies
You claimed in the scaleRationale below that "regional failover routes traffic in <60s." That's one bullet. In an interview it will get drilled to 30 minutes of follow-ups. Here is what the interviewer wants to hear — a second-by-second timeline of a real region loss, with the numbers behind every phase.
The scenario: at T+0, us-east-1 experiences a full network partition (this actually happens ~1× per year at AWS — see the AWS us-east-1 outages of Dec 2021, June 2023, and July 2024 for historical precedent). us-east-1 handles ~40% of your global traffic. eu-west-1 and ap-northeast-1 are healthy.
| Time | What user sees | What infra is doing | Metrics |
|---|---|---|---|
| T+0s | Redirects fine (from CDN edge) | us-east-1 full partition. Route 53 health check starts failing (30s interval) | 100% SLO green |
| T+30s | CDN cache still serves 95% of GETs (edge cache is REGION-INDEPENDENT). POST /v1/urls fails for 40% of traffic (5xx errors on origin miss) | Route 53 marks us-east-1 origin UNHEALTHY (2 consecutive failures). DNS starts returning ONLY eu-west + ap-northeast as origins. PagerDuty fires page. Incident #INC-2024-... opens. Slack #incident-response bridged | Region marked down |
| T+60s | 95% of GETs still working (CDN). Some POSTs still fail (30% users on stale DNS) | Route 53 TTL is 60s. Well-behaved DNS resolvers refresh now. ~30% of the 5xx traffic shifts to eu-west/ap-northeast in this window | eu-west shard CPU: 40% → 55% |
| T+5min | GETs 100%. POSTs ~95% (5% still on stale DNS from ISPs that ignore TTL) | Corporate DNS + mobile carriers (worst offenders: ~5min TTL cache) finally refresh | eu-west CPU: 55% → 70%. ap-northeast CPU: 40% → 55% (uneven — us-east traffic goes to two nearest regions, mostly eu-west) |
| T+30m | ALL traffic working from 2 regions. BUT: new URLs created between T-500ms and T+0 may be LOST (RPO window) | Steady state on 2 regions. Read-your-writes still holds in each surviving region. Cross-region replica in us-east is stale by the outage duration | Bounded data loss inside 500ms replication lag |
The numbers
| Metric | Value | Meaning |
|---|---|---|
| RTO (Recovery Time Objective) | 5 min for 95% of traffic; 30 min for 99.9% | Bounded by DNS TTL propagation |
| RPO (Recovery Point Objective) | ~500 ms of writes | Async replication lag at moment of partition |
| CDN cache blast-radius shield | 95% of GETs unaffected | Edge cache is region-independent |
| Data loss risk | 0.001% of URL creations in a 500ms window | Only if not yet replicated AND us-east-1 is permanently gone |
Why each number is what it is
- Why RTO is not 60s despite Route 53 60s TTL — real-world DNS clients ignore TTL. Corporate networks cache for 5 min. Mobile carriers cache for hours. Java's default
InetAddresscache is FOREVER unless JVM property is set. Realistic RTO is bounded by the worst DNS client, not by Route 53's ideal. - Why RPO is not 0 — we deliberately picked ASYNC replication (see the L6 write flow above). Sync would give RPO=0 but add 200ms to every write. That is an intentional trade — the URL shortener SLA states "your URL is guaranteed within 1s globally," not "your URL cannot be lost." Different product than a bank.
- Why 95% of GETs survive without action — CDN edge is entirely region-independent. As long as any one origin region is reachable AND the CDN cache TTL has not expired for a given short_code, redirects continue. This is the value of the CDN tier — it is failure insulation, not just a latency win.
- Why "eu-west 40% → 70% CPU" is the number to watch — we had 40% steady-state headroom per region (see L6 capacity math). us-east-1 = 40% of traffic. That 40% splits ~30% eu-west + ~10% ap-northeast (weighted by geographic proximity). eu-west absorbs 30% new load on top of its 40% baseline → 70% steady. Above 70% sustained, p99 melts (queueing theory). This is exactly the safety margin we sized for.
Newbie mentor commentary — read this to understand the whole point:
- The RTO/RPO numbers are consequences of design choices, not aspirations. RTO=5min comes from "DNS TTL=60s + real-world clients cache 5min." If we wanted RTO=30s, we would need a client-side mechanism (SDK health-check + failover, or anycast IPs where BGP handles the shift). Those cost more. We chose the cheaper answer and documented the RTO honestly.
- The CDN is your circuit-breaker, not just your speed layer. Notice that 95% of GETs kept working with ZERO human action. That is the entire reason you own a CDN. Without it, region-1 loss = 40% of GETs fail immediately. Every dollar spent on CDN edge capacity is dollar-for-dollar cheaper than making origins failover faster.
- The 500ms RPO is a product-level lie you tell yourself deliberately. The SLA text on your marketing page probably says "we lose no data." Reality: any async system CAN lose up to the replication window. You buy that trade for latency. Interviewers respect the honest answer: "our RPO is bounded by replication lag, typically 500ms; we picked this over sync's 200ms per-write cost because a URL shortener's product does not require RPO=0."
- Test this with a game day BEFORE it happens for real. The industry-standard practice (Netflix Chaos Monkey, Google's DiRT, AWS's Fault Injection Simulator) is to break your own region on purpose in staging every quarter. If you have never actually tripped a region-failover in a controlled setting, your runbook is fiction. Reference: Basiri et al. (2016) "Chaos Engineering" (netflixtechblog.com/chaos-engineering-upgraded-878d341f15fa) and the Chaos Engineering book (Rosenthal & Jones, O'Reilly 2020).
Interview soundbite for this timeline. "For region loss my RTO is ~5 minutes for 95% of traffic, ~30 minutes for the long tail — bounded by DNS TTL. My RPO is ~500ms of writes, bounded by async replication lag. 95% of GETs survive with zero action because the CDN edge cache is region-independent. I deliberately picked async replication over sync because 200ms per-write latency was worse than a 500ms RPO window for our product. I game-day this every quarter — if the runbook has not been executed, it does not exist."
References for the failover machinery:
- AWS Route 53 health checks documentation — docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover.html — the failover mechanism used above.
- Netflix Chaos Monkey (netflix.github.io/chaosmonkey/) — the founding open-source tool for chaos engineering.
- AWS Fault Injection Simulator — aws.amazon.com/fis — commercial-grade chaos engineering platform.
- Google DiRT program (published in Site Reliability Engineering, Ch 15) — Google's Disaster Recovery Testing.
- AWS us-east-1 major outage postmortems — aws.amazon.com/message/ (search for "us-east-1") — real historical data on region loss cadence.
At L6, the interviewer expects cross-service reasoning, sharding strategy, migration paths, capacity math, and cost awareness. You should be able to propose a topology, explain why the shard key is what it is, walk through a real migration from L5 to L6 with zero downtime, and estimate node counts + cost. Vague answers ('we shard the DB') are not acceptable.
A good L6 answer proposes sharded MySQL + Redis Cluster + Kafka analytics + a coherent migration story. A great L6 answer does that AND: gives concrete node counts and monthly cost with reasoning; explicitly names the migration approach step-by-step with rollback plan; proposes shadow-mode + canary + progressive rollout; discusses hot-key mitigation (per-key in-process LRU); addresses rate-limiter scaling; talks about operational maturity (runbooks, on-call, chaos); and — most importantly — describes 'the next bottleneck at 1B RPS is cross-region latency and single-region capacity, and here's why we'd go multi-region'. That forward-looking sentence separates L6-solid from L6-strong.
How a request actually flows at L6
Two sequence diagrams — the READ path (redirect, hot) and the WRITE path (create, rare). At each level the sequence adds participants as the architecture evolves.
READ path — redirect at L6 (1M RPS)
CDN at edge absorbs 95%. Regional API + Redis cluster + sharded MySQL.
WRITE path — create at L6 (1M RPS)
Regional writes, Snowflake IDs, async cross-region replication, outbox for reliability.
Scale evolution at a glance
The same problem, four scales. Each column shows what the architecture looks like AT that scale + the bottleneck that forces evolution to the NEXT one. Read left → right to trace the evolution.
Single region, single service, single database
Ten thousand requests per second is comfortable for a well-tuned MySQL. The whole system is a load balancer, a small pool of app servers, and one database. Correctness first; scale later.
- • Add a database read index on `short_code` for O(1) lookup.
- • Pool DB connections via ProxySQL (or RDS Proxy) to avoid connection-storm at burst.
Add a distributed cache and read replicas
Ten× the traffic. The database becomes read-bound around 100K RPS. Introduce Redis in front of the read path (cache-aside pattern) and add MySQL read replicas. Writes still go to the primary.
Redis — Single Redis becomes a hot spot; also a single point of failure.
- • Cluster Redis for read scale and high availability.
- • Add a small in-process LRU on app servers for the hottest short codes.
- • Rate-limit new URL creation per IP to blunt abuse.
Shard writes, cluster the cache, async analytics
At a million RPS, the primary database write path becomes the bottleneck. Shard writes by `short_code` hash. Redis becomes a cluster. Analytics move to a Kafka + stream-processing pipeline so the redirect path stays lean.
Hot shard — A viral URL's shard sees disproportionate traffic.
- • Consistent-hash sharding by `hash(short_code)` for even distribution.
- • For hot keys, promote the entry to an in-memory tier on every app server (LRU) with a short TTL.
- • Rate-limit URL creation per API key; provide bulk-create as an async job.
Global edge, multi-region active-active, distributed KV
A billion RPS means the redirect must complete at the edge in most cases — the origin sees only cache misses and writes. Storage moves from sharded MySQL to a globally distributed KV (DynamoDB-style). Writes are replicated across regions.
Global write replication — Cross-region replication adds tens of ms of latency and creates a consistency window.
- • For the 'create then share' hot path, return the short URL only after the KV write has been acked by ≥2 regions (quorum).
- • For redirects, tolerate eventual consistency — worst case is a 404 for a few hundred ms, gracefully retried.
- • Edge cache negative results (404s) with a short TTL to avoid origin storms.
L6 interviewer will probe you at every scale — here's how to answer at each
Four scale tiers, one interview level. The mentor tells you at each scale: how deep to go, what to say, what to skip, and what will kill your answer.
30 seconds and move on
Single region, single service, single database
'Trivial — LB + MySQL + 3 app servers. Ships in 8 weeks with 3 engineers. Cost is $400/mo. Nothing interesting here for L6-level conversation.'
You don't waste time. You demonstrate scale-perspective — 10K is background noise at your level.
Spending 10+ min explaining a 10K design. Interviewer will pivot to 'walk me through 1M' and you'll have wasted your budget.
60-90 seconds, hit the pattern quickly
Add a distributed cache and read replicas
'Cache-aside with Redis (92% hit rate cuts DB 10x), plus 2 read replicas. Cost jumps to ~$1,600/mo. This is the pattern that lets us stay single-region up to ~200K RPS before latency for international users forces multi-region.'
You know the pattern by name and have specific numbers. You already know the ceiling of this design.
Getting stuck in cache-aside implementation details when the interviewer wants to move up.
This is where L6 lives — go deep here
Shard writes, cluster the cache, async analytics
Deep on: (1) CDN at edge with cache TTL strategy for redirects, (2) sharding strategy including rebalance mechanics + hot-shard mitigation, (3) Snowflake ID generation for cross-region write coordination, (4) explicit consistency trade-off (async replication, eventual global visibility), (5) regional failover runbook, (6) rate limiting + DDoS strategy, (7) cost model — $18K/mo across CDN + 3 regional stacks. Handle every follow-up.
Operational maturity. You've been on-call for a system like this. You know what breaks and how to handle it.
Missing the consistency conversation (async replication trade-off). Missing operational reality (runbooks, tabletop exercises).
At a million RPS, the primary database write path becomes the bottleneck. Shard writes by `short_code` hash. Redis becomes a cluster. Analytics move to a Kafka + stream-processing pipeline so the redirect path stays lean.
- Hot shard — A viral URL's shard sees disproportionate traffic.
- Consistent-hash sharding by `hash(short_code)` for even distribution.
- For hot keys, promote the entry to an in-memory tier on every app server (LRU) with a short TTL.
- Rate-limit URL creation per API key; provide bulk-create as an async job.
- Sharding by short_code means point lookups are fast, but any global scan (analytics) has to fan out — that's exactly why analytics went async.
- Async click ingestion means click counts lag by seconds. Real-time is not free.
- Kafka down → click events buffer locally on the app server for up to N minutes; drop with a metric alert past that.
- Redis cluster failover → circuit-break to shed load until warm.
- Shard failover → writes to that shard fail for ~30s; reads served by replicas.
1M RPS is where 'scale' stops being a knob and starts being a topology decision. Sharding, cache clustering, and async event pipelines are all mandatory. The shape of the diagram doubles, but the redirect path — the 99% case — is still just three hops.
Confident, includes cost + org dimensions
Global edge, multi-region active-active, distributed KV
'1B is where cost, org design, and product strategy dominate. Technically: own-CDN model (Netflix Open Connect), tiered storage (hot on SSD, cold on S3-Glacier, saves 5x), regional legal-entity operations for GDPR/PIPL/India-data-localization. Organizationally: platform team owns the golden paths, feature teams own product surface, security + compliance separate orgs. Cost: infrastructure spend $50-500M/year — capital allocation problem, not engineering.'
You can shift between technical and strategic in the same answer. You know 1B has business dimensions.
Staying purely in the technical lane. At L6, you must show you can talk business.
The L6 design walkthrough in full
Now that you know how to speak across scales, here's the full L6 answer — walkthrough, alternatives considered, key decisions, common mistakes, and what separates a good L6 answer from a great one.
At L6, the interviewer expects cross-service reasoning, sharding strategy, migration paths, capacity math, and cost awareness. You should be able to propose a topology, explain why the shard key is what it is, walk through a real migration from L5 to L6 with zero downtime, and estimate node counts + cost. Vague answers ('we shard the DB') are not acceptable.
Scale context
The interviewer typically anchors around 1,000,000 RPS. At this level: single-primary write throughput fails, single-Redis-node capacity fails, analytics volume overwhelms sync writes, and the single-region assumption may or may not still hold. Storage growth is still manageable (~44 GB/year) but write hot spots become the primary concern.
Clarifying questions to ask
Ask these before drawing anything.
- 1L4/L5 questions plus: Is 1M sustained or a peak? (Determines whether we overprovision or auto-scale.)
- 2Are we still single-region? (Multi-region is L7; single-region at L6 is still valid.)
- 3What's the analytics latency budget? (Real-time click counts vs 5-minute rollups vs daily batch — huge design impact.)
- 4What's the cost budget? (L6 is where 'best architecture' becomes 'best architecture that fits the P&L'.)
- 5What's the migration plan constraint? (Zero-downtime? Can we tolerate 5 seconds of write unavailability during cutover?)
Design walkthrough
The story of the design, one section at a time.
1. Shard the database by short_code hash
1M RPS with 100:1 read ratio = 10K writes/sec. That's beyond comfortable primary write throughput. Shard writes by hash(short_code) → 4 shards (each takes ~2.5K writes/sec, comfortable). Each shard has its own primary + 2 read replicas. Total: 4 primaries + 8 replicas = 12 MySQL nodes. Cost: ~$5K/mo.
2. Cluster the cache
Single-Redis-node caps at ~100K QPS. At 1M redirects/sec with 90% cache hit rate, we need ~900K Redis QPS. Redis Cluster with 3-6 shards handles this easily. Consistent-hash keys across cluster; add per-app in-process LRU (size ~1000 hottest keys) to save cluster hops on truly hot keys.
3. Move analytics off the hot path
Writing a click event synchronously on every redirect is now 1M writes/sec — impossible on a single system. Solution: emit click events to Kafka on the redirect (fire-and-forget from app), let stream workers aggregate into a click-counter store (Cassandra or ClickHouse), and expose via a separate stats API. Trade-off: click counts lag by seconds; that's fine for a URL shortener's analytics use case.
4. Migration from L5 to L6 (zero-downtime)
Step 1: Provision the new 4-shard cluster alongside the existing single-primary. Step 2: Dual-write from application code (write to old primary AND new shard on every create). Step 3: Backfill historic data via a batch job (short_code range partitions → new shards). Step 4: Read from new shards, fall back to primary for missing keys. Step 5: Verify metrics for a week. Step 6: Cut over reads fully to new. Step 7: Decommission old primary. Total migration: ~2 weeks with careful rollback plan.
5. Rate limiting at scale
Redis rate-limit counters need to shard too — otherwise one Redis node holds all our rate-limit state. Use per-user hash prefix to spread rate limits across cluster. Also introduce global rate limits per API key at the LB tier (WAF-level) to prevent malicious spikes from even reaching services.
6. Observability and operational maturity
At 1M RPS, you need: distributed tracing (Jaeger/Tempo) with per-service latency breakdowns; structured logs shipped to a central store (ELK/OpenSearch); metrics per shard (not just aggregate); on-call rotation with runbooks per top-10 alert; chaos testing (kill a shard, verify graceful degradation). If you're not doing these, you're not L6.
7. Cost model
Rough monthly cost: 12 MySQL nodes @ $500 each = $6K. Redis Cluster (6 nodes) = $2K. Kafka cluster (3 brokers) = $1.5K. Click-analytics store (Cassandra or ClickHouse, 3 nodes) = $1.5K. Load balancers + edge + monitoring = $2K. Total ~$13K/mo. At 1M RPS avg, that's ~$0.005 per 1000 requests — reasonable but worth optimizing. L6 candidates are expected to know this math.
Alternatives we considered (and why we didn't pick them)
For each alternative: why we rejected it, and when it would be the right choice.
DynamoDB instead of sharded MySQL
DynamoDB gives us automatic sharding without operational overhead. But: (1) cost at 1M RPS is significant (~$30K/mo on-demand), (2) migration from L5 (single MySQL) is a bigger jump, (3) we lose SQL for analytics queries. If we were greenfield, DynamoDB is a strong choice. From an existing L5 MySQL, sharded MySQL via Vitess/Citus is a smaller step.
Greenfield at 1M+ RPS scale where we don't have MySQL inertia. Also wins for global multi-region (DynamoDB Global Tables) — that's L7 territory.
Shard by user_id instead of short_code
User-based sharding is common but wrong for URL shorteners. Our hot path is: given short_code, return long_url. If sharded by user, every redirect needs a user_id lookup first (round trip) OR every shard scans for the short_code (fan-out). Sharding by short_code is O(1) routing.
Systems where the hot path is user-centric — feeds, timelines, user data. Shortener is short-code-centric.
Keep single primary, just add more read replicas
Reads scale linearly with replicas — but writes are stuck at primary capacity. At 10K writes/sec sustained, a single MySQL primary suffers. Adding replicas doesn't help writes. We MUST shard.
If write RPS is low relative to reads (e.g., 100K reads:1K writes). Not our case at 1M.
Multi-primary (MySQL BDR / Cassandra)
Multi-primary means writes accepted anywhere, replicated between primaries. But: conflict resolution on same-key writes is nasty, and we don't need write locality (single region). Sharded single-primary is simpler.
Multi-region setups with local writes — L7 territory.
Synchronous click writes to Cassandra (skip Kafka)
Direct writes to Cassandra work — Cassandra handles 100K+ writes/sec per node. But: coupling the redirect path to Cassandra write latency (~10ms) hurts p99. Kafka decouples us: producer writes are async (~1ms), Kafka absorbs bursts, workers write to Cassandra at their own pace.
If analytics latency requirement is truly real-time and Kafka's few-second lag is unacceptable. Rare in practice.
Key decisions and their reasoning
Sharding strategy
Hash-partition by short_code, 4 shards each with 3-replica MySQL
Hash gives even distribution guaranteed. Range creates hot spots (newest URLs dominate). User_id sharding doesn't match our access pattern. Directory-based adds a routing service (extra hop) and coordination complexity.
Number of shards
4 shards initially (with room to double)
Each shard handles ~2.5K writes/sec at 1M RPS — well within primary capacity with headroom for growth. Rebalancing to 8 shards later is a known operation. Starting with too many small shards wastes money and adds operational overhead.
Analytics pipeline
Kafka + stream worker + Cassandra/ClickHouse for counters
Kafka decouples the hot redirect path from analytics durability. Stream workers can be scaled independently. Cassandra excels at high-write counter workloads. ClickHouse if we want richer analytical queries.
Migration approach
Dual-write + backfill + verify + cutover
Zero-downtime with rollback capability. Dual-write catches new writes; backfill catches history; verify period ensures correctness before cutover. Total ~2 weeks of engineer time; production is stable throughout.
Cache cluster technology
Redis Cluster with 6 shards + local in-process LRU per app server
Redis Cluster gives us horizontal cache scaling with consistent hashing. In-process LRU saves round trips for truly hot keys. Memcached Cluster is a viable alternative if we don't need Redis's richer types.
Common mistakes at this level
- Sharding by user_id instead of short_code — access-pattern mismatch
- Not proposing a migration plan — 'we shard' isn't a plan; how do we get there without downtime?
- Ignoring cost — at L6, cost is a real constraint. Named numbers required.
- Skipping the analytics decoupling — 1M sync click writes is not viable
- Forgetting the rate-limiter needs to shard too — single Redis for rate limits doesn't scale to 1M RPS
- Naming 'chaos engineering' or 'runbooks' as afterthought — at L6, these are core, not extras
- Assuming multi-region is required — the interviewer may say 'stay single-region'; know when NOT to multi-region
What separates good from great at this level
A good L6 answer proposes sharded MySQL + Redis Cluster + Kafka analytics + a coherent migration story. A great L6 answer does that AND: gives concrete node counts and monthly cost with reasoning; explicitly names the migration approach step-by-step with rollback plan; proposes shadow-mode + canary + progressive rollout; discusses hot-key mitigation (per-key in-process LRU); addresses rate-limiter scaling; talks about operational maturity (runbooks, on-call, chaos); and — most importantly — describes 'the next bottleneck at 1B RPS is cross-region latency and single-region capacity, and here's why we'd go multi-region'. That forward-looking sentence separates L6-solid from L6-strong.
Why this design fits L6 — and what will break it
Why this design works AT L6
CDN (Cloudflare/CloudFront) absorbs 95%+ of GET traffic at the edge — 950K RPS never reaches your origin. Cache TTL on redirects: 5 min (jittered ±30s). Origin sees ~50K RPS, distributed across 3 regions (us-east, eu-west, ap-northeast) = ~17K RPS per region. Each region has: 8 app servers, Redis cluster (3 nodes), sharded MySQL (4 shards, ~4K writes/sec per shard). Snowflake-style IDs prevent cross-region collision without coordination. Regional writes replicate asynchronously to a global replica in ~500ms (eventual consistency accepted for URL creation → global visibility).
~$18K/month total (CDN $3K + 3 regional stacks × $5K each). Cost-per-million-requests: $0.02 (CDN offloading is the reason). Ratio of revenue to infra at this scale should be ~100:1 minimum.
Team is now 20-40 engineers, structured into 3-4 sub-teams: platform (multi-region infra), API (product logic), data (MySQL/Redis operations), and SRE. Total moving parts: 30+ across regions. Requires on-call rotation, runbooks, and tabletop exercises for regional failover.
- Entire AWS region down → CDN + regional failover route traffic to healthy regions in <60s (assumes DNS TTL=60s + Route 53 health checks)
- MySQL shard down → other shards continue serving, degraded but no full outage
- Redis cluster rebalance → hash-slot migration transparent to clients
- CDN origin unreachable → CloudFront serves stale-if-error from cache for up to 1 hour
- Malicious traffic burst → WAF + rate limiter (per-IP + per-API-key) sheds abuse
- Cross-region replication lag → each region has its own Redis + MySQL, tolerating temporary partition
At 1M RPS I use CDN at the edge (95% offload), 3 regional stacks with sharded MySQL + Redis, Snowflake IDs to avoid cross-region write coordination, and async replication for eventual global consistency of new URLs. I explicitly gave up global read-your-writes as a trade-off — a new URL is visible in its home region in <10ms and globally in <1s. That's the right trade for a URL shortener (nobody creates a URL and immediately expects a Tokyo user to see it).
Why this design breaks at the NEXT scale tier
At 1B RPS the bottleneck isn't compute or storage — those scale linearly. The bottleneck becomes (1) cost efficiency (are you spending $10M/mo you could avoid?), (2) organizational scaling (200-engineer teams stepping on each other's changes), (3) regulatory (China Cybersecurity Law, EU DMA, India data localization), and (4) product strategy (is URL shortening even the product anymore, or is it now a feature of a larger analytics platform?).
Infrastructure spend >$5M/year, team size >50 engineers, presence required in 10+ regulatory regimes, product-market ambiguity ('should we bundle this into our marketing platform?').
Nothing bad user-facing. User experience is great. What users don't see: infrastructure inefficiency, team velocity slowdown, compliance overhead, and strategic drift.
The architecture works fine technically. What can't scale is (a) *how you allocate capital to it* (build vs buy for CDN? Own DCs vs cloud?), (b) *how you structure the 200-engineer team* (platform team? feature teams? SRE embedded or centralized?), (c) *how you differentiate from competitors* (Bitly, Rebrandly are commodifying — do you go up-market to enterprise? Down-market to zero-cost? Bundle into analytics?). These are L7 questions — strategy, not architecture.
Interviewer will ask: 'You're at $50M/year infra spend. Where would you cut 20%?' Your answer: 'First I look at the biggest line items — likely CDN egress (30% of spend). Options: (1) negotiate contracts (Cloudflare vs CloudFront pricing tiers), (2) origin shielding to reduce cache misses, (3) own our own CDN nodes in high-traffic regions (Netflix Open Connect model — cheaper at extreme scale but requires 20+ engineers to operate). Second-biggest: cross-region data transfer. Options: (1) reduce replication frequency for cold data, (2) tiered storage (S3 Glacier for old redirects). That's the L7 mindset.'
The trigger to evolve to the next tier
Infrastructure spend, team size, competitive positioning, regulatory pressure
$5M+/year infra OR 50+ engineers OR 5+ regulatory regimes OR competitor pricing pressure
L7 evolution is 12-18 months. Strategy, hiring, org-restructure lead times dominate.
L6 = CDN + multi-region API + Snowflake IDs + async replication + regional failover. Latency wins force async replication, which loses global read-your-writes. You pick that trade-off consciously and defend it.
- What are the 3 hard problems at global scale?
- How do you generate globally-unique short codes without coordination?
- How does CDN caching work for redirects?
- What's the trade-off between sync and async replication?
- How do you handle region failover?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The ring behind CDN routing + sharded DB — this concept is used 3 times in this chapter (CDN PoP selection, Redis Cluster, DB shard placement).
Range vs hash vs consistent-hash — the exact 4-way decision matrix we ran for the short_code table. Full deep-dive here.
How region failover happens at the DNS layer — TTL + health check + weighted routing. The 30-60s failover window traces to DNS TTL.
Serving the same IP from 300 places at once — the mechanism behind CloudFront + Cloudflare edge + why global CDN latency is 20-50ms not 200ms.
How multi-region leader election actually works — the algorithm inside every managed distributed database (Spanner, Cockroach, etcd).
Cross-region replication + client requirements = a specific consistency model. Know the vocabulary before you defend 'eventual consistency is fine' or 'we need linearizable'.
Async / sync / semi-sync — the trade-off we make for cross-region. The 3 topologies with 3 latency/consistency profiles.
The general problem behind MySQL Multi-AZ failover + Redis Sentinel + Kubernetes control-plane election.
Chapter 7.5: the analytics pipeline. We keep saying 'fire an event to Kafka' — but how does the click actually get counted? What's the Kafka topic partitioning? How does the dashboard show a real-time count of billions of clicks? This is the deep-dive on the pipeline that turns redirects into product value.
The complete L6 request lifecycle
Every hop, every server, every microsecond — architect's-eye view
Every previous chapter has zoomed in on a slice — the API, the cache, the sharding. This chapter zooms out. From the moment a browser types `sysd.link/aB3xY9k` until the browser lands on `example.com/team-offsite`, what actually happens? How many servers touched the request? Where did each microsecond go? What if any one component failed?
At L6 scale, this is where multi-server coordination stops being abstract and becomes a concrete choreography. I'll trace the read path (redirect) and the write path (create) through every layer and every failure branch. This is how a Staff Engineer thinks — not "we use MySQL" but "MySQL is server #17 in a 200-server dance."
The L6 architecture — everything on one page
Before we trace, here's the complete component inventory at L6 (1M RPS, 3 regions):
Edge tier:
- CDN + Anycast IPs — Cloudflare or Fastly, ~250 PoPs globally. Terminates TLS, does BGP routing to nearest region.
- DDoS/WAF — Cloudflare Bot Fight Mode, AWS Shield Advanced.
Regional tier (× 3 regions — us-east-1, eu-west-1, ap-northeast-1):
- ALB / Regional load balancer — AWS ALB (or GCP CLB). Health checks on app pods every 5s.
- App servers — 50 pods × 3 regions = 150 pods total. Each pod is Go/Java, 4 vCPU, holds local KGS batch + Redis connection pool.
- Redis Cluster — 6 shards × (1 primary + 2 replicas) = 18 nodes per region. Cache-aside. TTL 24h. 92% offload from Ch 6.5.
- MySQL sharded — 4 shards × (1 primary + 2 read replicas Multi-AZ) = 12 nodes per region. Vitess as the sharding proxy (Ch 7 decision).
- KGS servers — 4 KGS API servers + 4 KGS worker pods per region. Refills sharded available_codes table.
- Kafka cluster — 3 brokers × 3 AZs = 9 brokers for analytics fan-out (Ch 7.5).
Cross-region tier:
- Route 53 — DNS-based failover with health checks (30s interval).
- Cross-region MySQL replication — async, 100-500ms lag (Ch 7).
- Cross-region Redis Cluster replication — via Redis Enterprise or custom pubsub invalidation.
Total component count at L6: ~250 pods + ~54 databases + ~30 Redis nodes + ~27 Kafka brokers + ~24 KGS instances + 3 CDN PoPs interacting = ~380 stateful components per region × 3 regions = ~1,140 things that can independently fail.
Read path — the redirect hot path (99% of traffic)
Scenario: user in London clicks https://sysd.link/aB3xY9k (Bit.ly-style short URL). Trace every microsecond:
text━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 1: Browser → DNS resolution (Anycast) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Browser looks up "sysd.link" → cached in OS resolver for 300s. Cache MISS: query recursive resolver → root NS → .link TLD → sysd.link NS. Anycast IP returned: 172.66.0.42 (Cloudflare edge). | Latency: 0 ms (cache hit) OR 20-50 ms (DNS resolution) | Cost: $0 (Route 53 → $0.40/M queries) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 2: Browser → Cloudflare edge PoP (LHR — London) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ BGP routes the browser's traffic to nearest Anycast PoP (LHR). TLS 1.3 handshake (0-RTT if client has session ticket). Cloudflare Worker inspects request: - Not in bot signature list? ✓ - Not in rate limit block? ✓ - Short URL not in KV cache? ✓ (KV cache = last-known short_code → long_url) | Latency: 5-10 ms (TLS + edge worker check) | Cost: ~$0.00005/request If Cloudflare KV cache HIT (top 10 URLs by traffic): Return 302 directly from edge, skip origin. | Cache hit rate: ~40-60% for top-10K URLs | End-to-end: ~15 ms — user perceives instant If MISS: forward to nearest origin region (eu-west-1). ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 3: Cloudflare → ALB (eu-west-1) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Cloudflare→origin via Argo (Cloudflare's optimized transit). ALB accepts TCP connection (pooled), routes to healthy pod. Health check bit: pod passed /healthz within last 5s. Consistent hashing by short_code → app pod #23 of 50. | Latency: 2-5 ms (LHR → Dublin) | Cost: ALB $0.008/hour + $0.008/LCU ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 4: App pod → Redis Cluster (in-region) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ App pod: CRC16("aB3xY9k") mod 6 → Redis shard #4. GET url:aB3xY9k → HIT (92% hit rate at L6). Return long_url = "https://example.com/team-offsite". | Latency: 0.3 ms (in-region Redis) | Cost: Cache.r6g.xlarge = $0.35/hour × 18 nodes = $6/hour ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 5 (only if cache MISS, 8% of requests): App → Vitess → MySQL ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ App connects to Vitess vtgate (sharding proxy). vtgate: hash("aB3xY9k") mod 4 → shard #3. Route to read replica #2 of shard #3 (least loaded). SELECT long_url FROM urls WHERE short_code = 'aB3xY9k' Buffer pool HIT (95% warm at L6). Clustered PK B+tree, 4 levels, leaf has row data. | Latency: 40-100 μs (warm), 1-5 ms (cold) | Cost: db.r6i.2xlarge × 12 replicas = ~$8K/month for reads App: SET url:aB3xY9k = long_url TTL 24h (warm-on-miss, fire-and-forget). ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 6: App → Kafka (fire-and-forget analytics — Ch 7.5) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ App builds ClickEvent {short_code, timestamp, ip_hash, user_agent, referer}. Kafka produce (acks=1, not acks=all — analytics tolerates loss). Producer queue in-memory, batched, flushed every 100ms. | Latency: 0 ms (async — response fires before Kafka roundtrip) | Cost: Kafka MSK = ~$500/month regional ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 7: App → HTTP 302 response ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Build response: HTTP/1.1 302 Found Location: https://example.com/team-offsite Cache-Control: private, no-cache, max-age=0 X-Trace-Id: {tracing-uuid} Traverse: pod → ALB → Cloudflare → browser. | Latency: 5-8 ms (return leg mirrors outbound) | Total wall-clock: 10-15 ms warm, 15-20 ms cold miss ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 8: Browser → example.com (out of our system) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Browser follows Location header. New DNS, new TLS, new TCP. Our job ended at STEP 7. User's perception ends at page render (~1-3s).
Latency budget summary (warm path, 92% of traffic):
| Step | Time | % of total | Component owned by |
|---|---|---|---|
| 1. DNS | 0 ms (cached) | 0% | Client OS |
| 2. TLS + Cloudflare edge | 5-10 ms | 60% | Cloudflare |
| 3. Cloudflare → ALB | 2-5 ms | 25% | Cloudflare + AWS |
| 4. App + Redis lookup | 0.5 ms | 3% | Us |
| 5. MySQL (skipped when cache hit) | 0 ms | 0% | Us |
| 6. Kafka async | 0 ms (fire-and-forget) | 0% | Us |
| 7. Response return leg | 5-8 ms | 40% (network) | Cloudflare + AWS |
| Total | 12-15 ms | 100% |
Key architect's-eye observation: ~85% of our latency is network we don't control. The ~1% we own (app + Redis) is the ONLY place we can optimize with code. The rest is CDN geography — the answer is more PoPs, not faster code.
Multi-server coordination points on the read path
The read path touches ~15 distinct components. How do they stay coherent?
Coordination point 1: Cloudflare KV cache vs origin Redis vs MySQL
Three caching tiers. What if they disagree?
- KV cache TTL = 60s, Redis TTL = 24h, MySQL = source of truth.
- On UPDATE (Ch 9.5 Gap #2): publish invalidation to Redis cluster (in-region <100ms) + Cloudflare purge API (global <5s).
- Race window: 5s where Cloudflare might serve stale. Acceptable for URL shortener (no financial impact).
- If stronger consistency needed (e.g., after DELETE): serve 410 Gone at origin — Cloudflare KV cache still expires in 60s but at least we don't send stale traffic.
Coordination point 2: 4 MySQL shards, 3 replicas each
Which replica does app pod pick?
- Vitess vtgate routes based on: (a) short_code hash → shard, (b) least-recently-used replica within shard.
- Replica lag threshold: 500 ms. If replica > 500ms behind primary, vtgate removes it from routing pool.
- Read-your-own-writes: after a POST /v1/urls returns, the same app pod pins subsequent reads to primary for that user's session for 60s. Reference: Vitess consistent reads.
Coordination point 3: 3 regions, cross-region replication
If London writes a new URL that's read from Tokyo 200ms later, what happens?
- Cross-region MySQL replication has 100-500 ms lag (Ch 7).
- Tokyo read: cache MISS (URL didn't propagate yet) → Tokyo MySQL → NOT FOUND → 404.
- Fallback: on 404 in region X, retry against region Y where the write originated (X-Trace-Id has hint).
- Or: writes go to a "recent-writes" table in Redis (pubsub) that all regions see within <1s. Fallback lookups check this first before returning 404.
Coordination point 4: KGS pool depth across shards
KGS shard #2 pool is 10x deeper than shard #1. Why?
- Consistent hashing distributes creates unevenly (short_code prefix = first 3 chars, not full hash).
- Solution: KGS worker pool doesn't shard by prefix — it shards by round-robin across pool DB shards. Consumption is by short_code hash. Pool remains balanced.
Write path — the create cold path (1% of traffic)
Trace POST /v1/urls {long_url: "https://example.com/very-long"}:
text━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 1-3: Same as read — DNS, edge, ALB ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | Latency: 10-15 ms ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 4: App pod → validate request ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Parse JSON, check long_url format (< 2048 chars, valid URL). Check Idempotency-Key: has this key been seen in last 24h? Redis GET idem:{key} → HIT: return cached response (dedupe). → MISS: continue. | Latency: 0.5 ms ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 5: App pod → Google Safe Browsing (Ch 7.6) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Async: fire GSB API call (10-50 ms). Simultaneously: local denylist check (in-memory bloom filter, <100 μs). If denylist HIT: 400 Bad Request "URL blocked by policy". | Latency: 0 ms (async), 10-50 ms if we blocked on it ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 6: App pod → KGS.pop() from local batch ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Local batch of 100 pre-fetched codes (Ch 4.5 KGS deep-dive). Batch not empty → pop one code = "kQ4mL7p" Batch empty → fetch batch of 100 from KGS API (1-2 ms round-trip). | Latency: 0.02 ms (local pop) or 1-2 ms (batch refill) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 7: App pod → Vitess → MySQL write shard ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ INSERT INTO urls (short_code, long_url) VALUES ('kQ4mL7p', $url) vtgate: hash("kQ4mL7p") mod 4 → shard #1 primary. Shard #1 primary: 1. Write to redo log (fsync) 2. Write to binlog (fsync — Multi-AZ semi-sync) 3. Wait for 1 replica ACK (semi-sync) 4. Return OK | Latency: 2-5 ms | Cost: db.r6i.2xlarge write = $500/month × 4 shards = $2K/month ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 8: App pod → Redis warm cache ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ SET url:kQ4mL7p = $long_url TTL 24h | Latency: 0.3 ms ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 9: App pod → Idempotency-Key cache ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ SET idem:{key} = {short_url response body} TTL 24h | Latency: 0.3 ms ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 10: App pod → HTTP 201 Created response ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ { "short_url": "https://sysd.link/kQ4mL7p", "long_url": "https://example.com/very-long", "created_at": "2026-08-28T18:00:00Z" } Traverse: pod → ALB → Cloudflare → browser. | Latency: 5-8 ms | Total write path: 20-35 ms
Multi-server coordination points on the write path
Coordination point 5: MySQL primary + 2 semi-sync replicas
At INSERT: primary waits for 1 of 2 replicas to ACK before returning success. This survives 1 replica loss silently, 2 replica loss fails-open (loses write durability guarantee).
- Semi-sync trade-off: +1-2 ms latency vs async, but survives primary crash without data loss.
- Reference: MySQL Semi-Sync Replication.
Coordination point 6: Cache warm-on-write race
If step 8 (SET Redis) happens AFTER a concurrent redirect request reads from Redis (cache miss → MySQL → SET Redis), we could OVERWRITE the correct value with our own. Solution: use SET IF NOT EXISTS (SETNX) on cache warm-on-write. Or just accept it — both values are identical.
Coordination point 7: KGS pool depletion during viral event
Traffic spikes 10x. Every app pod drains its 100-code local batch in 1 second instead of 100 seconds. All 150 pods hit KGS API at once → thundering herd.
- Solution 1: KGS API in-region, fronted by ALB, autoscales to absorb burst.
- Solution 2: Local batch size adapts — pods that see high create rate pre-fetch 1000 codes instead of 100.
- Solution 3: Circuit breaker — if KGS fails, fall back to random+collision-check (Ch 4.5 Approach 1) for 60s.
What can fail at each step (the L6 failure surface)
For each numbered step above, here's what breaks and how we degrade:
| Step | Component fails | Impact | Mitigation |
|---|---|---|---|
| 1 | DNS resolver | User can't resolve sysd.link | Cache TTL 300s masks brief outages; Route 53 Multi-region |
| 2 | Cloudflare edge PoP | Traffic routes to next-nearest PoP | Anycast BGP handles automatically, +10-20ms |
| 3 | ALB | Regional outage | Route 53 health check fails → route to next region, 30-60s TTL |
| 4 | App pod | Pod restarts, ALB re-routes | 5s health-check window; 1 pod loss = 0 impact |
| 5 | Redis shard primary | 8% cache miss for 1/6 of URLs | Redis Sentinel fails over in 15s; MySQL absorbs miss |
| 6 (write) | KGS API | Local batches drain in 100s | Circuit breaker → inline collision-check |
| 7 | MySQL primary | Write path fails for 1/4 shards | Vitess fails over to replica in 15-30s; 25% write outage during window |
| 8 | Kafka | Analytics events buffer in producer | Producer local queue (200 MB) buys 60s of runway |
The senior insight — coherence is the product
You can build every one of these components in isolation. Cache, DB, LB, CDN — well-documented. The magic is in the coordination. How do 15 components stay coherent when any one might fail, and how does the user never see it?
The 4 senior principles:
- Every layer has a fallback. Cache misses fall to DB. DB primary fails to replica. Region fails to next region. No single layer is "the answer".
- Every write publishes coordination events. Redis invalidation, Kafka analytics, cross-region replication — all fan-out asynchronously so the write path stays fast.
- The blast radius is bounded. One MySQL shard fails = 25% write outage, not 100%. One region fails = fail-over to next, not global outage.
- The critical path is minimum. Read path = 15ms, mostly network. Write path = 25ms, mostly durable log + cross-AZ. Nothing else is on the critical path.
Interview soundbite: "The architect's-eye question isn't 'do you know Redis?' — it's 'when Redis fails, how does the system degrade gracefully so the user sees 20ms latency instead of a 5xx error?' At L6, that answer is defined by ~15 explicit fallback paths and ~7 coordination points — and the discipline to keep them ALL bounded and testable."
References
- Vitess Consistency Mode docs — routing reads to replicas + read-your-writes.
- MySQL Semi-Sync Replication docs — 1-of-N replica ACK for durable writes.
- Redis Cluster consistency guarantees — eventual consistency on failover, pubsub for invalidation.
- AWS ALB target group health checks — the 5s health-check that gives 1-pod-loss = 0 impact.
- Cloudflare Anycast + Argo — how global BGP routing hides regional failures.
- Google SRE — Handling overload (Ch 21) — the load-shedding pattern that makes step 6 KGS thundering-herd survivable.
The L6 URL shortener is ~380 components per region × 3 regions = ~1,140 things that can fail. The read path traverses ~15 components in 15ms (85% network); the write path traverses ~10 components in 25ms (mostly durable log fsync). Coherence comes from 7 explicit coordination points + 15 fallback paths. Interview greatness = talking about the coordination, not the components.
- How many components touch a single redirect request at L6?
- Where does 85% of the read-path latency come from?
- What are the 7 multi-server coordination points?
- What does the write path look like end-to-end?
- How does the system degrade when a MySQL shard fails?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The algorithm behind Vitess shard routing (step 5 read, step 7 write) and Redis Cluster slot assignment (step 4).
Coordination point 7 (KGS thundering herd) uses circuit breakers to fall back to inline collision-check when KGS is overloaded.
Chapter 7.5 zooms into the analytics pipeline — step 6 of the read path (Kafka fan-out) at 10B clicks/day. Fire-and-forget looks simple; getting exactly-once semantics at that scale is anything but.
The analytics pipeline
How 10 billion clicks/day actually get counted
Here's the honest confession every URL-shortener design tutorial makes: "we fire an analytics event to Kafka on every redirect." One sentence. Then it moves on.
That single sentence hides an entire distributed system. If you can't defend the pipeline in an interview, the interviewer will drill in and expose the gap. Analytics is 50% of a URL shortener's value proposition — Bit.ly, Rebrandly, and every enterprise shortener sells the dashboard, not the redirect. Let's make sure you know how it works.
What are we actually counting?
Requirements — spelled out:
- Every click on `sysd.link/aB3xY9` increments a counter for that short_code
- Attribution: country, referer, user_agent, timestamp
- Real-time (< 5 sec lag): the URL creator opens the dashboard and sees a click that just happened
- Historical (indefinite retention, aggregated): "here's your click volume by day for the past year"
- Never block the redirect on analytics. The user experience is sacred; if analytics fails, we still redirect and drop the event.
- At-least-once delivery for the raw event, exactly-once for the aggregation (real-time dashboard OK to drift by 1-2% short-term; historical counts must be exact)
Step 1 — the ingest hop (the redirect path fires an event)
The redirect handler does two things in strict order:
pythondef handle_redirect(short_code): long_url = cache_or_db_lookup(short_code) if not long_url: return 404 # Fire-and-forget event — NEVER block on this try: kafka_producer.send_async( topic="url-clicks", key=short_code, # partition by short_code value={ "short_code": short_code, "timestamp": now_utc(), "ip_hash": hash(request.ip), # NEVER store raw IP (GDPR) "country": geoip_lookup(request.ip), "referer": request.headers.get("Referer"), "user_agent_family": ua_parse(request.headers["User-Agent"]).family, }, ) except Exception: # Drop the event silently; the user's redirect is still served metrics.increment("analytics.event.drop") return redirect(long_url, status=302)
Why fire-and-forget? Because the alternative — synchronous send — means when Kafka has a bad day, our redirect latency spikes. A URL shortener's job is to redirect fast; analytics is secondary. Google's SRE book (Ch 3, "Embracing Risk") spells this out: latency of the primary path is your SLO; auxiliary services must never block it.
Why key by short_code? So all clicks for the same short_code land on the same Kafka partition. This gives us partition-local aggregation — the downstream consumer doesn't need cross-partition coordination to count clicks per URL.
Step 2 — Kafka topic partitioning
The critical L6 design question: how many partitions?
The math (from Kafka's documented sizing guide — kafka.apache.org/documentation/#operations):
- Each partition can sustain ~10 MB/sec write throughput (with tuning: up to 100 MB/sec)
- Each event is ~500 bytes (JSON)
- 10K RPS × 500 B = 5 MB/sec → 1 partition suffices
- 1M RPS × 500 B = 500 MB/sec → 50 partitions needed (with 20% headroom → 60)
- 10M RPS × 500 B = 5 GB/sec → 600+ partitions
But partition count also affects consumer parallelism. More partitions = more consumers in parallel = faster processing. Rule of thumb: partitions = max(throughput_partitions, target_consumer_parallelism × 2) so consumers have room to scale independently.
For our 1M RPS L6 design: 64 partitions (nice power of 2, gives 32 consumer parallelism headroom).
Replication factor: 3 (industry standard for durability — one broker can die, one can be in maintenance, one still serves) per the Kafka documentation. Retention: 7 days on the raw topic (enough for reprocessing / debugging), then aged out.
References: Kafka documentation (kafka.apache.org/documentation), Kreps et al. (2011) "Kafka: A Distributed Messaging System for Log Processing" — the founding paper: notes.stephenholiday.com/Kafka.pdf.
Step 3 — real-time aggregation (Flink or Kafka Streams)
Now the consumer. Two industry-standard choices:
| Option | Trade-off |
|---|---|
| Kafka Streams | Java library, embeds in your app. Zero extra infrastructure. Best for teams with strong Java skills and simple aggregation. |
| Apache Flink | Standalone stream processor. Better checkpointing, exactly-once semantics, richer windowing. Better for complex pipelines and non-Java teams. |
| Apache Spark Streaming | Micro-batch (not true stream). Higher latency. Ubiquitous in data teams. |
Recommendation for URL Shortener at L6: Flink. Exactly-once semantics matter for billing customers on click volume; Flink's checkpoint-based recovery guarantees no double-counting even during failover. Reference: Carbone et al. (2015) "Apache Flink: Stream and Batch Processing in a Single Engine" — the founding paper: sites.computer.org/debull/A15dec/p28.pdf.
The Flink pipeline:
java// Read the raw clicks stream DataStream<ClickEvent> clicks = env .fromSource( KafkaSource.<ClickEvent>builder() .setBootstrapServers("kafka:9092") .setTopics("url-clicks") .setDeserializer(new ClickEventDeserializer()) .build(), WatermarkStrategy.<ClickEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5)), "clicks-source"); // Windowed count: 1-minute tumbling window per short_code clicks .keyBy(ClickEvent::getShortCode) .window(TumblingEventTimeWindows.of(Time.minutes(1))) .aggregate(new CountAggregator()) .addSink(new RedisRealtimeCountSink()); // writes to Redis for dashboard // Windowed count: 1-day tumbling window per short_code (batch fallback) clicks .keyBy(ClickEvent::getShortCode) .window(TumblingEventTimeWindows.of(Time.days(1))) .aggregate(new CountAggregator()) .addSink(new PostgresHistoricalSink()); // durable long-term storage
Two windows, two sinks. The 1-minute window feeds Redis for the dashboard's "clicks in the last minute" widget. The 1-day window feeds MySQL for the historical dashboard.
Watermark strategy: forBoundedOutOfOrderness(5 seconds) — allow events up to 5 seconds late (mobile networks are messy). Events later than 5s go to a side output (dead letter) for manual investigation.
Checkpointing: every 30 seconds Flink writes a checkpoint to S3. On failure, the pipeline resumes from the last checkpoint — Kafka offsets are stored WITH the checkpoint, so we never double-count. This is what "exactly-once" means in practice.
References: Flink docs (flink.apache.org/docs), Carbone et al. 2015 paper, the Flink chapter of "Stream Processing with Apache Flink" (Hueske & Kalavri, O'Reilly 2019).
The end-to-end pipeline, visualized
Every click event flows through this pipeline from redirect to dashboard. Explore the interactive evolution below — pick any tier (L4/L5/L6/L7) to see how the pipeline grows one primitive at a time. Live event particles animate through each stage:
Pick a tier. Watch the pipeline evolve. See why each tier breaks at the next scale.
L4: in-DB counter · L5: Redis async · L6: Kafka+Flink dual sink · L7: regional + tiered. Each tier adds ONE new primitive.
Postgres UPDATE clicks blocks the redirect path. 10K concurrent locks on the SAME row.
Newbie insight: the redirect path (left column) never blocks on the analytics path (right column). If Kafka is down, the redirect still returns 302; we just drop that one event. This is what "auxiliary services must never block the primary path" (Google SRE book Chapter 3) actually looks like in a diagram.
Step 4 — the read path (dashboard queries)
The URL creator opens their dashboard. Three types of queries:
Query A: "How many clicks total?" → Redis GET clicks:total:aB3xY9 → ~500µs
Query B: "Clicks in the last hour?" → Redis ZRANGEBYSCORE clicks:minute:aB3xY9 <one-hour-ago> <now> → 60 entries summed → ~2ms
Query C: "Clicks per day for the last 30 days?" → MySQL SELECT day, count FROM click_daily WHERE short_code = $1 AND day > NOW() - INTERVAL '30 days' → ~10ms
Two aggregation tiers, two SLAs. Real-time (Redis) is <5ms; historical (MySQL) is <100ms. Neither is on the redirect hot path; both survive if the other tier is degraded.
Step 5 — the cost math (the interview probe)
"How much does this analytics pipeline cost at 1M RPS?" — the interviewer's favorite L6 probe. Show them:
- Kafka (MSK): 3 m5.large brokers with 3TB storage = $650/mo. Handles 500 MB/sec write, 200 MB/sec sustained.
- Flink (managed via Kinesis Data Analytics or Confluent Cloud): 4 parallelism units at $150/unit/mo = $600/mo. Scales as you add short_codes.
- Redis for real-time counts: reuse existing L5/L6 Redis cluster; extra memory for counter keys = ~10 GB at 100M active short_codes × 100 bytes each = $50/mo added.
- MySQL for historical: reuse existing analytics DB (separate cluster from URL DB — never let analytics pressure the redirect path); ~$300/mo for storage growth.
- Total: ~$1,600/mo on top of the existing L6 stack.
Cost per click: $1,600 / (10B/day × 30) = $5 per billion clicks. That's the "unit economics" line an executive wants to hear.
Step 6 — the failure modes (the L6 depth)
Failure 1: Kafka broker dies.
- Propagation: producer retries with exponential backoff. Events for that partition queue in producer memory.
- Mitigation: replication factor 3 means another broker takes over the partition in <30s.
- Recovery: producer flushes queued events. No data loss (unless producer OOMs — hence the fire-and-forget drop above).
Failure 2: Flink job dies mid-window.
- Propagation: checkpointing means we resume from the last checkpoint (up to 30s of re-processing).
- Mitigation: Kafka Streams / Flink handles automatically; on-call sees an alert but no user impact.
- Recovery: worst case is 30s of double-counted or missing minute-window entries — the 1-day rollup catches up cleanly.
Failure 3: Redis for real-time counts is down.
- Propagation: dashboard "clicks in the last minute" widget fails to load.
- Mitigation: dashboard shows a "temporarily unavailable" message. Historical widget still works from MySQL.
- Recovery: Redis Sentinel promotes a replica in <15s. Real-time counter is behind by ~1 min after failover; Flink catches up.
Failure 4: MySQL historical DB is down.
- Propagation: historical dashboards fail.
- Mitigation: dashboard shows "temporarily unavailable" for historical. Real-time still works from Redis.
- Recovery: MySQL Multi-AZ promotes standby in 30-90s.
Step 7 — the L4 → L7 evolution
| Scale | Analytics architecture |
|---|---|
| 10K RPS (L4) | No Kafka. Increment a MySQL counter directly on redirect (UPDATE clicks SET count = count + 1 WHERE short_code = $1). Simple, correct, slow at scale but fine here. |
| 100K RPS (L5) | Redis-based real-time counter. INCR clicks:aB3xY9 on redirect. Async job replicates to MySQL daily. Kafka is still overkill at 100K RPS. |
| 1M RPS (L6) | Kafka + Flink + Redis + MySQL — the full pipeline described above. Necessary to keep the redirect path unencumbered. |
| 1B RPS (L7) | Add regional Kafka clusters (regional locality for latency), CDN log ingestion (Cloudflare/Fastly stream you the edge logs — many redirects never even hit your origin), and tiered storage (hot data in Redis, warm in ClickHouse, cold in S3+Parquet). |
The evolution visualized side by side — what actually gets ADDED at each scale
The table above tells you what to build. Here are the per-tier Mermaid flowcharts so you can pattern-match on the interviewer's stated RPS.
WHY EACH TIER BREAKS AT THE NEXT SCALE:
- L4 → L5 boundary: MySQL UPDATE clicks... blocks the redirect path. At 10K concurrent redirects, 10K UPDATE statements queue behind row locks on the SAME row (hot short_code). Redirect p99: 200ms → user complains. Fix: async Redis INCR.
- L5 → L6 boundary: Redis INCR is fine, but you cannot store 10-year history in Redis (memory cost). The nightly ETL job to MySQL takes 4 hours — during that window analytics is 4h stale. At L6 with billion-events/day the ETL takes 40 hours (never converges). Fix: Kafka + Flink streaming ETL.
- L6 → L7 boundary: Single-region Kafka cluster becomes the network egress bottleneck (~5 Gb/s per broker limit). Cross-region traffic costs $0.02/GB via cloud egress = $30K/mo just to move click events. Fix: regional Kafka + CDN edge logs (Cloudflare pushes logs from the EDGE, not through origin).
COST ANCHOR PER TIER: L4: $0/mo · L5: $50/mo · L6: $1,600/mo · L7: $50K/mo
Read the four tiers in order. Each one adds exactly one new primitive to the previous — L4→L5 adds Redis (because in-DB UPDATE dies at row-lock contention). L5→L6 adds Kafka+Flink (because Redis is memory-bound and nightly ETL never converges at billion events/day). L6→L7 adds regional Kafka + CDN log push + tiered storage (because single-region egress cost dominates and you can offload 95% of events to CDN edge logs). The colored tint per tier maps to the level convention (blue=L4, green=L5, indigo=L6, pink=L7) used across the journey.
Newbie mentor commentary — what to internalize:
- Each column is a defensible answer at its own scale. The L4 architecture is NOT wrong — it is correct FOR L4. The mistake engineers make is proposing L6 for an L4 problem (over-build) or L4 for an L6 problem (under-build). Match the tier to the traffic.
- The dangerous shared-MySQL pattern in L4 and L5. Notice both L4 and L5 write analytics to the same MySQL cluster that serves URL storage. This is fine at low scale, becomes a foot-gun at ~50K RPS when analytics writes start slowing URL lookups. The failure signal: URL-lookup p99 starts spiking during analytics-heavy hours. The fix that unlocks L6: move analytics to its own MySQL cluster (or Kafka), not just a bigger box.
- L6 is the tier where fire-and-forget becomes MANDATORY. At L6, doing a Kafka send synchronously in the redirect handler would add ~5ms tail latency because network calls are variable. Async fire-and-forget with in-process buffering is the discipline that keeps redirect p99 under 5ms even at 1M RPS.
- CDN log streaming at L7 is the crown jewel. Cloudflare Enterprise Logpush and Fastly Log Streaming send you the click events directly from the edge — you never process the redirect on your origin, and you get the analytics for free. This is how Bit.ly, TinyURL, and every billion-click shortener actually operates. The cost saving: 95% of your click events never traverse your origin infra.
- The visualization reads left-to-right as a story of what breaks. L4 works until concurrency on the counter row explodes. L5 works until history exceeds Redis memory. L6 works until network egress saturates. L7 works until — well, at 1B RPS you are one of the top 20 web services on earth and your problems are strategic, not architectural (per Ch 8).
Interview soundbite for scale evolution: "At each analytics scale I add ONE primitive that solves the specific bottleneck the previous tier hits. L4 is in-DB UPDATE — dies from row-lock contention. L5 adds Redis INCR — dies when history exceeds Redis memory. L6 adds Kafka+Flink streaming ETL with fire-and-forget — dies when cross-region egress cost dominates. L7 adds regional Kafka + CDN log ingestion — cost becomes a strategy question, not a tech one. Every 10× in traffic changes exactly ONE primitive, not the whole pipeline."
Interview soundbite
"At 1M RPS I use Kafka partitioned by short_code (64 partitions, RF=3), Flink with 1-min and 1-day tumbling windows, Redis for the real-time dashboard, and MySQL for historical. I explicitly do fire-and-forget from the redirect path so analytics never blocks the user. Exactly-once semantics via Flink checkpointing means we don't over-bill customers on click counts. Total cost is $1,600/mo — $5 per billion clicks. At 10K RPS none of this exists; we just UPDATE a MySQL counter. The complexity is proportional to the load, not a lift-and-shift Kafka install."References (10 items)
- Kafka documentation (kafka.apache.org/documentation): partition sizing, replication factor, retention.
- Kreps et al. (2011) — "Kafka: A Distributed Messaging System for Log Processing," NetDB '11. notes.stephenholiday.com/Kafka.pdf.
- Carbone et al. (2015) — "Apache Flink: Stream and Batch Processing in a Single Engine," IEEE Data Eng. Bulletin 38(4). sites.computer.org/debull/A15dec/p28.pdf.
- Kleppmann, Martin — "Turning the Database Inside-Out with Apache Samza" (2015): martin.kleppmann.com/2015/03/04/turning-the-database-inside-out.html. The mental model for stream processing that inspired Flink and Kafka Streams.
- Google SRE book — Chapter 3, "Embracing Risk": sre.google/sre-book/embracing-risk. Explicit guidance on non-blocking auxiliary services.
- Beyer et al. (2016) — Site Reliability Engineering (O'Reilly). The SRE book proper — chapters on SLI/SLO and error budgets that justify the "never block redirect on analytics" rule.
- Confluent Blog (blog.confluent.io) — production sizing and best practices for Kafka clusters at multi-region scale.
- Uber Engineering Blog (eng.uber.com) — search "kappa architecture" and "AthenaX." Their post explains the exact Flink-based aggregation pattern used above at Uber's ~10× scale.
- RFC 3339 — Date and Time on the Internet (2002): rfc-editor.org/rfc/rfc3339. For timestamp normalization on the wire.
- GDPR — Regulation (EU) 2016/679, Article 4 (definition of "personal data"): gdpr-info.eu/art-4-gdpr. Justifies hashing IP before storing (raw IP is PII in the EU).
Now — I'll say what every senior architect says: understand this, and you understand analytics at 90% of tech companies. The pattern (event source → Kafka → stream processor → dual sinks for real-time + historical) is universal. You just built it for URL Shortener; you can now build it for Twitter timeline, Uber trip events, Netflix viewing sessions, or any high-throughput event source. That's transfer of judgment — the goal of this whole platform.
Analytics = Kafka partitioned by short_code (64 partitions, RF=3) + Flink dual windows (1-min → Redis for real-time, 1-day → MySQL for historical) + fire-and-forget from redirect path. Exactly-once via Flink checkpointing. $1,600/mo at 1M RPS = $5 per billion clicks. Evolution: SQL counter (L4) → Redis INCR (L5) → full Kafka+Flink pipeline (L6) → CDN log ingestion + tiered storage (L7).
- Why fire-and-forget from the redirect path?
- How do you size Kafka partitions for 1M RPS?
- Why Flink over Kafka Streams for exactly-once billing?
- What are the 4 failure modes of the analytics pipeline?
- How does analytics architecture evolve 10K → 1B RPS?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The redirect must never block on analytics. This concept is the discipline behind that principle — read this to defend fire-and-forget in an interview.
Analytics can be eventually consistent — but reads by end users cannot. Know the boundary and why exactly-once (Flink) vs at-least-once (Kafka Streams) is the trade-off.
Kafka trades latency for throughput — the exact trade-off in one sentence. Why batching writes to Kafka reduces P50 latency by amortizing network cost.
Chapter 7.6: the security story. We've built the redirect, the cache, the sharding, and the analytics. But we haven't talked about the users we don't want — spammers shortening malware URLs, phishing campaigns hiding behind [sysd.link](https://sysd.link), DDoS attacks against our redirect endpoint. Security is the L6 story that separates well-designed products from ones that make the news for the wrong reasons.
Security and abuse at scale
Malicious URLs, phishing, DDoS, GDPR — the L6 defense story
Every URL shortener eventually gets abused. If yours isn't, it means you have no users. Bit.ly hosts millions of clicks per day to malicious URLs; they built a security org to combat it. TinyURL was so overrun by spam in the 2010s that Twitter, Reddit, and Facebook banned it outright. Google's goo.gl was shut down in 2020 partly because they couldn't keep up with abuse.
This chapter is the one every tutorial skips. Let's not.
## Threat model (the actual threats)
T1 — Phishing. Attacker shortens https://faceb00k-[login.evil.com/steal-your-password](https://login.evil.com/steal-your-password). Victim sees sysd.link/promotion and trusts it. Attacker steals credentials.
T2 — Malware distribution. Attacker shortens https://malware-[drop.com/ransomware.exe](https://drop.com/ransomware.exe). Victim clicks, downloads.
T3 — SEO manipulation. Attacker mass-creates shortened URLs pointing at their competitor's site with negative keywords, gaming search engines.
T4 — Rate-limit abuse. Attacker scrapes your API to enumerate all short_codes (see Chapter 4.5 on why counter-based codes leak this).
T5 — DDoS on the redirect endpoint. Attacker floods sysd.link/* with random 6-char codes, causing 404s that exhaust your DB.
T6 — Data exfiltration via short_code URLs. Attacker creates sysd.link/x = https://public-[log.evil.com/collect?data](https://log.evil.com/collect?data)=<internal-secret>. Employee shares internally, silently leaking data.
T7 — GDPR / CCPA violations. You store the clicker's IP address raw; regulator fines you €20M under GDPR Article 4.
The 5-layer defense-in-depth, visualized
Every request travels through 5 layers of defense. If layer N fails or misses, layer N+1 catches it. Attackers must defeat ALL 5 to hurt you. Try the interactive attack simulation below — toggle any layer off to see the survivor count spike:
Watch 10,000 attacks funnel through 5 layers. Toggle layers off to see what breaks.
An attacker must defeat ALL FIVE to reach the outcome they want. Turn a layer off to see the survivor count spike.
Layer 5 · DDoS mitigation at the edge
Layer 4 · WAF + hierarchical rate limits
Layer 3 · Create-time malicious URL detection
Layer 2 · Post-facto abuse scanning + user reports
Layer 1 · Human security team + compliance
An attacker breaking ANY ONE layer fails. They must defeat ALL FIVE to reach the outcome they want. Each layer catches a different attack shape and has a different cost profile. Cheap layers (5, 4) handle volume; expensive layers (2, 1) catch persistent adversaries.
Newbie insight: each layer catches a different attack shape and has a different cost profile. The cheap layers (5, 4) handle the volume attacks. The expensive layers (2, 1) catch the persistent adversaries. You cannot skip layers — dropping L5 exposes L4 to attacks it wasn't designed to handle; dropping L1 means zero-day URLs live for days before takedown. The layered defense IS the security posture.
Defense layer 1 — malicious URL detection at CREATE time
Every POST /v1/urls should check the long_url against a threat intelligence feed BEFORE storing it:
pythondef create_short_url(long_url): # Step 1: URL syntax validation (Chapter 3.5) if not is_valid_http_https(long_url): return 400 if len(long_url) > 2048: return 400 # Step 2: Real-time threat intelligence if google_safe_browsing.check(long_url) == "MALICIOUS": log_abuse(long_url, "google_safe_browsing_hit") return 403 # do not shorten if virustotal.check(long_url).positives > 5: log_abuse(long_url, "virustotal_multi_engine_hit") return 403 # Step 3: Domain reputation (in-house denylist) if url_parse(long_url).host in domain_denylist: return 403 # Step 4: Rate-limit the creator (Chapter 3.5 rate limiting) if rate_limiter.exceeded(creator_id_or_ip): return 429 # Step 5: Store + return return store_url(long_url)
Google Safe Browsing API (developers.google.com/safe-browsing) — free for reasonable use, returns MALWARE / SOCIAL_ENGINEERING / UNWANTED_SOFTWARE / POTENTIALLY_HARMFUL_APPLICATION classifications. Cached at Google. Latency: <50ms per call.
VirusTotal API (virustotal.com/api/v3) — commercial, aggregates 70+ engine verdicts. Rate-limited on free tier; enterprise plan for production.
In-house denylist — a Redis SET populated by (a) user abuse reports, (b) integrations with tools like PhishTank, (c) manual security-team review.
Cost at scale: Google Safe Browsing free tier is 10K queries/day. At 100M creates/day (1M RPS × 1% write ratio × 86400 sec / 1M scale) you need the commercial tier ~$200/mo. Or use Chrome's Safe Browsing SDK (bulk-download the hash list) — free but adds latency to your create path.
Defense layer 2 — post-facto abuse detection on stored URLs
You can't rely on create-time checks alone. Malicious URLs get created BEFORE they show up on Google's Safe Browsing list. So:
Background scanner: every hour, take the top-1000 clicked URLs in the last hour, re-check against threat feeds. If any are now flagged as malicious, disable the short_code (return 404 or a "this URL was disabled for abuse" page). Notify security-team + the creator via email.
User-reported abuse: POST /v1/report endpoint on the redirect page (visible when the redirect page shows an interstitial). Once N reports for the same short_code (e.g., N=3), auto-disable and route to security-team review.
Interstitial for suspicious URLs: if a URL has 1-2 reports but not enough to auto-disable, show an interstitial page ("Warning: this URL may be unsafe. Continue?"). Twitter's t.co does this. Bit.ly does this. It's a UX cost but a security win.
Defense layer 3 — rate limiting at every tier
Chapter 3.5 covered the 429 semantics. Here's the actual layered rate-limit design at L6:
| Tier | Limit | Enforced at | Backing store |
|---|---|---|---|
| Anonymous (per IP) | 60 create/hour, 1000 redirect/hour | Nginx / CloudFront edge | Redis token bucket per IP |
| API key (per key) | 1000 create/hour, 100K redirect/hour | Application layer | Redis token bucket per key |
| OAuth user (per user_id) | 5000 create/hour, unlimited redirect | Application layer | Redis token bucket per user_id |
| Global (per short_code) | 10K redirect/sec per code | Application layer + CDN | CDN cache-status header |
| DDoS (per source) | anomaly detection (100× baseline) | Cloudflare / AWS Shield | Behavioral model |
The critical L6 insight: rate-limiting is HIERARCHICAL. An attacker gets 429 at the edge (Cloudflare) for IP abuse; if they use 1000 IPs to spread the attack, they get 429 at the application (per API key or per user); if they distribute across 10K anonymous IPs, they hit the global per-short_code limit; if none of that works, the DDoS mitigation kicks in.
Distributed rate limiting math: Redis token bucket at 100K RPS is fine on a single Redis node. At 1M RPS you need Redis Cluster with hash-slot routing OR a multi-shard token-bucket algorithm like Cloudflare's approximate counting (blog.cloudflare.com/how-we-built-rate-limiting-capable-of-scaling-to-millions-of-domains). Trade accuracy for scale.
Defense layer 4 — DDoS mitigation
Layer 3/4 (network) attacks (SYN flood, UDP amplification, IP spoofing):
- AWS Shield Standard (free with any AWS account) handles most volumetric attacks
- Cloudflare Free/Pro handles up to 100 Gbps attacks at the edge for free
- AWS Shield Advanced ($3000/mo) handles nation-state-scale attacks with 24/7 DDoS response team
Layer 7 (application) attacks (HTTP flood, slowloris, Layer 7 amplification):
- WAF rules (AWS WAF, Cloudflare WAF): rate limit per IP, block known bot user-agents, geo-block if you don't serve those regions
- CAPTCHA challenge for suspicious traffic patterns (Cloudflare Turnstile is the modern free option)
- Rate limit per short_code: if 10K RPS hits sysd.link/aB3xY9, it's either viral or an attack. Serve from cache aggressively (which happens naturally with our tiered cache), and rate-limit at the application tier if origin sees anomalous traffic.
Defense layer 5 — GDPR / privacy compliance
You collect IPs (for geoip → country attribution). You collect timestamps. You collect referer URLs. You collect user agents. All of this is personal data under GDPR Article 4.
The specific requirements:
- Never store raw IPs. Hash them with a per-day rotating salt:
hash(ip + daily_salt). This gives you day-level analytics without long-term linkability. - Consent for tracking cookies (GDPR Article 7 + ePrivacy Directive). If your redirect page sets any cookie for tracking, you need a consent banner or you can't set it.
- Data retention limits (GDPR Article 5(1)(e)). Raw click events must have a retention policy — typically 13 months for analytics, then aggregated only.
- Right to erasure (GDPR Article 17). Users must be able to request deletion of their data. This means your MySQL schema needs a
user_idcolumn that can be nulled/anonymized on request. - Data Processing Agreement with your CDN and any third-party analytics (Cloudflare, Google Analytics). Sign it, keep it, produce on regulator request.
Cost of GDPR non-compliance: up to €20M or 4% of global annual revenue, whichever is higher (GDPR Article 83). Meta paid €1.2B in 2023 for one violation. Take this seriously.
Failure modes
Malicious URL slipped through create-time check. Post-facto scanner catches it within the hour. Security team gets alerted. Auto-disable + notification.
Rate limiter itself DDoS'd. Redis token bucket is the bottleneck — if Redis is overwhelmed, rate limiting fails open (better than failing closed and blocking legitimate users). Fallback to Nginx-level static rate limits per IP.
GDPR audit finds we stored raw IPs for 3 months. Retention policy violation. Fix: change to hashed IPs, delete or hash-in-place all historical rows, document the remediation. Notify DPA per Article 33 (breach notification).
Nation-state-scale DDoS. AWS Shield Advanced + Cloudflare Enterprise + on-call SREs. Real cost: $10K/mo on retainer. The incident itself is 4-8 hours of degraded service for other users, then mitigation kicks in.
Evolution across scales
| Scale | Security posture |
|---|---|
| 10K RPS (L4) | Basic: HTTP validation, URL scheme check (no javascript:/data:), Google Safe Browsing free tier, simple IP rate limit. |
| 100K RPS (L5) | Add API key auth, in-house denylist, per-key rate limits. Basic WAF rules (block obvious bots). |
| 1M RPS (L6) | Full stack from above: hierarchical rate limits, DDoS mitigation via Cloudflare/AWS Shield, post-facto abuse scanning, interstitial for suspicious URLs, GDPR compliance in code (hashed IPs, retention policy, DSR endpoint). |
| 1B RPS (L7) | Nation-state-scale DDoS defense (Shield Advanced retainer), security engineering team of 5+ people, machine-learning abuse detection (features: URL structure, creator history, IP reputation, geographic patterns), compliance across GDPR + CCPA + PIPL + LGPD + Australia Privacy Act. |
Interview soundbite
"Security at L6 is a layered defense: (1) malicious URL detection at create-time via Google Safe Browsing + VirusTotal + in-house denylist, (2) post-facto scanning of top-clicked URLs, (3) hierarchical rate limiting from CDN edge → app layer → per-short_code with Redis token buckets, (4) DDoS mitigation via Cloudflare/AWS Shield, (5) GDPR compliance built in (hashed IPs, retention policy, right-to-erasure endpoint). Cost is ~$500/mo at L6, plus a security engineer's time proportional to abuse volume. At L7 you add ML-based abuse detection and a nation-state-scale DDoS retainer."
References (10 items)
- Google Safe Browsing API v4: developers.google.com/safe-browsing/v4. Real-time URL threat classification.
- VirusTotal API v3: virustotal.com/en/documentation/public-api. Multi-engine malware detection.
- OWASP Top 10 (2021): owasp.org/Top10. A01 Broken Access Control (rate limit bypass), A03 Injection (URL validation), A09 Security Logging Failures (abuse audit trail).
- PhishTank: phishtank.org — community-driven phishing URL denylist.
- Cloudflare Rate Limiting Engineering Blog: blog.cloudflare.com/how-we-built-rate-limiting-capable-of-scaling-to-millions-of-domains. The definitive distributed-rate-limiting engineering post.
- AWS Shield documentation: docs.aws.amazon.com/waf/latest/developerguide/shield-chapter.html. DDoS mitigation architecture.
- Cloudflare Turnstile: blog.cloudflare.com/turnstile-private-captcha-alternative. Modern CAPTCHA-alternative.
- GDPR — Regulation (EU) 2016/679: gdpr-info.eu. Full regulatory text. Article 4 (definitions), Article 5 (principles), Article 17 (right to erasure), Article 33 (breach notification), Article 83 (fines).
- CCPA — California Consumer Privacy Act (2018): leginfo.legislature.ca.gov (search CCPA). US analog to GDPR.
- RFC 6265 — HTTP Cookies (2011): rfc-editor.org/rfc/rfc6265. Cookie semantics including `Secure
- Twitter's t.co security architecture — engineering.twitter.com/tech-blog (search "t.co" and "url shortener"). The reference implementation for URL-shortener security at scale.
- Bit.ly Security Blog — word.bitly.com (search "security"). Real production case studies of abuse patterns.
- Kleppmann DDIA Chapter 8 (The Trouble with Distributed Systems) — how rate limiters fail in partition scenarios.
Now the real senior-architect insight: security is not a feature, it's a product surface. Users pay Bit.ly $29/mo per user for reliable URL shortening AND for the promise that when they share [bit.ly/xyz](https://bit.ly/xyz) they won't accidentally send their audience to malware. If you don't run this defense stack, you don't have a product — you have a spam-multiplier waiting to be blocked by every social network on Earth. Take this chapter as seriously as the redirect path itself.
Security = 5-layer defense: (1) create-time malicious URL check (Google Safe Browsing + VirusTotal + denylist), (2) post-facto scanner + user reports + interstitials, (3) hierarchical rate limiting (edge → app → per-short_code), (4) DDoS via CDN + Shield, (5) GDPR compliance (hashed IPs, retention, DSR). Evolves 10K RPS (basic scheme validation + IP limits) → 1B RPS (ML abuse detection + nation-state DDoS retainer). €20M fines are real; not optional.
- What are the 7 threats to a URL shortener?
- How do you detect malicious URLs at create time?
- What's a hierarchical rate limit and why?
- How does DDoS mitigation stack Layer 3/4 vs Layer 7?
- What are the specific GDPR requirements for click analytics?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
Why HTTPS is non-negotiable for a URL shortener — the exact browser + intermediary threats HTTPS defends against.
TLS 1.3 handshake + certificate rotation — deep-dive on the cryptography behind the HTTPS badge. Why cert pinning + HSTS matter.
Idempotency + rate limiting — the two mechanisms that defend against automation abuse. Rate limits are enforced at the L7 LB where header inspection is possible.
Where DDoS mitigation happens depends on layer — L3/L4 (SYN floods) vs L7 (application floods). Which one your ALB defends is critical.
Chapter 8: L7. This isn't about servers anymore. This is about *business*, *organization*, and *strategy*. When you're at 1B RPS, technology is easy — you have the resources for anything. The hard problems are: which markets do we enter? Which customers do we NOT serve? How do we structure a 200-engineer team around this? This is where Staff+ engineering meets executive leadership.
The security deep dive
18 attacks · 18 defenses · animated · every mechanic explained
The previous chapter gave you the overview — 7 named threats, 5 defense layers, GDPR in one sentence. Enough to answer "what's the security posture at L6?" in an interview.
But that's the outline of security. This chapter is the mechanics. Every attack visualized. Every defense visualized. Every trade-off named. Because when the interviewer asks the follow-up — "walk me through how SSRF against your link-preview service would work, and why your egress-VPC design defeats it" — the outline won't save you. Only mechanic-level understanding will.
I've watched hundreds of Staff+ interviews. The pattern is universal: *candidates who lose points on security don't lose them on knowing the vocab. They lose them on not being able to demonstrate the attack mechanically.* "SSRF" is a word. "The attacker sets the target URL to http://169.254.169.254/latest/meta-data/iam/security-credentials/ — which is the EC2 IMDS endpoint your VPC has route access to — and your unhardened preview fetcher dumps your IAM role's temporary credentials back in the og:image scrape response" — that's mechanical fluency. This chapter is the drill.
How to use this chapter
Play with every animation. Every attack starts in 🎣 Attack mode — see how the adversary thinks. Then flip to 🛡️ Defense mode — see where each layer intercepts. Every attack has a real-world case citation — those aren't made up. Those are CVEs, incidents, and bug bounty reports. Read them if you want to go even deeper.
Then at the end, there's a threat-modeling exercise. Do it. Not just read it. That's the difference between someone who knows security and someone who can do security in a whiteboard interview.
The threat landscape
Before we dive into any single attack, understand the whole surface. There are 18 distinct attack categories against a URL shortener. Click each one to explore its attacks:
The URL Shortener Threat Landscape
18 attacks across 5 categories. Click a category to explore, then an attack for the defense TL;DR.
Newbie insight: notice how the categories aren't just "attacks on X system." They're organized by the type of harm the attacker seeks: (1) delivering bad content, (2) reading data they shouldn't, (3) exhausting resources, (4) hijacking our infrastructure, (5) triggering regulatory pain. This is how professional threat-modelers think. The framework is called STRIDE in the Microsoft SDL vocabulary (Spoofing / Tampering / Repudiation / Information disclosure / Denial of service / Elevation of privilege) — our 5 categories map to it directly. Learn to think in these buckets and you can enumerate attacks against ANY system, not just URL shorteners.
PART 1 — CONTENT THREATS
What does the short URL actually point at? This is the classic threat class — the one your product manager thinks of when they hear "security."
1.1 · Phishing via URL cloaking (the #1 abuse)
This is why URL shorteners exist as a problem for the security industry. A URL shortener is, by design, a URL disguise machine. That's the product. That's also the attack surface.
Play the attack, then flip to defense mode:
Attack: Phishing via URL cloaking
The #1 abuse vector for URL shorteners. See attack mechanics → then how defenses catch it.
Attacker buys secure-yourbank-login.co for $12/year. GoDaddy doesn't flag it. WHOIS privacy hides identity.
The economics that make this attack unstoppable. A phishing domain costs $12/year. A crafted email costs $0. Your defense stack costs ~$500/mo at L6 plus a security engineer at $250K/year all-in. Attacker asymmetry is 20,000×. You can never eliminate the attack — you can only make it costly enough that attackers move to easier targets. That's the game.
Why interstitials work (and their cost). Twitter t.co's interstitial reduces click-through on suspicious URLs by 42% (2019 t.co engineering blog). Bit.ly's does ~38%. Feels good but costs ~5% of ALL clicks (including legitimate) — users click "continue" without reading, or bounce because they don't recognize the warning. That's the trade-off: safer users vs conversion loss for legitimate URLs. Interview answer: "We only interstitial when trust score is below threshold T, not on every URL. T is tuned quarterly by the Trust & Safety team based on abuse metrics."
The three-tier reputation model. Bit.ly, TinyURL, and t.co all use variants of this:
| Reputation tier | Signal | Action on redirect |
|---|---|---|
| Trusted (verified brand + long history) | Custom domain, >100K clicks, >6 mo history, zero abuse reports | Direct 302 redirect, no interstitial |
| Neutral (default) | Standard sign-up, <100K clicks, <6 mo history | Direct 302 redirect, background scan |
| Suspicious (any signal) | New domain (<7d), abuse reports, low-trust ASN, homograph match, brand fuzzy hit | Interstitial with warning + "Report" button |
| Blocked (confirmed malicious) | Google Safe Browsing hit, VirusTotal ≥5 engines, 3+ user reports, ML classifier ≥0.95 | 410 Gone + "This URL was disabled" page |
1.2 · Malware distribution — the .exe drive-by
Same wrapper as phishing, different payload. Attacker points short URL at https://cdn.[malware-drop.io/setup.exe](https://malware-drop.io/setup.exe) or a landing page that triggers a drive-by-download exploit against unpatched browsers.
Why this is harder than phishing to detect. Phishing lives on a landing page — the login form is visible in the DOM, and DOM-diff against known brands catches it. Malware distribution is often directly downloadable content — no landing page, just Content-Type: application/x-msdownload. Your scanner needs to:
1. Follow the target URL (30-second timeout)
2. Inspect Content-Type and Content-Disposition headers
3. Hash the payload (first 8KB is enough for most binary fingerprinting)
4. Cross-check hash against VirusTotal / Chrome Safe Browsing hash database
Edge cases attackers exploit:
- User-Agent gating — target serves harmless content to your scanner's user-agent but malware to real browsers. Defense: use rotating residential-proxy scanners with real Chrome UA strings.
- Referer gating — target requires Referer: [sysd.link](https://sysd.link) to serve the payload. Defense: your scanner sends the correct referer just like a real click would.
- Cookie / login gating — target serves malware only to logged-in users. Defense: escalate to human review; store hash for later.
- Time-of-day gating — malware only served during business hours in target time zone. Defense: continuous re-scan every 6 hours for high-risk destinations.
1.3 · TOCTOU — scan safe, then swap the target
Attack: TOCTOU (scan-then-swap)
The fundamental URL scanner problem: safe when checked, malicious when used.
Time-of-check to time-of-use is the fundamental problem with any URL scanner. You scanned the target at T=0 and marked it safe. At T=1 hour, the attacker changed what the URL points at.
Three ways attackers exploit TOCTOU:
- DNS re-pointing — attacker's domain
benign.cominitially points at Cloudflare-hosted static content (safe). Attacker updates DNS to point at their phishing IP. Same URL, new destination. - Server-side redirect swap — target URL is
benign.com/entry.phpwhich returns a 302. Attacker updates their PHP to redirect toevil.cominstead of the original benign destination. - Content mutation — target URL is a benign article. Attacker edits the article's HTML to include a malicious iframe or JavaScript payload.
Defense: continuous re-scanning + content pinning.
- Continuous re-scan. Top-1000 URLs by click volume get re-scanned every hour. Top-100K get re-scanned daily.
- Content pinning for high-risk destinations. If the target is a login page, financial site, or file download, we pin the SHA-256 of the initial content hash. If the hash changes, we auto-disable pending human review.
- DNS TTL monitoring. If the target's DNS TTL is <300 seconds, we treat it as suspicious (legitimate sites use 3600+ TTL; short TTL is a fast-flux attacker signal).
The interview answer: "TOCTOU is why security is a continuous verification problem, not a one-time check. We treat every URL as expired after 24 hours unless re-verified; hot URLs are re-verified hourly. This trades ~$2K/mo in scanner cost against zero-day attack blast radius."
1.4 · Homograph / IDN / RTLO attacks
Homograph attack — the invisible Cyrillic
These two aliases look identical. One is malicious. Hover to reveal each character's Unicode script.
Unicode is a phishing weapon. Consider these two custom aliases:
sysd.link/microsoft-support— Latin M-I-C-R-O-S-O-F-Tsysd.link/microsоft-support— Cyrillic о at position 5
Visually indistinguishable in nearly every font. Only revealed by hex-dumping the URL or hovering to reveal the underlying encoding.
The three unicode weapons:
- Homograph substitution — swap Latin characters with visually identical Cyrillic, Greek, or Armenian ones (а/а, о/о, е/е, р/р).
- RTLO (Right-to-Left Override, U+202E) — a single Unicode control character that reverses the rendering direction of subsequent text.
sysd.link/file.gpjU+202Eexerenders assysd.link/fileexe.jpg— user thinks it's an image, it's actually an executable. - Invisible characters — U+200B (zero-width space), U+FEFF (BOM), U+2062 (invisible times). Insert between letters of a reserved brand name to bypass exact-match denylists.
Defense: aggressive normalization + mixed-script rejection.
- NFKC normalization on every custom alias input. This canonicalizes visually-equivalent characters.
- Mixed-script detection. If an alias contains characters from more than one Unicode script (Latin + Cyrillic, for example), reject it. Bit.ly does this since 2019.
- Punycode reveal on redirect. Even if a target URL uses IDN (e.g., http://аррӏе.com), display the punycode form (http://xn-[-80ak6aa92e.com](https://-80ak6aa92e.com)) on the interstitial page.
- Reserved-alias fuzzy match. Maintain a list of protected brand terms; use Levenshtein + phonetic matching to reject any alias within edit-distance 2 of a protected term.
Real-world cost: IDN attacks against Chrome (CVE-2017-5060) forced a URL-bar-only display fix; every browser vendor now punycodes suspicious IDN domains automatically. But your redirect page runs in your control — that's YOUR responsibility.
PART 2 — ENUMERATION & PRIVACY THREATS
Attacker learns things they shouldn't. This is the class of attack that gets you sued, fined, or on a news cycle — even without any direct "hack."
2.1 · Sequential ID enumeration
Attack: Sequential enumeration
Counter-based short_codes leak neighbors. Attacker walks 0, 1, 2, ...
If your short_codes are generated from an auto-incrementing integer (or a lightly-scrambled version like base62-of-counter), sequential enumeration is trivial:
```
sysd.link/aB0 → 302 → target A
sysd.link/aB1 → 302 → target B
sysd.link/aB2 → 302 → target C
...
sysd.link/aB9 → 302 → target J
```
An attacker can enumerate ALL your URLs in ~24 hours at 400 RPS (well under any rate limit). What they find:
- Private password-reset URLs your users shortened
- Enterprise URLs pointing at internal wikis
- Personal Google Drive / Dropbox links
- Slack shared file URLs (which are unauthenticated once you have the link)
Real incident: in 2016 a researcher enumerated 100M+ Bit.ly URLs by walking sequential base62 codes. Found: 40K+ Google Drive links (many still working), personal photo albums, corporate Confluence pages, credential-reset emails. Bit.ly changed to random 7-char codes shortly after.
Defense stack:
- Random 62^7 codes (Chapter 4.5 decision) — 3.5 trillion address space, guessing rate = 1 in a trillion per attempt. Rate limit + 3.5T space = enumeration is economically infeasible.
- Per-code auth for private URLs. If a user marks a URL as "private," require an auth token in the redirect URL:
sysd.link/aB3xY9?t=hmac_signature. Sharing loses the token loses access. - Rate limit lookups aggressively. A single IP hitting >100 unique
GET /aB*per hour is bot-like. Serve 429 + trigger IP reputation downgrade. - Canary URLs. Register
sysd.link/aB1,sysd.link/aC2, etc. as decoy short_codes that don't exist. If an IP hits >5 decoys, they're enumerating. Auto-blocklist.
2.2 · Referer header leakage
Referer header leak — pick a policy, watch the leak
User shortens a password-reset URL. Click "Simulate click" and watch what leaks to the target site.
clicks
(302 redirect)
+ analytics
When a user clicks your short URL and lands on the target site, the target's server receives a Referer header. That referer contains the original short URL — including any query parameters.
The attack scenario: a password-reset flow generates [https://yourbank.com/reset?token=SECRET123](https://yourbank.com/reset?token=SECRET123). User shortens it via sysd.link for convenience. Now it's sysd.link/reset-my-pw?token=SECRET123. User clicks. Target bank's server logs the Referer: [sysd.link/reset-my-pw?token=SECRET123](https://sysd.link/reset-my-pw?token=SECRET123) header. Any third-party analytics / logging / CDN service the bank uses gets the reset token.
Real incident: in 2013, LinkedIn's password reset tokens leaked to Facebook Analytics via Referer header. LinkedIn had to rotate all pending reset tokens; forced 100M+ users to redo password reset. The URL was NOT shortened, but the mechanic is identical.
Defense stack:
- `Referrer-Policy: no-referrer` on the redirect page. Your 302 response has zero Referer info leaked to the target site. Cost: some analytics platforms rely on referer for attribution.
- Strip query params from Referer.
Referrer-Policy: strict-originsends only the origin (https://sysd.link) and not the full URL. Compromise between analytics + privacy. - Never redirect password reset / magic links. Detect password-reset URL patterns during create (
?token=,?reset=,?magic=) and refuse to shorten. Educate users. - Expire query-param URLs faster. URLs with query params get 7-day TTL; URLs without get 5-year TTL. Sensitive data ages out fast.
2.3 · Custom alias brand-jacking
Attack: Brand-jacking race
Vanity URL `sysd.link/microsoft-support` — who wins the race?
Vanity URLs are a great enterprise feature. They're also a brand-hijacking attack vector. First person to claim sysd.link/microsoft-support gets it — even if that person is a phishing gang.
The mechanic:
1. Attacker monitors trending brands (Netflix launches new service, Apple releases event).
2. Attacker registers sysd.link/apple-event-2026 within minutes of the announcement.
3. Attacker points it at their phishing infrastructure.
4. Legitimate Apple marketing gets stuck with sysd.link/apple-event-2026-official — worse SEO, worse trust.
5. Confused customers click the attacker's URL because it's "cleaner."
Defense stack:
- Reserved-alias list. Maintain a database of protected brand terms — Fortune 5000 companies, government agencies, top NGOs. Reject any alias matching or fuzzy-matching a reserved term unless the requester has verified ownership.
- DNS TXT verification for branded aliases. Enterprise tier: to claim
sysd.link/microsoft-*, add a DNS TXT record on_sysd.[microsoft.com](https://microsoft.com)proving domain ownership. - Delete-on-conflict for verified brands. If Microsoft joins the enterprise tier AFTER attacker registered
sysd.link/microsoft-support, Trust & Safety has authority to reassign the alias to Microsoft. Original registrant gets asysd.link/{random}fallback. - Trademark law integration. UDRP (Uniform Domain-Name Dispute-Resolution) applies to URL shorteners in some jurisdictions. Consult counsel.
2.4 · Analytics dashboard privacy leak
Every URL shortener wants public analytics pages — "share your link performance" is a growth loop. But default-public analytics leak enterprise data.
Real incident: in 2019, Bit.ly's public + endpoint showed click count + geographic breakdown for ANY URL without auth. Journalists used this to reverse-engineer several corporate communications strategies (Uber's 2019 IPO messaging, WeWork's crash-era press releases). Multiple Fortune 500 companies filed complaints.
Defense stack:
- Default to private. Analytics visible only to the URL creator + explicitly shared collaborators. Opt-in checkbox to make public at create time.
- k-anonymity threshold. Even for public analytics, don't display geographic breakdown until N ≥ 100 clicks (below that, individual clicks are re-identifiable in low-population regions).
- Time-delay for high-precision data. Real-time click counts leak breaking-news attention data; delay by 24h to reduce competitive intelligence value.
- Owner-only referer/UA data. Referer + user-agent breakdowns visible only to the URL owner, never on public dashboards.
PART 3 — ABUSE & DoS THREATS
Attacker exhausts your infrastructure or your wallet. Different from Ch 7.6's DDoS section — those were volumetric external attacks. This section is about clever adversaries.
3.1 · Denial-of-wallet (DoW) — the modern DoS
Attack: Denial-of-Wallet (DoW)
Modern attack: don't take you offline — bankrupt you while you stay online. 2023 Vercel $96K incident.
Classic DDoS aims to take you offline. Modern DoW aims to bankrupt you while you stay online.
The mechanic: attacker generates 1M synthetic redirects/sec against your API. Your infrastructure absorbs it — CDN, cache, DB all scale up. Your bill scales up too. Attacker paid $0 (residential proxies) or ~$50/month (cheap cloud VMs). You paid $50K/day in cloud bills.
Real incident: in 2023, a Vercel customer received a $96,280 bill after attackers targeted their site with bandwidth-amplifying requests. Vercel refunded — but not every provider does.
The attack economics:
| Layer | Attacker cost | Your cost (per 1M requests) |
|---|---|---|
| CDN cache-hits | ~$0.001 (proxy egress) | ~$0.10 (CDN request fee) |
| CDN cache-misses (behind rate limit) | ~$0.005 | ~$0.50 (origin round-trip) |
| DB read on miss | ~$0.005 | ~$5.00 (MySQL RDS + IOPS) |
| Cross-region replication triggered | ~$0.005 | ~$50.00 (inter-region egress) |
Attacker asymmetry: 10,000×. The attacker's $5 in proxy costs generates $50,000 in your bill.
Defense stack:
- Per-code request budget. Each short_code gets 10K RPS budget. Traffic beyond that is served 429 or the interstitial page.
- Cost circuit breaker. Real-time cost monitor triggers auto-throttle when hourly cost exceeds 3σ of baseline. Alerts SRE on-call.
- Origin shield with hard cap. CDN's origin shield tier caps hits to origin at 5K RPS globally. Beyond that: serve stale-while-revalidate or 502.
- Refuse to serve unfindable codes.
GET /randomgibberishreturns 404 withCache-Control: public, max-age=3600— CDN caches the 404. Attacker can't repeatedly hit origin.
3.2 · Hot-key DDoS on one short_code
Attack: All traffic → one shard
Consistent hashing routes each key to ONE shard. When a code is viral (or attacked), that shard melts.
Even without malicious intent, a viral URL can DDoS your infrastructure by pinning ALL traffic to a single cache shard. Attackers exploit this by manually pointing traffic at one hot code.
The mechanic: all traffic hits sysd.link/aB3xY9. Consistent hashing routes ALL those requests to Redis shard #47. Shard #47's CPU hits 100%. Adjacent shards on the same node melt from cache-line contention. Regional cache goes down. Cascade to origin.
Defense stack (see Ch 6 hot-key section for full detail):
- Multi-shard replication for hot keys. Detect hot keys (>1000 QPS on any single code) and replicate to N=5 shards. Client-side load-balance across replicas.
- Probabilistic early expiration. Instead of all clients refreshing cache at the same TTL boundary, add jitter — expire between 90-100% of TTL randomly. Prevents thundering herd.
- Edge cache TTL extension for hot codes. Once a code is identified as hot, increase edge cache TTL from 60s to 3600s. Trade freshness for load absorption.
3.3 · Rate limit bypass via distributed IPs
Attack: Distributed rate limit bypass
Slide to add residential proxy IPs. See how flat per-IP limits get bypassed vs how hierarchical limits catch aggregate.
Per-IP rate limits are trivially bypassed by residential proxy networks. IPRoyal, Bright Data, and Oxylabs sell access to 10M+ residential IPs for ~$500/mo. Attacker rotates through them; each stays under per-IP limits.
The mechanic:
- Per-IP limit: 60 create/hour
- Attacker uses 10K IPs from a proxy pool
- Aggregate rate: 600K creates/hour, still "compliant" per-IP
Defense stack:
- Hierarchical rate limits. IP → ASN → user → global. Any limit at any level triggers 429. See Ch 7.6 hierarchical rate-limit table.
- Cloudflare Bot Fight Mode. Free CF plan detects bot behavior via JA3 TLS fingerprint + browser challenge; residential proxies fail JA3 diversity checks.
- Behavioral analysis. Legitimate user creates 3 URLs, spends 2 min on each. Attacker creates 30 URLs, spends 500ms each. ML classifier on inter-event timing.
- Proof-of-work challenge for suspicious traffic. Cloudflare Turnstile requires the browser to compute a small crypto puzzle. Cheap for real users, expensive at attacker scale.
3.4 · Redirect loop / chain-shortening DoS
Attack: Cyclic redirect chain
Attacker creates A→B→C→A. Browser follows up to 20 redirects — each a hit on our infrastructure.
Attacker shortens sysd.link/A → target = sysd.link/B; shortens sysd.link/B → target = sysd.link/C; shortens sysd.link/C → target = sysd.link/A. Now every click triggers an infinite redirect loop.
The mechanic:
- Browser follows redirects: sysd.link/A → sysd.link/B → sysd.link/C → sysd.link/A → ...
- Each hop = 1 read on YOUR infrastructure
- Modern browsers cap at 20 redirects; attacker gets 20 free reads per victim click
- Amplification: 1 victim click × 20 redirects × 1M victims = 20M reads
Defense stack:
- Reject targets on our own domain during create.
POST /v1/urlswith target =sysd.link/*→ 400 Bad Request. Simple + effective. - Server-side max-hop check. Even for cross-shortener chains (bit.ly → t.co → sysd.link → tinyurl.com), abort after 5 hops.
- Loop detection during redirect. Track
X-Redirect-Depthheader; if incoming request has depth ≥ 5, serve the interstitial page with a warning instead of another 302. - Circular graph detection at create time. Track the graph of shortener-to-shortener references; refuse to create edges that would form a cycle.
PART 4 — INFRASTRUCTURE THREATS
Attacker tricks OUR servers into doing harm. This is where senior candidates separate from mid-level. These attacks require understanding of how our infrastructure ACTUALLY runs.
4.1 · SSRF via link-preview / og:image fetch
Attack: SSRF via link-preview fetch
The Capital One 2019 breach mechanic. $270M in fines + settlement. Every senior candidate must know this.
(EC2)
169.254.169.254
creds
POST /v1/urls with target = http://169.254.169.254/latest/meta-data/iam/security-credentials/scraper-role
Modern URL shorteners generate rich previews — Open Graph title, description, image thumbnail. Doing so requires fetching the target URL server-side. That fetch is the SSRF (Server-Side Request Forgery) attack surface.
The mechanic:
- Attacker submits
POST /v1/urlswith target =http://169.254.169.254/latest/meta-data/iam/security-credentials/my-role - Your og:image scraper (running in an EC2 instance) issues
GETagainst that URL 169.254.169.254is the EC2 Instance Metadata Service (IMDS) endpoint — routable from ANY EC2 instance's VPC- IMDS returns your instance's IAM role temporary credentials — access key, secret key, session token
- Your scraper puts those creds in the "preview description" field of the URL record
- Attacker fetches the URL analytics page → sees the description → has AWS credentials
- Attacker uses those creds to enumerate S3 buckets, drop backdoors, exfil data
Real incident: the 2019 Capital One breach was EXACTLY this. A misconfigured WAF allowed SSRF against IMDS. Attacker (former AWS employee) dumped credentials, listed 700 S3 buckets, exfiltrated 100M records. Capital One paid $80M fine + $190M settlement. The CVEs (CWE-918 SSRF) got a new SDL rule.
Defense stack (the layered SSRF defense every senior candidate should know):
- Deny at fetch time — RFC1918 + link-local + metadata + loopback. Before any HTTP fetch, parse the URL, resolve DNS, and check the target IP against a denylist:
- Resolve DNS then re-check. Attackers use DNS rebinding — return a public IP at first resolve, then re-resolve during fetch returns 169.254.169.254. Defense: resolve DNS, cache the IP, fetch using the resolved IP (not the hostname) so the resolver can't be re-called.
- Egress VPC with block-by-default. Run the scraper in a dedicated VPC with NAT gateway. Route table blocks all RFC1918 + metadata destinations at the network layer. Even a bypass of app-layer checks fails at the network.
- IMDSv2 with hop limit. AWS's IMDSv2 requires a PUT token before GET. Set metadata hop-limit=1 to prevent proxying via app-layer SSRF. Cost: zero. Effectiveness: near-total for IMDS attacks.
- Least-privilege IAM. Even if credentials leak, they can only do what the scraper's role permits. Scraper role =
s3:PutObjecton ONE preview-image bucket only. No wildcards.
The interview answer for SSRF is 6 sentences. Practice it: "Preview fetch is an SSRF surface. Defense is layered: (1) parse-and-deny RFC1918+link-local+metadata+loopback at the URL level, (2) resolve DNS then fetch via IP to prevent DNS rebinding, (3) run the scraper in an egress-VPC with network-layer block on private ranges, (4) IMDSv2 with hop-limit=1 on all EC2 instances, (5) least-privilege IAM so leaked creds have minimal blast radius. Capital One's $270M lesson is why every one of these matters."
4.2 · Cache poisoning
Attack: Cache poisoning via unkeyed header
Cache key includes attacker-controllable header — poisoned entry serves ALL subsequent readers.
Attacker manipulates cache keys or headers to store a poisoned response that's served to subsequent legitimate readers.
The mechanic (Web Cache Poisoning via unkeyed input):
1. Cache key includes: HTTP method + hostname + path
2. Cache key does NOT include: request headers like X-Forwarded-Host
3. Backend echoes X-Forwarded-Host into a rendered link on the response
4. Attacker sends GET /aB3xY9 with X-Forwarded-Host: [evil.com/steal](https://evil.com/steal)
5. Cache stores the response with link rel="canonical" href="//[evil.com/steal/aB3xY9](https://evil.com/steal/aB3xY9)"
6. Legitimate readers see the response with the poisoned link
7. Click-through gives attacker traffic
Real research: James Kettle (PortSwigger, 2020) demonstrated cache-poisoning against 12+ Fortune 500 sites via variations of this mechanic. Every CDN vendor issued patches; every application team had to re-audit their cache-key config.
Defense stack:
- Canonical cache keys. Cache key = short_code ONLY. Not path variations, not query strings (they're irrelevant to a redirect), not headers.
- Vary allow-list. If you MUST vary on any header (Accept-Encoding, for example), explicitly allow-list which ones. Default DENY.
- Signed cache entries. Origin signs each cache entry with HMAC. CDN validates signature on serve. Poisoning requires cracking HMAC — infeasible.
- Cache-key normalization. Strip
X-Forwarded-*,Hostoverrides, and any client-controllable headers before computing cache key. - Origin never echoes client-supplied data into responses. The rendered redirect page uses only server-side data.
4.3 · Open redirect trust laundering
Enterprise email filters like Microsoft Defender, Proofpoint, and Mimecast maintain URL denylists AND allowlists. Trusted URL shorteners (Bit.ly, T.co, LinkedIn's lnkd.in) are typically allowlisted — otherwise legitimate marketing emails would get blocked.
Attackers exploit this. [evil.com/steal](https://evil.com/steal) is denylisted. But sysd.link/xyz → [evil.com/steal](https://evil.com/steal) — the initial URL is on the allowlist, so email filter passes it through. Recipient clicks, redirect fires, arrives at denylisted destination.
Real incident: in 2022, Microsoft Defender's URL scanner missed a phishing campaign because attackers used allowlisted URL shorteners. Microsoft added "final-destination scanning" post-incident.
Defense stack (this one is partially on YOUR reputation):
- Interstitial for external redirects. If the target is not on our own trust-list (e.g., attacker's fresh domain), show a warning page before the 302. Legitimate marketing loses ~5% conversion; phishing conversion drops 40%+.
- Sender-domain reputation scoring. Track the reputation of the account that created each short URL. New accounts default to interstitial; long-standing enterprise accounts get direct 302.
- ML-based landing-page fingerprint. Post-shorten sandbox scan runs headless Chrome, screenshots the target, compares against known phishing templates. Flagged targets get interstitial or blocked.
- Reputation feed to email filter vendors. Publish an outgoing feed of confirmed-malicious short URLs to Defender / Proofpoint / Mimecast so the filter can block DOWNSTREAM of us. It's a shared responsibility.
4.4 · XSS in preview / interstitial
Attack: Stored XSS via og:title
Target's HTML metadata is fetched and rendered on our interstitial. Unescaped input = stored XSS.
The preview generator fetches target URL metadata (og:title, og:description, og:image alt-text). If we render this metadata in HTML without escaping, we have stored XSS.
The mechanic:
1. Attacker's target URL responds with <meta property="og:title" content="<script>alert(document.cookie)</script>"> (or a similar payload)
2. Our preview generator extracts og:title
3. Our redirect page renders <h2>{og_title}</h2> server-side (without escaping)
4. Legitimate user clicks the short URL
5. Interstitial renders — includes the attacker's <script> payload
6. Script executes in our origin — steals cookies, exfils to attacker
Real incident: TinyURL preview XSS (CVE-2015-4041) allowed stored XSS via crafted <title> elements. ~10M users affected before patch.
Defense stack:
- Escape ALL fetched HTML metadata. Use a well-audited escape function (React's default, Django
|escape, Railsh()). Never trust anything fetched from a target URL. - Strong Content Security Policy on the interstitial.
default-src 'none'; script-src 'self' 'nonce-{random}';. Even if XSS payload lands, script tags without our nonce can't execute. - Sandbox-iframe for previews. Render the preview (with attacker-controlled content) inside
<iframe sandbox="allow-same-origin">— no scripts, no top-level navigation, no forms. - Store parsed metadata, not raw. Extract og:title into a plain-text field in the DB. Never store raw HTML from a target site.
- Reject binary characters in metadata. If og:title contains a null byte, control character, or looks like base64-encoded payload, discard the field.
PART 5 — GOVERNANCE & COMPLIANCE
Attacker triggers regulatory pain. This is where a URL shortener startup fails — not from a technical hack, but from a regulator, a court order, or a CSAM report the company can't respond to fast enough.
5.1 · CSAM / illegal-content takedown
CSAM takedown — 24-hour compliance workflow
18 U.S.C. §2258A obligations. Automated pipeline: report → hash → disable → NCMEC → evidence. Every step logged, every clock enforced.
Every URL shortener with US-hosted infrastructure has NCMEC (National Center for Missing & Exploited Children) obligations under 18 U.S.C. §2258A. If someone shortens CSAM, you must:
1. Detect (via hash-matching against NCMEC's Child Sexual Abuse Material hash database)
2. Preserve evidence (the URL, the target content hash, timestamps, requester metadata)
3. Report to NCMEC within 24 hours
4. Remove from your service immediately
5. Retain evidence for 90 days minimum
Non-compliance = up to $150,000 per instance in fines + potential criminal liability for officers.
Defense stack:
- PhotoDNA integration. Microsoft's PhotoDNA hash database is free for platforms with abuse detection needs. Every image fetched during preview generation gets hashed and matched.
- 24/7 abuse@ inbox with automation. Reports from external parties get triaged automatically; confirmed hits auto-disable the URL and route to Trust & Safety within 60 seconds.
- Automated NCMEC reporting. SBI (System-to-System Business Interface) API allows automated CyberTipline reports. No human required for the 24-hour clock.
- Legal audit trail. Every disable action gets logged with: URL, target hash, detection method, timestamp, reporter (if any), NCMEC report number. Retained for 5 years.
A senior engineer must know: this isn't optional. This isn't "we'll add it in a later release." Every US-hosted URL shortener has this on day 1 or gets shut down.
5.2 · Insider threat / admin audit trail
Insider threat detection — immutable audit + anomaly ML
Admin queries flow into an append-only log. ML classifier learns baseline patterns. Anomalies escalate automatically.
Your support engineer, at 2am, looks up "which URLs is [celebrity email] creating?" Or "what did [journalist covering us] shorten last week?" No malicious intent — just curiosity. No audit trail = no detection = eventual scandal.
Real incident: 2018 Facebook fired 52 employees for improper access to user data (Slack messages, DMs, private posts). Discovery mechanism: an internal audit tool that flagged unusual query patterns. Without that tool, none would have been caught.
Defense stack:
- Immutable audit log for every admin lookup. Every internal query hits a Kafka topic; sinks to append-only S3 + Elasticsearch. Can't be deleted, can't be modified.
- Automated anomaly detection. Baseline each admin's typical query pattern; flag deviations (e.g., 10× volume, queries for celebrity accounts, cross-team lookups).
- Quarterly review by non-technical compliance team. Log summary reviewed by Legal + Trust & Safety. Anomalies escalated to VP Eng.
- Break-glass access with reason. Admin queries require typed justification ("customer support ticket #12345"). Reviewed by manager async.
- Zero-privilege default. Admin accounts have READ-ONLY on production data by default. Write access is time-boxed (4-hour session) and multi-party approved.
5.3 · GDPR right-to-erasure cascade
GDPR Article 17 erasure cascade
User invokes right-to-erasure. PII lives in 6+ storage tiers. Fan-out delete via central identity service.
User invokes GDPR Article 17. You have their PII across:
- MySQL primary + 2 read replicas
- Redis cache (with 90-day TTL on some entries)
- S3 backup snapshots (7-day daily, 4-week weekly, 12-month monthly)
- Analytics warehouse (Snowflake)
- Kafka event log (7-day retention)
- CDN edge caches (all 200+ POPs globally)
- Vendor systems (SendGrid for email, Twilio for SMS, Datadog for logging)
Regulatory clock: 30 days to complete erasure. €20M+ or 4% global revenue for failure.
Defense stack:
- Central identity registry. Every service refers to users via a stable
user_idUUID. No PII duplicated in service tables. - Fan-out delete job. DSR request → identity service publishes
DeleteRequest{user_id}to Kafka → each service subscribes + implements erasure — MySQL row delete, Redis key delete, Snowflake anonymization, S3 tombstone with 90-day cleanup grace. - Append-only tombstone for backups. Don't try to modify backup snapshots (immutable). Instead: maintain a
deleted_userstombstone table; every restore-from-backup checks tombstone and drops matching rows. - Retention-policy TTL. All services set TTL on user-data based on policy (13 months for analytics, 30 days for logs, etc.). Data ages out even without explicit erasure requests.
- DPA (Data Processing Agreement) with every vendor. SendGrid + Twilio + Datadog contract to honor erasure signals. Automated API integration where possible; manual ticket where not.
5.4 · Zero-day URL — Safe Browsing feedback loop
The zero-day URL problem
Fresh malicious URL is not yet in any threat feed. Google Safe Browsing median lag ~24h. Median phishing URL lives just 3h. Attacker races the clock.
Google Safe Browsing, VirusTotal, PhishTank — they're all feed-based systems. There's a lag between "a URL becomes malicious" and "the feed lists it." Google's median lag is ~24 hours. In that window, our create-time check says "safe" — the URL is not.
The attacker's window: the median phishing URL lives only 3 hours. Attackers race the clock — get maximum credential captures before defenders update the feeds.
Defense stack (this is the frontier):
- Behavioral signals from the URL structure. Domain age, TLD reputation (.tk, .ml, .ga are 90%+ malicious), URL length, query-param complexity, use of URL shortener nesting — all feed a real-time classifier.
- Creator reputation signals. New account (<7d) + no email verification + creates 10 URLs in first hour + all point at newly-registered domains → 95% likely abuse. Interstitial + human review queue.
- Click pattern anomalies. Legitimate URL: gradual click growth, wide geographic distribution, varied user agents. Attacker URL: sudden burst, geographic clustering, bot-like UAs. Post-creation ML flags anomalies within minutes.
- Human-in-loop for high-risk creates. New account creating URLs to high-value target categories (finance, healthcare, gov) → route to Trust & Safety review within 5 min. Cost: 1 human-hour per 200 flagged URLs.
- Contribute upstream. When we detect a novel phishing pattern, publish to PhishTank + notify Google Safe Browsing. Feed loop improves for everyone.
THE COMPLETE DEFENSE STACK (with animation)
We've walked through 18 attacks and 18 defenses. But you don't build a defense-per-attack. You build a stack — layers that catch different attack shapes with different cost profiles. Here's the full 5-layer stack from Ch 7.6, now with 18 attacks worth of context:
Watch 10,000 attacks funnel through 5 layers. Toggle layers off to see what breaks.
An attacker must defeat ALL FIVE to reach the outcome they want. Turn a layer off to see the survivor count spike.
Layer 5 · DDoS mitigation at the edge
Layer 4 · WAF + hierarchical rate limits
Layer 3 · Create-time malicious URL detection
Layer 2 · Post-facto abuse scanning + user reports
Layer 1 · Human security team + compliance
An attacker breaking ANY ONE layer fails. They must defeat ALL FIVE to reach the outcome they want. Each layer catches a different attack shape and has a different cost profile. Cheap layers (5, 4) handle volume; expensive layers (2, 1) catch persistent adversaries.
The defense-in-depth axiom, restated with context: Each layer catches specific attack shapes. Layer 5 (edge DDoS) handles volume + protocol attacks. Layer 4 (WAF + rate limit) handles automated abuse. Layer 3 (application-tier auth + validation) handles CSRF, SSRF, XSS. Layer 2 (post-shorten scan + user reports) handles zero-day content threats. Layer 1 (governance + audit) handles regulatory + insider. No layer is optional. Skipping any layer creates a specific class of vulnerability.
THREAT MODEL EXERCISE (do this — don't just read it)
Given the system diagram below, identify the top 5 attacks + the layer that catches each. Answer key at the bottom.
System: L6 URL shortener at 1M RPS. Client → Cloudflare → API Gateway → App tier (Node.js) → Redis Cluster + MySQL. Async pipeline: create → Kafka → link-preview scanner → S3 for og:image storage.
Q1. An attacker submits POST /v1/urls with target = http://internal-jenkins.corp.acme:8080/script-console/. What attack? Which layer catches it?
Q2. An attacker registers 10,000 free accounts and creates 100K short URLs pointing at malware landing pages. What attack? Which layer catches it?
Q3. A support engineer's account gets compromised. Attacker uses the account to query 1M URLs looking for password reset tokens in query params. What attack? Which layer catches it?
Q4. A researcher publishes a paper showing 40M+ Bit.ly URLs enumerable via sequential codes. What attack was published? What was our design decision that prevents it?
Q5. A journalist obtains an internal spreadsheet showing which short URLs Amazon Marketing shortened last quarter, ranked by clicks. What attack? Which layer catches it?
Answers (scroll down):
<br />
<br />
<br />
<br />
<br />
A1. SSRF (§4.1). Layer 3 catches — RFC1918 denylist at fetch time in the link-preview scanner. If bypassed, Layer 5 (egress VPC network-layer block) is the backup.
A2. Bot-abused mass creation (§3.3). Layer 4 catches — hierarchical rate limits (per-IP → per-account → global), plus behavioral analysis flagging new-account velocity anomaly. Layer 2 (post-shorten scan) provides backup by catching the malware targets even if creation succeeded.
A3. Insider threat (§5.2). Layer 1 catches — immutable audit log detects abnormal query volume by that account; anomaly detection triggers automated lockout + Trust & Safety review.
A4. Sequential ID enumeration (§2.1). Design decision: random 62^7 codes with rate-limited GET endpoints. Prevents enumeration by making the guess space 3.5 trillion + capping enumeration attempts at 100 unique GETs per IP-hour.
A5. Analytics dashboard leak (§2.4). Layer 1 catches at policy level — default to private analytics + k-anonymity threshold + owner-only referer data. If policy is wrong, no technical fix helps.
Interview soundbite
60-second version:
"URL shorteners have 18 distinct attack categories across 5 buckets: content threats (phishing, malware, TOCTOU, homograph), enumeration & privacy (ID enum, referer leak, brand-jack, analytics leak), abuse & DoS (denial-of-wallet, hot-key, distributed bypass, redirect loops), infrastructure (SSRF, cache poison, open redirect, XSS), and governance (CSAM, insider audit, GDPR, zero-day URLs). Defense is a 5-layer stack: edge DDoS mitigation, WAF+rate limits, application validation, post-shorten scanning, and governance+audit. No layer is optional. At L6 the cost is ~$500/mo infrastructure plus a security engineer's time. The interview answer for any specific attack is (1) the mechanic in 2 sentences, (2) which layer catches it, (3) which real-world incident proves it matters."
3-minute version (adds SSRF, DoW, and GDPR-cascade deep dives as three examples of "senior mechanical fluency" — see the section on each above for the full drill).
References (28 items)
- Google Safe Browsing v4 — developers.google.com/safe-browsing/v4. Real-time URL threat classification.
- VirusTotal API v3 — virustotal.com/en/documentation/public-api. Multi-engine malware detection.
- PhishTank — phishtank.org. Community-driven phishing URL database.
- OWASP Top 10 (2021) — owasp.org/Top10. A01 Broken Access Control, A03 Injection, A10 SSRF (added 2021).
- PortSwigger Web Cache Poisoning — portswigger.net/research/practical-web-cache-poisoning. James Kettle's foundational 2020 paper.
- Capital One SSRF Postmortem — krebsonsecurity.com/2019/07/capital-one-data-theft-impacts-106m-people. The definitive case study.
- AWS IMDSv2 documentation — docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html. The IMDS-hardening reference.
- Chrome Safe Browsing — safebrowsing.google.com/safebrowsing/report_general. Reporting + integration guide.
- NCMEC CyberTipline — report.cybertip.org. US-required CSAM reporting.
- PhotoDNA — microsoft.com/en-us/photodna. Microsoft's CSAM hash-matching service (free for platforms).
- GDPR — Regulation (EU) 2016/679 — gdpr-info.eu. Full regulatory text.
- Bit.ly enumeration research (2016) — technologyreview.com. Coverage of the base62 enumeration incident.
- Cloudflare Bot Fight Mode — developers.cloudflare.com/bots/get-started/free. Bot detection reference.
- Cloudflare Turnstile — blog.cloudflare.com/turnstile-private-captcha-alternative. Modern CAPTCHA-alternative.
- RFC 3986 URI Generic Syntax — rfc-editor.org/rfc/rfc3986. URL parsing spec — critical for normalization.
- UTS #39 Unicode Security Mechanisms — unicode.org/reports/tr39. IDN + homograph defense spec.
- CVE-2017-5060 — nvd.nist.gov/vuln/detail/CVE-2017-5060. Chrome IDN URL bar attack.
- CVE-2015-4041 — nvd.nist.gov/vuln/detail/CVE-2015-4041. TinyURL XSS in preview.
- STRIDE Threat Model — learn.microsoft.com/en-us/security/adaptive-cloud/threat-modeling. Microsoft SDL threat categorization.
The Staff+ interview insight: most candidates memorize security vocab and stop there. The senior differentiator is mechanical fluency — being able to describe the attack step by step, name the specific defense mechanism, cite a real incident. That's what this chapter drills. Read it once for coverage; then read it again next week to consolidate; then explain each attack out loud to an imaginary interviewer. Practice this until you can do all 18 without notes. Because when a Staff-level system-design interviewer asks a security follow-up, that's exactly what they're checking for.
18 URL-shortener attacks across 5 STRIDE-mapped categories: content, enumeration/privacy, abuse/DoS, infrastructure, governance. Defense is a 5-layer stack — edge DDoS, WAF+rate limit, app validation, post-shorten scan, governance+audit. No layer optional. Senior differentiator: mechanical fluency (describe the attack step-by-step, name the specific defense, cite a real incident). Practice until all 18 can be recited without notes.
- What are the 18 distinct attack categories against a URL shortener?
- Walk through the SSRF-via-preview attack step by step, and which defense layers catch it
- What's TOCTOU and how does continuous re-scanning defeat it?
- How do homograph attacks work and what's the mixed-script defense?
- What's denial-of-wallet and why is it different from classic DDoS?
- How does GDPR right-to-erasure cascade across your storage tiers?
- What's the 5-layer defense-in-depth stack and which attacks each layer catches?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
Every attack in this chapter travels over HTTP. Understanding request lifecycle, header semantics (Referer, X-Forwarded-*, Host), and status codes is prerequisite.
TLS 1.3 + JA3 fingerprinting is how Cloudflare Bot Fight Mode catches residential-proxy attackers. Cert pinning + HSTS defend against MITM.
Rate limit design (idempotency keys, token buckets) is the primary defense mechanic against automated abuse.
DDoS mitigation, WAF rules, and rate limiting all happen at specific layers of the LB stack. Where each defense lives is critical.
Chapter 8: L7 extreme scale. You now know the full defense stack at L6. At L7 (1B RPS, global) security becomes an ORG problem — a security team of 5+, ML-based abuse detection, nation-state-scale DDoS retainers. Technology gets easier at L7 because you have the budget; the hard problems are organizational and strategic.
Extreme scale (L7)
1B RPS — where technology becomes strategy
Buckle up. This is the chapter where I stop being a technical mentor and become a business mentor for a moment. Because at L7, you're not making technology decisions — you're making company decisions.
L7 (Staff/Principal/Distinguished engineer at FAANG) is about answering questions like:
- Do we build this in-house or buy from a vendor (Bitly, Rebrandly)?
- Is URL shortening a product we sell, or a feature embedded in something bigger (analytics platform, marketing automation)?
- Should we launch in China? What does the regulatory story look like there?
- Our biggest competitor got acquired last week. Do we adjust our pricing, or double down on enterprise?
- We have 200 engineers wanting to work on this. How do we structure them into teams that ship without stepping on each other?
- Our infra bill is $50M/year. Where would you cut 20% without hurting customers?
Notice how none of these are technical questions. They're engineering leadership questions.
But — here's the thing — you STILL need the technical foundation to answer them well. When your CEO says "can we handle 10x growth in Q4?" you can't say "let me get back to you." You need to already know:
- Our current capacity headroom is X.
- Adding capacity Y takes Z days and costs $W.
- The bottleneck we'd hit first at 10x is [component], and here's the mitigation.
The L7 design below shows how the technical architecture serves the business strategy. Notice how cost, organizational alignment, compliance, and technical debt trade-offs are all first-class citizens. This is not "just" a URL shortener anymore. It's a platform business.
Advice from someone who's been in the room: at L7 interviews, you'll be judged on whether you can shift between technical depth and strategic breadth in the same 60 seconds. When they ask a technical question, answer with depth. When they ask a business question, answer with clarity. When they ask a technical question that has business implications — answer BOTH.
## The 200-engineer org at L7 (Conway's Law in action)
At L7 the architecture is the org chart. Melvin Conway proved this in 1968 ("How Do Committees Invent?"): systems mirror the communication structures of the organizations that build them. So at 200 engineers, you don't just draw services; you draw teams that own services.
Explore the interactive org chart below — click any team card to see what they own, who they consume, and who consumes them. The dependency arrows animate as you select different teams:
Click any team to see what they own and who they depend on.
The architecture IS the org chart. At 200 engineers, service boundaries follow team communication boundaries.
- • Each product team owns their API + SLA + pricing model
- • Each platform team owns ONE cross-cutting service; product teams consume via internal API contracts
- • SRE + Security are horizontal orgs consulted by all
- • Data org owns the read-only warehouse + BI
“What team boundary should this service NOT cross?” — Answer: services should follow the team communication boundaries already in place.
Read this chart carefully. The lines are not just management reporting — they are the service dependency boundaries. When Enterprise Product wants a new SLA feature, they file a ticket to Platform's Kafka-as-a-Service team. When the Cons. Product team wants a new CDN config, they file a ticket to CDN Edge. The org structure is the API contract structure.
References:
- Conway, Melvin (1968) — "How Do Committees Invent?" Datamation 14: melconway.com/Home/Committees_Paper.html. The original paper introducing Conway's Law.
- Skelton & Pais (2019) — Team Topologies: Organizing Business and Technology Teams for Fast Flow (IT Revolution Press). The modern definitive book on structuring platform + product + enabling teams at L6+.
- Amazon "two-pizza team" concept (Bezos 2002 memo, widely referenced) — the size ceiling for a single team owning a service.
- Google SRE Book Chapter 32 (Evolving SRE Engagement Model): sre.google/sre-book/evolving-sre-engagement-model — how SRE org fits into product engineering.
Read the L7 design carefully. This is what senior engineers get paid for.
At L7, the interviewer tests ambiguity resolution, org design implications, precedent-setting, and extreme-scale reasoning. They will deliberately give you a vague brief. The signal is not the final architecture — it's how you REFRAME the problem, PROPOSE multiple viable architectures, PICK one with defensible rationale, and REASON about the org and precedent implications.
A good L7 answer proposes edge + global KV + multi-region + analytics pipeline. A great L7 answer does that AND: reframes the problem in the first 5 minutes ('this isn't URL shortening, it's a global edge platform'); proposes 2-3 viable architectures and picks one with explicit rationale; names precedents (Bitly, Cloudflare's own URL shortener, Twitter's t.co); proposes the org shape with 5 teams and their ownership boundaries; gives P&L math (cost per request, revenue per customer); and — most importantly — discusses the STRATEGIC question: 'is this a product we build or buy from Bitly'. L7 candidates think about build vs buy vs partner. That's what separates strong L7 from Distinguished/Fellow-track candidates.
How a request actually flows at L7
Two sequence diagrams — the READ path (redirect, hot) and the WRITE path (create, rare). At each level the sequence adds participants as the architecture evolves.
READ path — redirect at L7 (1B RPS)
Own CDN (Open Connect-style) + regional stacks + tiered storage. 99% edge offload.
WRITE path — create at L7 (1B RPS)
Same as L6 architecturally + AI abuse detection + real-time attribution + audit for compliance.
Scale evolution at a glance
The same problem, four scales. Each column shows what the architecture looks like AT that scale + the bottleneck that forces evolution to the NEXT one. Read left → right to trace the evolution.
Single region, single service, single database
Ten thousand requests per second is comfortable for a well-tuned MySQL. The whole system is a load balancer, a small pool of app servers, and one database. Correctness first; scale later.
- • Add a database read index on `short_code` for O(1) lookup.
- • Pool DB connections via ProxySQL (or RDS Proxy) to avoid connection-storm at burst.
Add a distributed cache and read replicas
Ten× the traffic. The database becomes read-bound around 100K RPS. Introduce Redis in front of the read path (cache-aside pattern) and add MySQL read replicas. Writes still go to the primary.
Redis — Single Redis becomes a hot spot; also a single point of failure.
- • Cluster Redis for read scale and high availability.
- • Add a small in-process LRU on app servers for the hottest short codes.
- • Rate-limit new URL creation per IP to blunt abuse.
Shard writes, cluster the cache, async analytics
At a million RPS, the primary database write path becomes the bottleneck. Shard writes by `short_code` hash. Redis becomes a cluster. Analytics move to a Kafka + stream-processing pipeline so the redirect path stays lean.
Hot shard — A viral URL's shard sees disproportionate traffic.
- • Consistent-hash sharding by `hash(short_code)` for even distribution.
- • For hot keys, promote the entry to an in-memory tier on every app server (LRU) with a short TTL.
- • Rate-limit URL creation per API key; provide bulk-create as an async job.
Global edge, multi-region active-active, distributed KV
A billion RPS means the redirect must complete at the edge in most cases — the origin sees only cache misses and writes. Storage moves from sharded MySQL to a globally distributed KV (DynamoDB-style). Writes are replicated across regions.
Global write replication — Cross-region replication adds tens of ms of latency and creates a consistency window.
- • For the 'create then share' hot path, return the short URL only after the KV write has been acked by ≥2 regions (quorum).
- • For redirects, tolerate eventual consistency — worst case is a 404 for a few hundred ms, gracefully retried.
- • Edge cache negative results (404s) with a short TTL to avoid origin storms.
L7 interviewer will probe you at every scale — here's how to answer at each
Four scale tiers, one interview level. The mentor tells you at each scale: how deep to go, what to say, what to skip, and what will kill your answer.
One sentence + move on
Single region, single service, single database
'Trivial monolith on RDS + 3 app servers. Ships in 8 weeks. This is a valid MVP path if we want to launch cheaply to test PMF. Not interesting at L7 unless you want to discuss when to intentionally UNDER-design as a business strategy.'
You reframe 10K as a business decision (build cheap MVP to test market). You don't waste breath on the tech.
Treating a 10K walkthrough as a technical exercise at L7. It's a strategic exercise.
30 seconds — cache + replicas, cost angle
Add a distributed cache and read replicas
'Cache-aside + MySQL replicas. $1,600/mo. But at L7 the interesting question is: at 100K RPS should we be self-hosting MySQL, or moving to managed Aurora/Cloud SQL/Cockroach at 3x cost for 0.5 SRE headcount saved? Build vs buy at every layer.'
You reframe every technical decision as a build/buy or cost decision. That's the L7 mindset.
Getting stuck in implementation details. At L7 you look for the strategic angle in every question.
Confident architecture PLUS strategic overlay
Shard writes, cluster the cache, async analytics
'Sharded MySQL, multi-region, CDN — standard. What matters at L7: (1) which cloud providers are we locked into and what's our exit strategy, (2) at what infra spend do we bring CDN in-house (Open Connect model breakeven ~$5M/yr), (3) how do we structure a 40-engineer org across platform + product + SRE, (4) which regulatory regimes do we skip and what's the market cost. Technology is the substrate for the business.'
You give the technical answer briefly and pivot to the strategic layer within 60 seconds. That signals L7.
Never getting to the strategic layer. At L7, the tech is expected — differentiation is in the strategy.
This is where L7 lives — go deep here
Global edge, multi-region active-active, distributed KV
Full L7 answer: (1) infrastructure as capital allocation ($50M-500M/yr is a P&L line), (2) build vs buy at every layer with breakeven math, (3) org design for 200-engineer team (Conway's Law + platform vs product teams), (4) competitive positioning (Bitly commoditizing? Move up-market? Bundle into analytics?), (5) regulatory strategy (which markets we enter, which we skip), (6) M&A calculus (acquire AI-native competitor? divest to focus?), (7) chaos engineering for COST (can we reduce infra 30% in 90 days?). Every answer maps back to business impact.
You're indistinguishable from a CTO/VP-Engineering conversation. You know 1B is not a technical problem — it's a business problem with technical inputs.
Staying in technical mode. At 1B RPS L7 interview, the answer 'here's the sharding strategy' fails — the interviewer wants 'here's how we allocate $200M/year.'
A billion RPS means the redirect must complete at the edge in most cases — the origin sees only cache misses and writes. Storage moves from sharded MySQL to a globally distributed KV (DynamoDB-style). Writes are replicated across regions.
- Global write replication — Cross-region replication adds tens of ms of latency and creates a consistency window.
- For the 'create then share' hot path, return the short URL only after the KV write has been acked by ≥2 regions (quorum).
- For redirects, tolerate eventual consistency — worst case is a 404 for a few hundred ms, gracefully retried.
- Edge cache negative results (404s) with a short TTL to avoid origin storms.
- Multi-region active-active + quorum writes reduce write throughput per region — the price of global consistency.
- Sub-10ms edge means we can't do anything server-side that requires DB — the edge is a pure cache-and-forward.
- Region outage → global traffic reroutes via Anycast; other regions absorb; quorum writes still work with 2 of 3 regions.
- Edge PoP outage → BGP/Anycast routes to nearest healthy PoP.
- Global KV partition → reads continue from local region; writes queued locally until quorum restored.
At a billion RPS, the redirect path fundamentally changes: it now completes at the edge. The origin is a fallback. The database is no longer a database — it's a globally replicated KV. Interviewers at this scale probe you on the write path: 'what happens when a user creates a URL and immediately shares it?' That's the quorum-write story.
The L7 design walkthrough in full
Now that you know how to speak across scales, here's the full L7 answer — walkthrough, alternatives considered, key decisions, common mistakes, and what separates a good L7 answer from a great one.
At L7, the interviewer tests ambiguity resolution, org design implications, precedent-setting, and extreme-scale reasoning. They will deliberately give you a vague brief. The signal is not the final architecture — it's how you REFRAME the problem, PROPOSE multiple viable architectures, PICK one with defensible rationale, and REASON about the org and precedent implications.
Scale context
The interviewer typically anchors around 1,000,000,000 RPS globally. This is Bitly-plus-plus scale — the URL shortener is a component in a larger ecosystem (marketing suite, analytics platform). Single-region assumption breaks. Latency to end-users matters (edge economics). Cost per request becomes a first-class concern.
Clarifying questions to ask
Ask these before drawing anything.
- 1Is 1B sustained or peak? (Sustained means we design for it; peak means we can burst.)
- 2What's the org structure? (One team owns everything? Or platform vs product split?)
- 3What's the acceptable p99 latency at the edge? (10ms? 50ms? 100ms? Radically changes design.)
- 4What's the click-analytics accuracy requirement? (99% is easier than 100%.)
- 5What's the cross-region consistency requirement for URL creation? (Read-after-write globally is expensive; regional-only is cheap.)
- 6What's our tolerance for failure blast radius? (Single-region outage tolerated? PoP outage OK?)
Design walkthrough
The story of the design, one section at a time.
1. Reframe the problem before designing
1B RPS globally = ~11M concurrent requests. Cannot serve from any single region. The problem is no longer 'design a URL shortener' — it's 'design a globally distributed edge system that happens to redirect URLs'. This reframe unlocks the entire design: put the redirect logic at the edge (CDN + Workers), keep the origin small.
2. Edge-first architecture
Redirects complete at the CDN edge in most cases. Cloudflare Workers or Lambda@Edge run at every PoP. Each PoP has a local KV store (Cloudflare KV, Fastly config store) holding hot short-code → long-url mappings. Workers check local KV → if hit, redirect immediately (p99 ~5ms globally). On miss, fall back to origin — a smaller regional service that queries the global KV store (DynamoDB Global Tables). Origin sees ~1% of traffic.
3. Storage — globally distributed KV
DynamoDB Global Tables (or Spanner) replicates URL data across 5+ regions. Reads are local (single-region latency). Writes go to any region and replicate. The trade-off: writes are eventually consistent globally (~seconds of lag). For URL creation, this is acceptable except for the 'create then immediately share' hot path — where we need quorum writes (write to 2+ regions before returning).
4. The create-then-share hot path
User creates URL in region A. Shares it. Reader in region B clicks it. If the write hasn't replicated to region B yet, reader sees a 404 that quickly becomes 200 (retryable). Mitigations: (1) quorum writes across ≥2 regions before returning the short URL (adds latency but guarantees availability), (2) edge caches negative results (404) for a short TTL (1s) so the reader retries automatically, (3) the create endpoint returns a 'settling' flag that clients handle gracefully.
5. Analytics at global scale
1B RPS means 30 trillion click events/year. Kinesis or Kafka per region → regional aggregators → global aggregation via lambda architecture (batch + streaming). Store rollups in ClickHouse or Snowflake. Real-time counters (top 1000 URLs per hour) via approximate counting (HyperLogLog, Count-Min Sketch). Exact per-URL counts computed nightly.
6. Global rate limiting and abuse
At 1B RPS, we're a top DDoS target. Edge-level DDoS scrubbing (Cloudflare / AWS Shield) is table stakes. Per-account rate limits enforced at the edge with local counters + eventual global sync (approximate global limit, exact regional limit). Bot detection via behavioral signals. Malicious-URL scanning via async workers + global block list synchronized to all edges.
7. Org design implications
This system needs: an edge team (Workers, PoP ops), a data team (KV storage, replication), an analytics team (Kafka, ClickHouse), an abuse team (bot detection, block lists), a customer team (API, dashboards). This is 5+ teams and ~50 engineers. At L7 you should propose the org shape, ownership boundaries, and cross-team contracts (API-level interfaces, on-call rotation).
8. Cost, revenue, and unit economics
Rough infra cost: ~$5M/mo at 1B RPS. That's $0.14 per 1M requests. For that to be viable, we need either: enterprise contracts ($100K+/yr each × 500 customers) or advertising / affiliate revenue on the redirect. L7 candidates are expected to think about revenue model, not just tech.
Alternatives we considered (and why we didn't pick them)
For each alternative: why we rejected it, and when it would be the right choice.
Traditional data center + heavy caching (no edge compute)
Even with 99% cache hit rate at the region level, the origin sees 10M RPS — massive infrastructure. Also latency to Asia/Africa users is 200+ms round-trip. Edge compute at 300+ PoPs solves this.
Regulated environments where compute-at-edge isn't compliant (data residency laws). Then you accept the latency cost.
Vitess-sharded MySQL across 3 regions
Vitess is a fine sharded MySQL. But multi-region Vitess is operationally heavy (cell topology, replication config), and MySQL cross-region replication is async — same eventual consistency as DynamoDB Global Tables, but with more ops burden.
If we already have Vitess expertise and can't accept DynamoDB lock-in.
Spanner for global strong consistency
Spanner gives us strong consistency globally at the cost of write latency (~100ms cross-region). For URLs where 'create then share' consistency is the only strong-consistency need, we get that with quorum writes on eventually-consistent KV cheaper. Spanner is worth the cost when strong consistency is the norm, not the exception.
Financial systems, inventory, anything where any-write divergence is catastrophic. Not URL shorteners.
Peer-to-peer resolution (BitTorrent-style)
Cute idea. In practice: clients don't want to talk to unknown peers, latency is unpredictable, censorship-resistance isn't a URL-shortener need. Fun thought experiment; not a real answer.
Content-addressed storage (IPFS) for immutable content. URLs aren't content-addressed.
Redirect at the DNS layer (weighted CNAME)
DNS-based redirection can point at the right region but can't do 302 semantics or per-URL routing. Doesn't solve the actual problem.
Regional routing (not URL shortening).
Key decisions and their reasoning
Edge topology
Edge Workers at 300+ PoPs with local KV; regional origin as fallback
Edge Workers give us sub-10ms redirects globally. Local KV avoids origin round trips for hot URLs. Regional fallback handles the long tail. This is Cloudflare's model, proven at planetary scale.
Global storage
DynamoDB Global Tables (multi-region async replication)
DynamoDB Global Tables gives us regional read latency, cross-region replication, and predictable cost. Spanner would give us stronger consistency at 3-10× the cost — we don't need it for URLs. Cassandra multi-region is powerful but operationally heavier.
Create-then-share consistency
Quorum-write to 2 regions synchronously; async replicate to others
Quorum-write to 2 regions gives us: (1) durability against a single-region loss, (2) global visibility within ~100ms (both target regions on major continents), (3) create-then-share works without 404. Full sync to 5 regions would add 200ms+ to create latency — unacceptable for interactive use.
Analytics architecture
Lambda architecture: real-time approximate (HLL) + nightly exact rollups
Real-time approximate answers 'is this URL trending' with 1% error at low cost. Nightly exact answers 'total clicks' with precision. Users see real-time counters that update near-live and are corrected overnight.
Org structure
5 teams: Edge, Data, Analytics, Abuse, Customer
At this scale, each team has enough distinct engineering (Edge worker eng ≠ analytics eng ≠ abuse eng) that combining them creates bottlenecks. Feature teams per vertical would duplicate storage/edge concerns. This split respects the natural technical boundaries.
Common mistakes at this level
- Not reframing the problem — at L7, 'design a URL shortener' isn't the question anymore
- Proposing one architecture without alternatives — L7 requires 'here are 2-3 viable options and here's why I picked this one'
- Ignoring org design — at L7, the socio-technical system is part of the design
- Forgetting cost/revenue — an architecture that doesn't survive P&L review is a bad architecture
- Assuming strong consistency is a virtue — L7 requires you to know when to trade it for latency
- Not naming precedents — 'this is how Netflix does X, and here's why we deviate'
- Skipping the migration story — 'how do we get from L6 to L7' matters
What separates good from great at this level
A good L7 answer proposes edge + global KV + multi-region + analytics pipeline. A great L7 answer does that AND: reframes the problem in the first 5 minutes ('this isn't URL shortening, it's a global edge platform'); proposes 2-3 viable architectures and picks one with explicit rationale; names precedents (Bitly, Cloudflare's own URL shortener, Twitter's t.co); proposes the org shape with 5 teams and their ownership boundaries; gives P&L math (cost per request, revenue per customer); and — most importantly — discusses the STRATEGIC question: 'is this a product we build or buy from Bitly'. L7 candidates think about build vs buy vs partner. That's what separates strong L7 from Distinguished/Fellow-track candidates.
Why this design fits L7 — and what will break it
Why this design works AT L7
Compute is trivial at this scale — you're horizontally scaled across 20+ regions, own CDN capacity in top 5 markets (Open Connect model), have contracts locking commodity pricing on remaining cloud spend, and every service auto-scales. The math shifts from 'how many servers' to 'what's our cost-per-user vs revenue-per-user' and 'what's our marginal CAPEX for market entry into region X.'
Infrastructure spend: $50M-$500M/year depending on business model. But now it's a P&L line, not an engineering worry. Cost-per-user optimization drives GTM (go-to-market): where can we operate for <$0.001 per user-year?
200+ engineers across platform, product, SRE, security, and compliance orgs. Structured by *business domain* (enterprise, SMB, consumer) not by *technical layer* (frontend, backend, DB). Platform teams provide golden paths (Kafka-as-a-service, DB-as-a-service internally) so product teams don't reinvent. This is Conway's Law in action — the architecture IS the org chart.
- Continental fiber cut → traffic re-routes via satellite links (Starlink for internal ops); revenue impact <5%
- Nation-state-level DDoS → Cloudflare Magic Transit + BGP anycast withstand terabit-scale attacks
- Regulatory freeze (China Cybersecurity Law audit) → separate legal-entity operations in each jurisdiction; no cross-border data flow
- Company-wide security incident → incident-command structure activates in <15 min, comms + legal + eng aligned
- Executive departure / M&A / macro downturn → ability to reduce infra spend 30% in 90 days without breaking product (chaos engineering for cost)
At 1B RPS the technical architecture is essentially solved — cost, org, strategy, and regulation are what I optimize. My mental model: infrastructure spend is capital allocation. Every $1M in savings is $1M of headcount or R&D I can redirect. Every regulatory posture is a market. Every org-chart choice is a bet on velocity vs quality. Staff/Principal engineers spend more time in exec meetings than in whiteboard sessions at this stage.
Why this design breaks at the NEXT scale tier
There is no 'next tier' technically. At 1B+ RPS you are one of the top 10 websites on the internet by traffic. The bottleneck becomes existential questions: does URL shortening remain a viable business? Does the product-market fit hold? Are you being disrupted by (a) built-in URL shortening in every social platform (Twitter [t.co](https://t.co), LinkedIn [lnkd.in](https://lnkd.in)), (b) universal QR codes replacing short URLs on physical marketing, (c) AI-generated ephemeral links?
Revenue growth <cost of capital (WACC), market share declining, net dollar retention <100%, competitors entering with 10x-cheaper AI-enabled products.
Market shift. Users move to competitors. Analysts write pieces about 'the death of URL shortening.' Board asks about pivot.
The architecture is fine. The business isn't. This is where Staff+ engineering becomes indistinguishable from CTO/VP Engineering work — you're making capital-allocation decisions, org-design decisions, M&A calls (should we acquire the AI-native competitor?), and platform-pivot decisions. Zero technical solution exists for a product-market-fit problem.
Interviewer will ask (this is a real senior-VP interview question): 'URL shortening is becoming commoditized. As the Staff engineer, what do you tell the CEO?' Your answer: 'Three options. (1) Move up-market: bundle analytics + link intelligence + enterprise SLAs, price at $10K+/mo instead of $50/mo. (2) Move sideways: turn short URLs into a platform for programmatic marketing automation (Zapier for links). (3) Acquire the AI-native competitor and pivot the whole company. My recommendation depends on our unit economics and current customer segmentation — but I'd advocate for (1) + starting (2) as a skunkworks, with (3) as a defensive move if a specific competitor crosses 20% market share.'
The trigger to evolve to the next tier
Strategic / business — not technical
Market saturation, competitive disruption, product-market ambiguity
2-5 years. Business strategy timescale.
L7 is strategy, not architecture. Build vs buy, org design, cost optimization, regulatory strategy, and technical debt as capital allocation. You need technical depth AND business breadth.
- What business questions does L7 engineering answer?
- How does cost optimization become an architectural decision?
- What's the build-vs-buy calculus for URL shortening?
- How do you structure 200 engineers around this?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
At L7 you have billing + click ingestion + user provisioning across N services. When these need to commit together, you need one of these 4 patterns. Only becomes relevant at this scale.
The algorithm inside every distributed database's leader election. Understand this to defend Spanner / Cockroach / DynamoDB Global Tables at L7.
Chapter 9: failures. We've built the system at 4 scales. But every architecture fails in specific ways. This chapter walks through 6 failure modes — what breaks, what cascades, and how you recover. If you can't articulate failure modes, you can't defend the architecture in an interview.
When things break
6 failure modes every URL shortener sees
Now we talk about the thing every design tutorial glosses over: failure.
Every architecture I've shown you looks pretty on paper. In production, everything fails. Redis crashes. MySQL runs out of connections. The AWS region has a bad day. A malicious user tries to shorten a URL 10 million times. A downstream analytics service starts returning 500s. A deployment introduces a bug that leaks memory.
The mark of a senior engineer isn't that they design systems that don't fail. It's that they design systems that *fail predictably, degrade gracefully, and recover quickly.*
Below are 6 failure scenarios specific to a URL shortener. For each one:
- The trigger — what caused it (real events, not hypotheticals)
- The propagation — how the failure spreads through the system
- The mitigation — what your on-call does in the first 5 minutes
- The post-mortem — what you change so it doesn't happen again
Read these carefully. If you can't articulate failure modes in an interview, you can't defend the architecture. Interviewers love asking "what happens when X fails?" because it separates architects from box-drawers.
Homework I want you to do mentally: for each failure below, ask yourself — would the L4 architecture handle this? The L5? The L6? Some failures are only possible at higher scales; some are only mitigated at higher scales. Notice the pattern.
Failure 1 — Redis primary dies during peak traffic
Trigger. Redis primary node OOMs (out-of-memory) at 12:47pm on a Tuesday. Root cause: an ops team member manually flushed a debugging session earlier that morning and set maxmemory incorrectly to 60% of available RAM. Cache filled to that new lower limit; when Zipfian working-set churn arrived at lunch traffic peak (roughly 1.3× baseline), Redis started evicting hot keys, allocating replacement working set, and eventually crashed on a large SETEX batch.
Real-world reference: GitHub had a similar Redis OOM in 2015 (githubengineering.com — "Kubernetes at GitHub" post-mortem series has discussed Redis operational incidents). Every URL shortener at scale has a version of this story.
Propagation.
- T+0s: Redis primary crashes. redis-sentinel (or Redis Cluster failover) detects unreachable primary within 5-15 seconds.
- T+5s: Application-server Redis clients start returning connection-refused errors on every read. Cache-hit rate drops from 92% to 0%.
- T+5s to T+20s: Every miss falls through to MySQL. The DB, which was cruising at 25% CPU because Redis absorbed 92% of read traffic, is now serving 100% of read QPS = 12× the load it was designed for. MySQL CPU jumps to 100% almost instantly.
- T+15s: Redis Sentinel promotes a replica to primary. New primary starts serving traffic but with a COLD cache (0% hit rate).
- T+15s to T+5min: MySQL continues at 100% CPU as the newly-promoted Redis primary slowly warms up. p99 latency for redirects: 3-5 seconds (MySQL queue depth). Some requests time out and return 500. Users see intermittent errors.
- T+5min: Redis working set warms up (top hot keys are re-inserted after their first miss). Cache hit rate climbs back toward 90%. MySQL CPU drops back to normal. Recovery complete.
Total user impact: ~5 minutes of degraded reads at 3× normal latency + ~1% error rate.
The cascade timeline, visualized
Here's what happens minute by minute — and why the DB melts even though it's healthy:
| Time | Redis status | Cache hit rate | MySQL CPU | p99 latency | User impact |
|---|---|---|---|---|---|
| T+0s | ▓▓▓▓▓ CRASH | 92% ▼ 0% | 25% → 100% | 50ms → 200ms | ~1% errors start |
| T+5s | ░ down | 0% | 100% ▓▓▓▓▓ | 200ms → 2s | Slow redirects |
| T+10s | ░ down | 0% | 100% ▓▓▓▓▓ | 2-5s | Some 500s |
| T+15s | ▒ FAILOVER promoted | 0% (cold) | 100% ▓▓▓▓▓ | 3-5s | Twitter starts complaining |
| T+30s | ▒ warming | 12% | 95% | 3-5s | Errors 2% |
| T+60s | ▒ warming | 45% | 70% | 2s | Recovery visible |
| T+2min | ▓ warm | 78% | 40% | 200ms | Users OK |
| T+5min | ▓▓▓▓▓ WARM | 92% ▲ | 25% ✓ | 50ms ✓ | All clear |
KEY CASCADE MECHANIC:
- Loss of cache → 12× DB load → queue-theory wall at 70% CPU melts p99
- NOT: DB is slow → cache down is fine
- IS: Cache down = DB gets 12× traffic = queue melts = p99 explodes — even though DB itself is "healthy"
Prevention (see post-mortem below): warmup script triggered on Sentinel failover cuts recovery time from 5 min → 30 sec.
Newbie insight: the "root cause" is Redis dying, but the user-visible failure is a MySQL queue-theory meltdown — because the DB is trying to serve 12× its designed load. A cache failure is a DB failure with extra steps. This is why you never think of the cache as an "optimization" — it's load-bearing infrastructure.
Mitigation — what on-call does in the first 5 minutes.
- Confirm the alert (T+0 to T+30s). PagerDuty says "cache-hit-rate < 50% for 60s". On-call opens the Redis Grafana dashboard. Confirms primary is unreachable. Confirms Sentinel promoted a replica.
- Verify DB isn't melting (T+30s to T+90s). MySQL CPU is at 100% but the DB is UP and serving. If it wasn't, you'd escalate to DB on-call and consider emergency-reject at the load balancer.
- Don't manually rewarm (T+90s to T+3min). Tempting to run a script that pre-loads all short_codes into Redis. Don't. You'll trigger a stampede (see Failure 2) and make things worse. The Zipfian distribution means top 10% of URLs will naturally re-warm within 60 seconds of organic traffic.
- Communicate (T+3min). Post to #incidents Slack: "Redis primary crashed at 12:47. Failover completed in 15s. Currently at cache-hit 60% and climbing. p99 latency elevated at 500ms. ETA to full recovery: 2 min. No customer action required."
- Watch it recover (T+3min to T+5min). Cache hit rate returns to 92%. MySQL CPU returns to <30%. Post an "all clear" and move to post-mortem prep.
Post-mortem — what you change so it doesn't happen again.
- Root cause: manual
maxmemorychange didn't have code review or blast-radius review. - Prevention actions:
- Blame-free: the ops engineer who set
maxmemoryincorrectly followed a runbook that hadn't been updated. Update the runbook, add a validation check.
Would L4 architecture handle this? L4 has no Redis — no problem. L5? Full impact as described. L6? Regional isolation means one region's Redis failure doesn't affect other regions; CDN 95% offload means MySQL never sees 12× load.
Failure 2 — Cache stampede on a viral URL
Trigger. Beyoncé tweets sysd.link/beyonce-tour-tickets at 3:14pm. Traffic to that one short_code spikes from 100 RPS to 500K RPS within 90 seconds. TTL on that cache entry happens to be 24 hours ± jitter, and specifically expires at 3:15pm.
Real-world reference: Cache stampede has taken down Facebook (see "Scaling Memcache at Facebook" — Nishtala et al. NSDI 2013, cited in Chapter 6.5, section 3.5 explicitly addresses stampede mitigation), Twitter, and Etsy. Every high-traffic site has a stampede story.
Propagation.
- T+0s (3:15pm): Cache entry for beyonce-tour-tickets expires. Next redirect request finds a cache miss.
- T+0s to T+50ms: 500K RPS all miss the cache simultaneously. All 500K queries hit MySQL for the same row.
- T+50ms: MySQL primary sees ~500K identical queries. Query planner does one lookup fine; the connection layer gets flooded. Connection pool exhausts within 100ms.
- T+100ms to T+2s: New MySQL connections queue. Existing connections can't be reused (they're all busy). Every redirect for EVERY URL — not just Beyoncé's — now fails or times out. Complete outage even though only one URL is "hot."
- T+2s: First application server times out its MySQL call. Marks it as unhealthy. Load balancer removes the instance. Cascade begins: healthy instances get more load, they too exhaust their DB connections and get marked unhealthy.
- T+30s: Almost all app servers are unhealthy. Load balancer serves 503 for everything. Total site outage.
Root problem: N clients all doing the same expensive operation at the same time = "thundering herd."
Mitigation — what on-call does in the first 5 minutes.
- Alert fires immediately (T+3s): "MySQL connection queue depth > 500" and "5xx rate > 10%".
- Recognize the pattern (T+3s to T+30s): on-call sees MySQL CPU at 100%, connection pool at 100%, but the QPS to MySQL is only ~10x normal — not 100x. Realization: it's one hot key, not general overload. Open the "top hot URLs" dashboard:
beyonce-tour-ticketsis at 500K RPS. Diagnosed. - Manual mitigation (T+30s to T+2min): on-call directly inserts the hot URL into Redis with a very long TTL, using a keyword-verified admin script (
hot-key-force-insert.py). This immediately absorbs 99.9% of the traffic at the cache tier. MySQL recovers within 15 seconds. Users see the redirect resume. - Comms (T+2min): post to #incidents: "Cache stampede on viral URL. Force-inserted into cache. Recovering."
Post-mortem — what you change so it doesn't happen again.
The mitigation was correct but reactive. The permanent fix is architectural:
- Add single-flight (a.k.a. request coalescing). When a cache miss happens, take a distributed lock on that cache key. First caller to acquire the lock queries the DB and populates the cache. All other concurrent callers WAIT on the lock (with a 100ms timeout) and read from the cache once it's populated. Reduces N misses to 1 DB query. Reference: the Nishtala Facebook Memcache paper covers this pattern as "leases."
Implementation sketch:
pythondef get_url(short_code): cached = redis.get(f"url:{short_code}") if cached: return cached lock = redis.set(f"lock:{short_code}", "1", ex=5, nx=True) if lock: try: long_url = MySQL.query(short_code) redis.set(f"url:{short_code}", long_url, ex=86400 + jitter()) return long_url finally: redis.delete(f"lock:{short_code}") else: # Someone else is fetching. Wait 50ms, retry cache. time.sleep(0.05) return redis.get(f"url:{short_code}") or MySQL.query(short_code)
- Add probabilistic early expiration (PER — Probabilistic Early Recomputation). As TTL approaches expiration, a small random fraction of requests re-fetch from DB BEFORE the cache entry expires. This spreads the re-fetch load across a window instead of concentrating it at expiration. Reference: Wikipedia article on "Cache stampede" documents the PER algorithm with the reference paper: Vattani et al. 2015.
- Add negative caching. If MySQL returns no rows for a short_code, cache the "does not exist" answer for 60 seconds. Otherwise every 404 also stampedes the DB.
- Add per-key rate limiting. If a single short_code exceeds 10K RPS, force-cache it at CDN edge with 5-min TTL (turns any viral event into a CDN-only affair).
Would L4 architecture handle this? L4 has no cache = no stampede at cache tier, but the DB gets hit directly by 500K RPS which also melts it. Simpler failure, same outage.
Failure 3 — MySQL connection pool exhaustion (slow-query cascade)
Trigger. A new deployment ships that runs an analytics-warehouse ETL query directly against the production DB (someone forgot to route it to the read-replica). The ETL query is a JOIN across 3 tables and takes 90 seconds to complete. It's scheduled to run every 5 minutes.
Real-world reference: Stripe had a similar incident in 2019 (see stripe.com/blog — search "postmortem" or "reliability"); Etsy has publicly discussed similar patterns in their SRE talks. Any team that mistakenly runs analytics on OLTP is at risk.
Propagation.
- T+0s (deployment): New code deploys. ETL cron runs immediately, holding one MySQL connection for 90 seconds.
- T+90s: Second ETL runs (5-min schedule). Two connections held.
- T+185s (~3min): Third ETL runs. Three connections held. Each ETL query is also doing a table scan, causing MySQL innodb_buffer_pool_size to churn — normal OLTP queries are slower because their hot pages get evicted.
- T+5min: Connection pool (typically 20-100 connections per app server) starts filling. Normal query latency doubles.
- T+10min: 10 ETL queries running concurrently. Connection pool exhausted. New queries queue at the pool. Application response time p99 goes from 50ms to 5 seconds.
- T+15min: App servers start timing out. Load balancer sees increased 504s. Retry storm makes it worse.
Mitigation — what on-call does in the first 5 minutes.
- Alert fires: "MySQL pool queue > 20" or "p99 latency > 1s".
- Look at information_schema.processlist:
SELECT pid, state, query, backend_start FROM information_schema.processlist WHERE state = 'active' ORDER BY backend_start— shows 10 long-running ETL queries. Diagnosed. - Manual kill:
SELECT pg_cancel_backend(pid)for each ETL PID. Kills them cleanly. - Prevent re-run: temporarily disable the ETL cron (comment out the entry) OR reroute to read-replica.
- Comms: "Slow ETL query cascade. Killed offending queries. Site recovering. ETL routed to read-replica."
Post-mortem — what you change so it doesn't happen again.
- Statement timeout:
ALTER DATABASE prod_urls SET statement_timeout = '30s'— any query longer than 30s gets killed automatically. This makes the incident self-mitigating. - Separate connection pools by query type: OLTP pool (fast queries only, 100 connections) vs Analytics pool (slow queries, capped at 5 connections). Slow queries can't exhaust the fast pool.
- Read-replica routing: every analytics query must go through the replica. Add a code review check that any query with
GROUP BYorJOINgoes through the analytics router. - CI test: add a test that fails if any code path routes ETL queries to the primary.
- ProxySQL connection pooling (in transaction mode) in front of MySQL, so app servers see effectively unlimited connections and the actual DB has a fixed count.
Would L4 architecture handle this? L4 has no read replica, but also no ETL running on production DB (too small to have an analytics team). L5 with replicas would tolerate this because ETL is designed to go to the replica. Reference: MySQL's docs on statement_timeout / max_execution_time (dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_max_execution_time) — every production MySQL should have this set for SELECT statements.
Failure 4 — Bad deployment (memory leak, connection leak)
Trigger. A dependency update in a routine deploy adds a memory leak. Every request allocates a new object into a global cache without eviction. Memory grows by ~10 MB/minute per app server.
Real-world reference: Cloudflare's July 2019 outage was triggered by a regex that consumed CPU exponentially — same class of "bad deploy" event. Reference: blog.cloudflare.com/details-of-the-cloudflare-outage-on-july-2-2019.
Propagation.
- T+0 (deploy): Rolling deployment starts. First app server gets new code.
- T+15min: First server memory hits 4GB (of 8GB total). GC starts running aggressively. Latency spikes.
- T+30min: Memory hits 6.5GB. GC pauses cause 500ms latency stalls. p99 goes from 50ms to 500ms.
- T+40min: Memory hits 8GB. OOM-killer terminates the process. Kubernetes / ECS auto-restarts.
- T+41min: Restart. Fresh process. Cycle begins again.
- T+30min later: Second app server hits the same wall. Third one too. All app servers OOM-cycling every 40 minutes.
Meanwhile, users see cyclic latency spikes and occasional 502s (when they hit a restarting instance).
Mitigation — what on-call does in the first 5 minutes.
- Alert: "app_server_memory_pct > 90% on 3+ instances" and "OOMKilled events > 3 in 5 minutes".
- Recognize the cause: Grafana shows all instances tracking a similar memory growth pattern. Correlation with the recent deploy is obvious.
- Roll back the deploy. This is the L5+ discipline: every deploy is a git SHA that can be rolled back with one command. Not "roll forward" — that takes an hour to write a fix.
- Wait for rollback to propagate (5-10 min). New deployment reverts to previous SHA. Memory stops growing on newly-restarted instances.
- Comms: "Deploy at 14:00 caused memory leak. Rolled back at 14:23. Recovery in progress. RCA to follow."
Post-mortem — what you change so it doesn't happen again.
- Canary deploys: never full-fleet-roll a new SHA. Deploy to 1% of instances for 30 minutes, watch memory + latency + error rate. If clean, expand to 10%, then 50%, then 100%. Reference: Google SRE Workbook Chapter 16 on "Canarying Releases" — sre.google/workbook/canarying-releases.
- Feature flags for risky changes: the dependency update should have been behind a feature flag so it could be disabled without a deploy.
- Load tests before merge: the memory leak would have shown up in a 1-hour load test. Add a load-test gate to the CI pipeline for changes touching the request-handling path.
- Better memory alerts: alert at 70% memory (not 90%) to give lead time to react.
- Blameless review: the dependency update was small and looked innocuous. The review missed it. Add explicit "did you check memory allocation patterns?" to the review checklist.
Would L4 architecture handle this? L4 has 3 app servers on a single ASG — a bad deploy kills all 3 at once. Rolling deploys are only possible with multiple deployment stages, which most L4 teams have. But the incident is universal.
Failure 5 — DDoS via random 404 flooding
Trigger. Attacker floods sysd.link/AAAAAA, [sysd.link/AAAAAB](https://sysd.link/AAAAAB), [sysd.link/AAAAAC](https://sysd.link/AAAAAC), ... at 100K RPS from a botnet of 10K IPs. Every code is random — never in the cache, always a MySQL lookup.
Real-world reference: every URL shortener sees this attack. Bit.ly's engineering blog has discussed defensive posture. See also OWASP AI Top 10 (LLM04 in 2025 update: prompt-based DDoS is now a real thing too).
Propagation.
- T+0s: 100K RPS of random 6-char codes hit the CDN. Every one is a cache miss.
- T+0s to T+10s: CDN passes all 100K RPS to origin. Redis cache misses on all 100K (no such codes exist). MySQL sees 100K QPS of SELECT * FROM urls WHERE short_code = 'AAAAAA'.
- T+10s: MySQL does the lookups fine — each is an indexed miss (returns no rows) taking ~50µs. Total CPU: 100K × 50µs = 5 seconds of CPU per second = 250% CPU on a 2 vCPU box. Saturated.
- T+30s: MySQL queue depth grows. Latency for LEGITIMATE queries goes from 1ms to 500ms. Other URL redirects start timing out.
- T+60s: Same cascade as Failure 3 — connection pool exhaustion, app server timeouts, load balancer 503s, retry storm.
The DDoS cascade timeline, visualized
The prose above tells you what happens. This is what an on-call engineer sees on the dashboards, second by second — and where each defensive layer should have caught the attack:
| Time | Source (attacker) | CDN edge | Origin ALB | Redis | MySQL | p99 latency | On-call status |
|---|---|---|---|---|---|---|---|
| T-30s | 100 RPS organic | 95% hit | Trickle load | 92% hit | 25% CPU (baseline) | 50 ms (baseline) | 🟢 Green |
| T+0s | 100K RPS RANDOM CODES START | 0% hit (never seen before) | 100K RPS (all misses) | 0% hit (all misses) | 100K QPS on index scan | 100 ms (still ok) | 🟢 Green (below alert) |
| T+10s | 100K RPS | 0% hit | 100K RPS | 0% hit | 250% CPU (SATURATED, 2 vCPU box) | 500 ms | 🟡 YELLOW alert fires |
| T+30s | 100K RPS | 0% hit | 100K RPS | 0% hit | 100% CPU + conn pool full | 2 sec | 🔴 RED page fires |
| T+60s | 100K RPS | 0% hit | App servers start timing out = 503s | 0% hit | 100% CPU + pool exhausted | TIMEOUT (5s) | On-call opens dashboards |
| T+90s | 100K RPS | 0% hit | Cascade to ALL redirect endpoints → TOTAL OUTAGE | 0% hit | 100% CPU | 100% 5xx | Identifies 10K IPs + random codes |
| 🛡 T+90s MITIGATION | Enable Cloudflare "Under Attack Mode" | ||||||
| T+95s | 99% BLOCKED at CDN edge | JS-challenge intercepts botnet | ~5% of original traffic | 92% hit (organic resumes) | CPU falling 100% → 40% | Falling 5s → 1s | On-call posts #incident |
| T+2m | 99% blocked | Blocking maintained | Organic traffic restored | 92% hit | 25% CPU | 100 ms | 🟢 Green status |
| T+5m | Botnet gives up | Blocking can relax | Normal ops | 92% hit | 25% CPU | 50 ms | Post-mortem scheduled |
Where each defense layer should have caught it
| Layer | Purpose | State during this attack | If enabled, effect |
|---|---|---|---|
| Layer 1: CDN with negative caching | Cache "does not exist" for 60s on 404s | ❌ Missing | Second lookup of AAAAAA hits CDN, attack reduces to 1/60th impact. First defensive layer that would have made the attack a non-event. |
| Layer 2: WAF with per-IP rate limit | Cap unique short_code lookups at ~100/IP/min | ❌ Missing until T+90s reactive | Would have blocked the botnet at T+0s. Enabling reactively works but hurts users during response window. |
Layer 3: MySQL statement_timeout + pool separation | Kill long queries at 30s; isolate OLTP from analytics | ⚠️ Partially missing | Even if attack reaches PG, statement_timeout kills it; separate pools prevent legit pool exhaustion. Last-line defense. |
| Layer 4: Per-key rate limiting (auto-CDN-cache viral URLs) | Auto-cache hot short_codes at edge | N/A here (random codes) | Would matter for Failure 2 (viral stampede), not for this random-404 attack. |
Cost of each defense layer
| Layer | Cost | Payback |
|---|---|---|
| Layer 1 (negative caching) | $0 (config change on existing CDN) | Prevents this exact attack shape |
| Layer 2 (WAF rate limit) | $5/month + $0.60 per million requests | Prevents 99% of botnet attacks |
| Layer 3 (statement_timeout) | $0 (MySQL config) | Prevents cascade even if L1+L2 fail |
| Layer 4 (per-key CDN cache) | $0 (Cloudflare Workers logic) | Prevents viral stampede attacks |
| Total defense-in-depth | < $50/month at L5 traffic | ~99% attack prevention |
Payback math: 5-minute outage revenue impact at ~$1K/hour = ~$100 lost. Full-day outage from ineffective defenses = $24K + brand damage. Defense-in-depth pays back in ONE prevented incident.
Newbie mentor commentary:
- The alert fires at T+10s but page fires at T+30s. That's 20 seconds of yellow-to-red escalation — meaningful for a well-tuned system where the on-call has time to react before customer impact peaks. If your paging fires at yellow, the on-call gets alert fatigue; if it fires only at total outage, customer impact is already at 100%. Tune the thresholds carefully.
- T+95s recovery WITHOUT a code deploy. Notice the mitigation is a single Cloudflare setting change — no code, no rollback, no deploy. That's the value of edge defenses: they're reactive-safe. Compare to fixing a memory leak in Failure 4, which requires a rollback and 5+ minutes of propagation.
- The three defense LAYERS you must build. Every attacker who takes down a URL shortener bypasses at least one of these three: (a) negative caching at CDN, (b) always-on WAF rate limiting, (c) MySQL statement_timeout + pool separation. If you skip any layer, the next attacker uses that gap. Defense-in-depth is not paranoia — it's the price of running an internet-facing service.
- The $50/month cost is the whole answer. DDoS defense at L5 costs less than a mid-tier engineer's daily lunch budget. This is the cheapest insurance in your entire architecture. There is no defensible reason to skip it.
- The cascade is scale-triggered. At L4 (10K RPS baseline), a 100K-RPS attack is 10× baseline — takes the system offline immediately, no defenses in place. At L5 (100K RPS baseline), a 100K-RPS attack is 1× baseline load — the architecture absorbs the volume but the targeted queries (all-miss on random codes) exploit a query-pattern weakness. At L6 (1M RPS baseline with CDN 95% offload), a 100K-RPS attack IS ABSORBED entirely by the CDN cache layer — origin never sees it. CDN offload is DDoS defense-by-architecture.
Interview soundbite for the DDoS drill: "Random-404 DDoS is the canonical URL-shortener attack. My defense is 3 layers: (1) CDN negative caching so 404 lookups don't re-hit origin, (2) always-on WAF per-IP rate limit at 100 unique short_codes per minute, (3) MySQL statement_timeout=30s with separate OLTP/analytics pools. At L5 this costs ~$50/month and prevents ~99% of attacks. If an attack slips through all three, my incident response is: enable Cloudflare Under Attack Mode from the on-call runbook — recovery in ~60 seconds without a code deploy. Defense-in-depth means no single defense failure = full outage."
Mitigation — what on-call does in the first 5 minutes.
- Alert fires: "404 rate > 10K/sec sustained for 30s" or "MySQL CPU 100% + query rate 100× baseline".
- Identify the source: open the WAF (Cloudflare / AWS WAF) dashboard. See the traffic pattern: 10K unique IPs each hitting ~10 RPS with random 6-char codes. Attack signature is clear (no referer, no user-agent variation, random paths).
- Rate-limit at the edge: enable "Under Attack Mode" on Cloudflare — every request must solve a JS challenge before reaching your origin. Cuts 99% of the botnet traffic instantly.
- Add negative caching (see Failure 2 post-mortem): CDN caches "does not exist" for 60 seconds so subsequent lookups of AAAAAB don't hit the origin.
- Recovery within 60s of enabling the WAF rule.
Post-mortem — what you change so it doesn't happen again.
- Always-on WAF rate limiting per source IP: max 100 unique short_code lookups per minute per IP.
- Negative caching by default: all 404s cached for 30-60s at CDN.
- Per-URL rate limiting: any short_code seeing > 10K RPS gets auto-CDN-cached with a 5-min TTL (turns viral events into non-events).
- Anomaly detection: train a simple model on request patterns; alert on divergence from baseline (e.g., referer distribution shifts dramatically).
- Automatic escalation: if attack persists > 5 min, page the security team; if > 30 min, page the CTO.
Would L4 architecture handle this? L4 has no CDN, no WAF. This attack takes L4 fully offline within 30 seconds. This is why "no cache at L4" only works when you're not internet-facing or your traffic is below the attack-detection threshold. Real-world lesson: put Cloudflare Free tier in front of ANY internet-facing product from day 1.
Failure 6 — Cross-region replication lag (the "stale-writes" silent failure)
Trigger. Normal traffic. No visible failure. But: at L6, you're doing async replication from us-east-1 (primary write region) to eu-west-1 (read region for European users). Normal lag: 200ms. During heavy write traffic, lag climbs to 30 seconds due to redo log apply backpressure.
Real-world reference: every multi-region system with async replication has seen this. Discord's engineering blog has discussed this pattern (blog.discord.com — search "replication lag"), so has GitHub.
Propagation.
- T+0s: European marketing team creates a new short URL via the API endpoint. Write goes to us-east-1 primary. Success returned to user in ~200ms.
- T+0.2s: Cross-region replication starts. Under normal load: 200ms to reach eu-west-1 replica. Today: 30 seconds.
- T+1s: European user clicks the newly-created short URL. Read hits the eu-west-1 REPLICA. Cache lookup misses. DB replica lookup returns 0 rows. 404 returned to the user.
- T+5s: Marketing user's monitoring dashboard shows 404s on their own newly-created link. Panic.
- T+30s: Replication catches up. Now the URL exists. But the marketing user has already reported "broken links to CEO."
Nobody paged. No alert fires. Everything is "up." But users are seeing errors for reasons no dashboard shows.
Mitigation — first response (usually reactive, not real-time).
- Report comes from user: "our new URLs don't work in Europe for the first 30 seconds!"
- Look at replication lag metric:
performance_schema.replication_applier_status_by_workershows lag at 30s. This is the "quiet" failure — no alarms, but data was stale. - Confirm root cause: correlate 404 spike in eu-west-1 with replication lag graph. Match times.
- Manual mitigation: temporarily route reads from eu-west-1 to us-east-1 primary (accepts 200ms cross-region latency instead of 30s stale data). Users see instant redirect resolution again.
- Comms: "Replication lag caused stale reads in EU. Rerouted reads to primary. RCA to follow."
Post-mortem — what you change so it doesn't happen again.
- Alert on replication lag: page if lag > 5 seconds for 60 seconds. Then it's detected proactively.
- Read-your-writes on create: for the 60 seconds after a POST, the CLIENT gets a signed cookie that tells the read tier "route to primary." User sees their own new URLs immediately regardless of replica lag.
- Synchronous replication for the create-endpoint's happy path — accept the 200ms write latency to guarantee reads work everywhere immediately. Trade-off explicitly discussed in Chapter 7 (L6 consistency model).
- Educate customers: for enterprise API users, document explicitly that "URL creation is eventually visible globally in <5s under normal load." If someone needs strong consistency, offer a "sync" endpoint that costs 3× (rare but pays for the SLA).
Would L4 architecture handle this? L4 is single-region. No replication lag = no problem. This is a purely L6+ failure mode. The lesson: multi-region introduces failure modes you never had before. Every optimization has a cost.
The pattern across all 6 failures
Look at the shape of every incident above:
| Phase | What happens |
|---|---|
| Trigger | Something specific goes wrong |
| Propagation | The failure spreads BECAUSE OF the architecture (cascade) |
| Detection | Some metric crosses a threshold |
| Mitigation | On-call takes a specific action, typically at the L7 / edge / infra level |
| Recovery | The system self-heals or gets manual help |
| Post-mortem | Architectural change to make this failure impossible or trivial next time |
The senior engineer's mental model: every failure is architectural information. When something fails, you learn how the system is coupled. Then you decouple it.
Interview soundbite: "Give me any component in the system, and I can tell you the failure mode, the propagation time, the mitigation, and the post-mortem change. I've had to think about all of them — some in production, most in the design phase. That's what separates 'designed this' from 'operated this.'"
References (11 items)
- Nishtala et al. (2013) — "Scaling Memcache at Facebook," NSDI '13: usenix.org/conference/nsdi13/technical-sessions/presentation/nishtala. Section 3.5 covers stampede mitigation via leases.
- Vattani et al. (2015) — "Optimal Probabilistic Cache Stampede Prevention," VLDB '15: vldb.org/pvldb/vol8/p886-vattani.pdf. The PER algorithm.
- MySQL 8.0 documentation on `max_execution_time` (per-SELECT statement timeout): dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_max_execution_time.
- Google SRE Workbook — Canarying Releases: sre.google/workbook/canarying-releases. The definitive reference on progressive rollouts.
- Google SRE Book — Handling Overload: sre.google/sre-book/handling-overload. The definitive reference on load-shedding and graceful degradation.
- Cloudflare July 2, 2019 outage RCA: blog.cloudflare.com/details-of-the-cloudflare-outage-on-july-2-2019. Real post-mortem of a bad deploy (regex catastrophic backtracking).
- AWS Kinesis outage post-mortem (Nov 25, 2020): aws.amazon.com/message/11201/. Real cascading failure at the largest scale.
- Stripe engineering post-mortems: stripe.com/blog/tag/reliability. High-quality incident reports.
- Discord engineering blog: blog.discord.com/tagged/engineering. Multi-region and replication-lag content.
- AWS Builders' Library — Reliability, Constant Work, Sustained Availability: aws.amazon.com/builders-library. Amazon SDE culture around resilience patterns.
- Nygard, Michael (2018) — Release It! 2nd ed. (Pragmatic Bookshelf), ISBN 978-1680502398. The definitive book on production-resilience patterns.
Now the mental exercise I mentioned at the top: for each of these 6 failures, go through the architectures at L4/L5/L6/L7 and note (a) which level FIRST introduces the failure mode, (b) which level FIRST has the mitigation, (c) which mitigation was reactive vs proactive. That's the L6/L7 depth an interviewer will drill you on.
Redis down
- Every read misses the cache
- Cascading load onto the DB or shard
- DB p99 spikes
- Circuit breaker at service layer
- Shed low-priority reads
- Warm the cache slowly on recovery to avoid stampede
Primary DB down
- Writes fail immediately
- Reads still served by replicas + cache
- Automatic failover to a synchronous replica
- New writes queue at the service for up to N seconds
Region outage (1B scale)
- Traffic shifts via Anycast to neighboring regions
- Neighboring regions absorb ~1.5× traffic
- Pre-provisioned capacity slack (~30%)
- Global KV quorum writes still succeed with 2 of 3 regions
Every system fails. 6 canonical URL-shortener failure modes: Redis primary crash (cascading DB overload), cache stampede on viral URLs (thundering herd → single-flight fix), MySQL connection exhaustion (slow queries → statement_timeout + pool isolation), bad deployment (memory leak → canary + rollback discipline), DDoS 404 flood (WAF + negative caching + per-URL rate limits), cross-region replication lag (silent staleness → alert on lag + RYW). Every failure teaches a coupling that must be broken. Senior engineers design for graceful degradation, fast recovery, and predictable failure modes.
- What are the 6 failure modes of a URL shortener, in detail?
- How does cache stampede propagate and how does single-flight / PER prevent it?
- How do you diagnose and recover from a Redis primary failure?
- What's the runbook for a bad deployment (canary + rollback discipline)?
- How do you defend against a DDoS 404 flood at the edge?
- Why does cross-region replication lag cause a 'silent' failure and how do you detect it?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The pattern that makes retries safe — every failure recovery flow depends on this. Without idempotency, retries create duplicate URLs.
What clients see during a failure is defined by the consistency model you picked in Ch 5.5 / Ch 7. Failure modes look completely different under linearizable vs eventual.
Failover requires re-electing a leader — the algorithm behind MySQL Multi-AZ failover + Redis Sentinel failover we walked through above.
Why split-brain scenarios happen and how quorum + Raft prevent them. Foundational for understanding any distributed failure mode.
Chapter 10: the trade-off matrix. Every decision we've made — MySQL vs DynamoDB, cache-aside vs write-through, sync vs async — was a trade-off. In this chapter I show you the matrix that captures them all, side by side. This is the interviewer's cheat sheet for probing your reasoning.
The operational gaps
CRUD, expiration, monitoring — the boring stuff Educative covers that interviewers ask about
Every time I teach this system, students say "we've covered create and read — but what about update, delete, expire, and monitor?" Fair. Most system-design tutorials (Educative's Grokking chapter included) list these as bullet points and move on. Let me give you the same treatment we've given the hot paths: exact APIs, exact SQL, exact math, exact reasoning.
Gap #1: Custom aliases — "make it bit.ly/team-offsite"
Bit.ly Premium, TinyURL Custom, Rebrandly all let paying customers pick their own short code. Design this at L4 without breaking your uniqueness invariant.
API extension:
httpPOST /v1/urls Content-Type: application/json Idempotency-Key: uuid-v4-here Authorization: Bearer <api-key> { "long_url": "https://example.com/team-offsite-2026", "custom_alias": "team-offsite" // optional — server generates one if absent }
Server logic:
textif custom_alias present: 1. Validate format: /^[a-zA-Z0-9_-]{3,20}$/ 2. Check reserved words: ["admin", "api", "login", ...] 3. INSERT INTO urls (short_code=custom_alias, long_url) VALUES (?, ?) ON DUPLICATE KEY UPDATE short_code = short_code -- error out 4. If insert fails duplicate → 409 Conflict 5. If insert succeeds → 201 Created + full short URL else: Standard flow: KGS.pop() → INSERT → 201
The subtle design decision — reservation table or single table?
Two schemas:
Option A — single `urls` table (recommended): custom aliases live in the same PK space as generated codes. INSERT fights collision naturally. Simple.
Option B — separate `reserved_aliases` table + `urls` table: 2 tables. Pre-check reservation. More complex, more consistency risk.
Why Option A wins: the PK uniqueness constraint IS the reservation mechanism. Adding a second table doubles the write path (2 INSERTs) and creates a race window between "check reserved" and "insert URL". Option A has zero race — MySQL's PK is atomic.
Rate limiting custom aliases: premium feature = premium abuse target. Limit to 100 custom aliases/day/user on free tier, unlimited on paid. Track via user_id column (add when you build auth).
Reserved word list: these MUST be blocked to avoid impersonation:
textadmin, api, login, logout, signup, dashboard, settings, auth, oauth, help, support, about, terms, privacy, static, assets, cdn, www, blog, docs, [your product name], [your competitor names]
Store this in a config file, load into memory on service start. Reference: Bitly's reserved list discussed in various engineering posts.
Gap #2: Update — "change the destination of an existing short URL"
Interviewer follow-up: "A paying customer edits their team-offsite URL to point to a new venue. What happens?"
API:
httpPATCH /v1/urls/team-offsite Content-Type: application/json Authorization: Bearer <api-key> { "long_url": "https://example.com/new-venue" }
Server logic:
sqlUPDATE urls SET long_url = ?, updated_at = NOW() WHERE short_code = ? AND user_id = ? -- authorization: only owner can edit
The 3 subtle issues:
- Cache invalidation. After UPDATE, the redirect cache (Redis) still has the OLD long_url. Solutions:
- Audit trail. Financial URLs, phishing risk, legal discovery — you MUST log every change:
sqlCREATE TABLE url_audit ( audit_id BIGINT AUTO_INCREMENT PRIMARY KEY, short_code CHAR(7) NOT NULL, old_long_url VARCHAR(2048), new_long_url VARCHAR(2048), changed_by BIGINT, changed_at TIMESTAMP(3), INDEX idx_short_code_time (short_code, changed_at) ) ENGINE=InnoDB;
Insert an audit row inside the same transaction as the UPDATE. GDPR right-to-erasure has to purge audit rows too.
- Cache stampede on hot URL. If a viral short URL is updated, the cache invalidation triggers a stampede — millions of readers race to reload. Mitigation:
Interview soundbite: "Updates are 30% of the interview trap. The naive answer is UPDATE urls SET long_url = ?. The senior answer is: (1) cache-invalidate via pubsub, (2) audit-log inside the same transaction, (3) prevent stampede with write-through cache on hot URLs. That's the difference between a working system and a production system."
Gap #3: Delete — "expire this URL immediately"
API:
httpDELETE /v1/urls/team-offsite Authorization: Bearer <api-key>
Server logic (hard delete, per our Ch 4 decision):
sqlBEGIN; INSERT INTO url_audit (short_code, old_long_url, changed_by, action) VALUES (?, (SELECT long_url FROM urls WHERE short_code = ?), ?, 'DELETE'); DELETE FROM urls WHERE short_code = ? AND user_id = ?; COMMIT; redis.del("url:team-offsite"); -- invalidate cache kafka.publish("url.deleted", {code, user_id, deleted_at}); -- fan out
After delete, what happens to redirect requests?
- 410 Gone (semantic: "existed, now permanently unavailable")
- NOT 404 Not Found (semantically wrong)
- NOT 200 OK to a "sorry, deleted" page (wastes client caching)
Serve 410 with a small HTML explanation for browsers, empty body for API clients. Cache the 410 for a few minutes (Cache-Control: public, max-age=300).
Bulk delete for GDPR "right to erasure":
sqlDELETE FROM urls WHERE user_id = ? LIMIT 1000; -- batch to avoid table lock
Loop until 0 rows affected. Reference: MySQL DELETE with LIMIT.
Gap #4: Expiration — "TTL cleanup at 100B rows"
The problem: at L6+ (1M creates/day × 365 × 5 years = 1.8B rows), storage grows unbounded. Some URLs have expires_at. How do we reclaim space efficiently?
The naive approach (BAD):
sqlDELETE FROM urls WHERE expires_at < NOW(); -- runs once a night
Why this fails at scale:
- Table lock during large DELETE — blocks reads for minutes
- Redo log explosion — deleting 100M rows generates GB of redo
- Replication lag — replicas fall hours behind
- Fragmentation — deleted pages don't get reclaimed automatically
The senior approach — chunked batch cleanup:
text# Runs every hour, deletes in batches LOOP: affected = DELETE FROM urls WHERE expires_at < NOW() LIMIT 1000 IF affected < 1000: SLEEP 300; CONTINUE ELSE: SLEEP 10 # small pause to let replication catch up
Math for cleanup throughput:
- 1000 rows/batch × 6 batches/min = 6000 rows/min = 360K rows/hour
- At 100M expired URLs = 100M / 360K = 278 hours = ~12 days to catch up from cold start
- Steady-state: if 100K URLs expire/day, we clean at 6000/min = 8.6M/day. Massive headroom.
Alternative — partitioned table with DROP PARTITION:
Partition by created_at (monthly). Expired data lives in old partitions. Drop entire partitions with metadata operation (< 1 second, no row-by-row DELETE):
sqlALTER TABLE urls DROP PARTITION p_2020_11;
Trade-off: partitioning imposes structural constraint (short_code lookups must include created_at or all partitions scanned). Not worth it for URL shortener where 99% of lookups are by short_code alone. Chunked DELETE wins for our workload.
Reference: MySQL Partitioning docs, pt-archiver from Percona for the industrial-grade batch delete tool.
Gap #5: Monitoring — the SLO / SLI / error budget stack
The classic Educative outline lists "monitoring" as a bullet. Here's what a senior expects:
Define the SLIs (Service Level Indicators — what you measure):
| Path | SLI | Target (SLO) | Consequence if breached |
|---|---|---|---|
| Redirect | Availability (2xx-3xx response rate) | 99.99% | User-facing 5xx, brand damage |
| Redirect | P99 latency | < 100 ms | Perceived slowness, drop-off |
| Create | Availability | 99.9% | Failed creates, retry pressure |
| Create | P99 latency | < 500 ms | Poor UX in dashboard |
| Analytics | Freshness (event-to-dashboard) | < 5 min | Delayed customer insights |
Error budget math:
- 99.99% availability = 0.01% error budget = 52.6 min/year of allowed downtime
- 99.9% = 8.76 hours/year
- 99.99% redirect for 1M RPS = 6 errored requests/sec allowed before budget alarm
The alert cascade:
- SLO burn-rate alerting (not raw threshold). Google SRE workbook Ch 5. Alert if we're burning error budget 14× faster than sustainable (page). Alert if 6× (ticket).
- Symptom-based alerts, not cause-based. "P99 > 100ms" not "MySQL CPU > 80%". Cause-based alerts fire 10× more often and page for non-issues.
- Runbook per alert. Every page has a linked runbook: "First: check Grafana dashboard X. Second: run script Y. Third: page secondary if not resolved in 10 min."
The Grafana dashboard (minimal viable, 12 panels):
textRow 1: SLO panels [Availability last 30d] [P99 last 30d] [Error budget remaining] Row 2: Redirect hot path [QPS] [Cache hit rate] [MySQL PK lookup latency] Row 3: Create path [Creates/sec] [KGS pool depth] [Duplicate rate] Row 4: Infrastructure [App server CPU] [MySQL replication lag] [Redis memory %]
Golden signals (Beyer et al. — SRE book Ch 6):
- Latency — per endpoint, P50/P95/P99
- Traffic — QPS per endpoint
- Errors — per HTTP status class + per exception type
- Saturation — CPU / memory / disk / network as % of capacity
Reference: Google SRE Workbook Ch 5 — SLO burn-rate alerting, Beyer et al. Site Reliability Engineering Ch 6.
What Educative covers that we don't (yet)
Comparing our L4-L7 journey against Educative's Grokking System Design URL Shortener chapter:
| Educative section | Our coverage | Gap? |
|---|---|---|
| Introduction | Ch 0 (Sound of the door) | ✅ Deeper — real Bitly business context |
| Functional/Non-functional requirements | Ch 1 | ✅ Covered |
| Capacity estimation | Ch 2 + Ch 4.75 (derivation) | ✅ Deeper — first-principles math |
| API design | Ch 3 + Ch 3.5 | ✅ Deeper — 301 vs 302 fact-check |
| Database design | Ch 4 (CREATE TABLE + lookup flow) | ✅ Deeper — per-column reasoning + I/O breakdown |
| Short URL generation | Ch 4.5 (5 approaches + base62 mechanics) | ✅ Deeper — algorithm + rejection sampling |
| Cache design | Ch 6 + Ch 6.5 | ✅ Deeper — hot-key + replicas |
| Redirection workflow | Ch 5 MVP + Ch 4 lookup flow | ✅ Covered |
| Load balancer | Ch 5 (Multi-AZ + rolling deploy) | ✅ Covered — with derivation |
| Rate limiter | Ch 9 failures + Ch 7.6 security | ✅ Covered |
| Analytics | Ch 7.5 (Kafka pipeline) | ✅ Deeper — exactly-once semantics |
| Security | Ch 7.6 + Ch 7.7 | ✅ Deeper — 5-layer defense |
| Multi-region | Ch 7 (L6) + Ch 8 (L7) | ✅ Deeper — active-active vs active-passive |
| Summary + trade-offs | Ch 10 (trade-off matrix) | ✅ Covered |
| CRUD operations | Was scattered | ✅ THIS CHAPTER |
| Expiration cleanup | Was scattered | ✅ THIS CHAPTER |
| Monitoring / SLOs | Was scattered | ✅ THIS CHAPTER |
Verdict: with this chapter, we now cover 100% of Educative's outline PLUS the derivation depth Educative doesn't give.
References
- Educative — Grokking System Design URL Shortener — the canonical outline this chapter closes gaps against.
- Google SRE Workbook — Alerting on SLOs — burn-rate alerting math.
- Beyer et al. — Site Reliability Engineering (2016) — SLI/SLO/error budget foundations.
- Percona pt-archiver — industrial-grade batch delete for expiration cleanup.
- MySQL Partitioning docs — DROP PARTITION alternative to chunked DELETE.
- Redis Distributed Locks (Redlock) — cache stampede prevention on hot URL update.
- Redis PubSub for cache invalidation — multi-region cache invalidation.
Short-code generation
- Simple
- No coordinator needed
- Collisions grow with volume; requires retry-on-conflict
- No collisions
- Compact
- Needs a distributed counter (ZooKeeper, Redis INCR, DB sequence)
- Distributed, no coordinator
- Time-ordered (useful for TTL cleanup)
- Codes are longer (~12 chars) and can reveal time
Redirect status code
- Browsers cache; less origin load
- Analytics undercount (subsequent visits skip origin)
- Every click hits origin — accurate analytics
- More origin load
The 5 operational gaps every real URL shortener must handle: (1) custom aliases via same-table PK collision, (2) update with cache-invalidate + audit + stampede protection, (3) hard delete with 410 Gone, (4) chunked expiration cleanup (not naive DELETE), (5) SLO/SLI/error-budget monitoring with burn-rate alerts. Together these close the last Educative-outline gap.
- How do custom aliases work without a separate reservation table?
- What are the 3 subtle issues in the update flow?
- Why is naive DELETE FROM urls WHERE expires_at < NOW() catastrophic?
- What's the burn-rate alert math for 99.99% availability?
- What Educative-outline items were previously scattered in our journey?
Every concept below has its own interactive, animated page in the Learning Tracks section. Read them any time you want to go deeper than the mentor prose above — they're the reusable foundation this chapter is built on.
The pubsub invalidation pattern in Gap #2 above is one strategy; TTL and versioning are others. Pick correctly.
Prevents cache stampede on hot URL update. Also the pattern behind KGS pool-refresh coordination.
Chapter 10: the trade-off matrix. With CRUD + cleanup + monitoring covered, every design decision now has a concrete alternative + a why-not answer. Time to lay it all out side-by-side so you can defend it in an interview.
The trade-off matrix
Every big decision, side by side
This is the chapter interviewers use to test your reasoning depth.
They'll ask: "You picked MySQL. Why not DynamoDB?" And you can't just say "because MySQL." You have to lay out the alternatives and explain your reasoning.
That's what a trade-off matrix is. It's the same information you already know, but organized so the comparison is explicit. Interviewers use them; you should too.
Below you'll find the 4 biggest trade-offs for a URL shortener, each with 2-3 alternatives, each with pros/cons/verdict. Read them all. Then — this is the important part — look at each recommendation and ask yourself: could I argue the opposite?
If you can only argue for the chosen answer, you're not ready. If you can argue for either side and explain when each wins, you are ready. That flexibility is what Staff/Principal interviewers are looking for.
One thing to notice: trade-offs change with scale. At L4, MySQL wins over DynamoDB. At L7, the answer might flip. The trade-off matrix isn't a single answer — it's a function of your context (scale, team, budget, compliance, timeline). Interviewers know this. Show that YOU know this.
## The scale-shift matrix — how each decision flips as you grow
Here is the visual that separates a Senior candidate from a Staff+ one. Every non-trivial architectural decision changes owner as scale changes. A newbie memorizes "MySQL for URL shorteners." A senior explains WHEN that answer flips and WHY. This chart is the full 4-scale-tier view of the 7 biggest decisions in a URL shortener design:
| Decision | L4 (10K RPS) | L5 (100K RPS) | L6 (1M RPS) | L7 (1B RPS) |
|---|---|---|---|---|
| 1. Primary DB | MySQL single AZ, Multi-AZ standby | MySQL + 2 read replicas | MySQL sharded (Citus or Vitess) | MySQL, or Cassandra if write-heavy, or DynamoDB if AWS-committed |
| 2. Cache | None (MySQL page cache is enough) | Redis cache-aside, single node | Redis Cluster per region | Regional Redis + Cloudflare Workers KV at edge |
| 3. Read consistency | Strong (single node) | Strong within region; eventual to replicas | Read-your-writes local; eventual global < 1s | Read-your-writes local; eventual global; strong for user + billing state (per-datatype) |
| 4. Short-code gen | Random + collision retry | Random + collision retry; counter+base62 at write nodes | KGS sharded pool per region | Regional KGS + Snowflake bits for cross-region ordering |
| 5. Failover | Multi-AZ, 30-90s | Sentinel promote 15s; PG standby | Regional failover + DNS TTL 60s | Regional failover + BGP anycast + CDN shift <5s |
| 6. Rate limiting | None (or dev tier of Cloudflare) | Per-IP at ALB (WAF) | Per-IP + per-API-key at edge + origin | Per-IP + per-API-key at edge + adaptive throttling |
| 7. Analytics | In-DB UPDATE counter | Redis INCR + nightly ETL to PG | Kafka + Flink + Redis + PG (dual sink) | Regional Kafka + CDN edge log stream + tiered storage (Redis + ClickHouse + S3 Parquet) |
Why each decision flips
- Decisions 1-2 (DB, cache) — ceiling of the primitive at each scale. Single MySQL tops at ~15K QPS; vertical scaling buys 3× for 8× cost; sharding unlocks linear scale but adds routing complexity.
- Decision 3 (consistency) — latency budget forces the flip. Cross-region sync costs 200-500ms. At L4-L5 we pay for strong; at L6+ we pay for latency and give up cross-region strong on non-critical datatypes.
- Decision 4 (short-code) — collision-retry rate grows with URL count. At 100M URLs random+retry is fine; at 100B URLs it becomes a hot spot forcing KGS pre-allocation.
- Decision 5 (failover) — blast radius scales with tier. Multi-AZ handles AZ failure (L4); regional failover handles region failure (L6); BGP anycast + CDN handle continent-scale events (L7).
- Decision 6 (rate limiting) — attack surface grows with reach. Not-yet-internet-facing = no need; internet-facing = MUST have; global = MUST have adaptive.
- Decision 7 (analytics) — write throughput ceiling forces the flip. In-DB UPDATE dies at row-lock contention on hot short_code (L5); Redis INCR dies at Redis memory ceiling for history (L6); Kafka dies at cross-region egress cost (L7).
The meta-pattern
Every decision above is an OPEN QUESTION at every tier. What changes is not that you MUST evolve — it is that a SPECIFIC metric crosses a SPECIFIC threshold that FORCES the evolution.
- Junior: memorizes L4 answers ("MySQL wins")
- Senior: memorizes the transition thresholds ("MySQL wins until sustained CPU > 70% or p99 > 100ms")
- Staff+: memorizes the FUNCTION — "given these 5 answers, the architecture is X; if any answer changes, here is what flips"
You are training the Staff+ view. Every matrix below is inputs; the ROW in this chart is your prediction for the correct output at each scale.
Read this chart before the 4 matrices below. The matrices give you the why for each decision at a specific scale. This chart gives you the shape of how those decisions compose and shift.
Interview soundbite for this visual: "Every non-trivial decision in this design is a function of scale, not a fixed answer. MySQL is our primary DB at L4-L6; at L7 it might flip to sharded MySQL+Citus, Cassandra, or DynamoDB depending on write-heavy pattern and cloud commitment. Consistency is strong at L4 within a single AZ; per-datatype at L6+ because cross-region sync costs 200-500ms per write. Short-code generation is random+retry until collision rate exceeds ~1% around 100B URLs, then flips to KGS pre-allocation. I don't memorize architectures — I memorize the transition thresholds."
Matrix 1 — Primary database
We covered this deeply in Chapter 5.5. Here's the compressed matrix for interview recall:
| Option | Pros | Cons | When it wins | Verdict for URL Shortener |
|---|---|---|---|---|
| MySQL | Rich SQL, MVCC, mature tooling, JSON, familiar, cheap ops | Sharding is 3rd-party (Citus), some ops burden | Team knows MySQL, workload has point lookups + occasional joins, cost matters | ✅ Default L4-L6. ~$170/mo at L4, ~$18K/mo at L6 |
| MySQL + Vitess | Vitess = native sharding at YouTube scale, InnoDB clustered index slightly faster for by-PK lookup | MySQL wins on JSON + full-text search + query planner | Team is MySQL shop, expecting L6+ scale with heavy sharding needs | ✅ L6+ if team is MySQL-native. Same L4/L5 pattern as MySQL |
| DynamoDB | Zero ops, auto-scale, Global Tables for multi-region, predictable p99 | 15-30× more expensive at scale ($7K/mo at 10K RPS vs $400/mo MySQL), AWS lock-in, weak query patterns | AWS-only, small ops team, willing to pay for ops relief | ⚠️ Cost-prohibitive above L5 unless AWS-committed with ops-budget constraint |
| Cassandra | Linear write scaling, tunable consistency per query, native multi-region active-active | Overkill below L7, 5+ node minimum ($2K/mo before workload), Java ops burden | 1B+ writes/sec globally, write-heavy pattern (unusual for shorteners) | ❌ Wrong tool below L7. Right for Discord/Netflix scale on this kind of shape |
Argue the opposite: the interviewer says "But DynamoDB has Global Tables — isn't that easier than your MySQL multi-region story?" Your answer: "Yes, DynamoDB Global Tables gives you multi-region for zero engineering. The trade-off is cost: at 1M RPS my MySQL + Aurora Global cluster is ~$18K/mo; DynamoDB Global at the same load is ~$300K/mo. If our unit economics support the 15× premium for ops relief, DynamoDB wins. If not, MySQL."
See Chapter 5.5 for the full head-to-head with derivations.
Matrix 2 — Cache pattern
We covered cache DECISION in Chapter 6.5 (which product); this matrix covers cache PATTERN (how you use it). Different question.
| Pattern | Read path | Write path | Consistency | When it wins | Verdict |
|---|---|---|---|---|---|
| Cache-aside (lazy load) | App checks cache, misses hit DB and populate | App writes to DB, then invalidates cache | Eventually consistent; stale-read window = TTL | Read-heavy with tolerable staleness (URL shortener is 100:1 R:W) | ✅ The default for URL Shortener. Simple, robust, well-understood |
| Read-through | App reads from cache; cache itself fetches on miss | App writes to cache, cache writes to DB | Same as cache-aside but cache handles fetch logic | You're using a cache product with built-in read-through (DAX, Aurora reader) | ⚠️ Same trade-off; only wins if the cache product handles it for you |
| Write-through | App reads from cache | App writes to cache, cache immediately writes to DB (synchronous) | Strong on write side | Write-heavy workloads that need consistency | ❌ Wrong for URL Shortener — writes are rare, sync-write cost isn't worth it |
| Write-behind (write-back) | App reads from cache | App writes to cache, cache batches writes to DB async | Reads see fresh writes; DB may lag by seconds | Extreme write throughput (metric counters, IoT) | ❌ Wrong for URL Shortener — durability matters (shortened URLs can't be lost) |
| No cache | Direct to DB | Direct to DB | Strong | Below the Zipf-cache-payoff threshold | ✅ L4 pick. Simple. |
Argue the opposite: the interviewer says "But if you cache-aside and Redis goes down, you're in trouble — write-through would keep the DB current." Your answer: "Correct, but at 100:1 R:W the cost of write-through (double the write latency, coupling DB failures with cache failures) exceeds the benefit. My Chapter 9 Redis-primary-dies failure mode has a 5-minute recovery via cache warmup. That's acceptable. Write-through would MAKE that failure worse by tying more failure modes to the write path."
See Chapters 6.5 and 9 for the full analysis.
Matrix 3 — Consistency model (per data type)
The senior insight: you don't pick ONE consistency model for the whole system. You pick per data type based on the actual product requirement.
| Data type | Options | What we pick | Why |
|---|---|---|---|
| URL creation → read by creator | Strong / Read-your-writes / Eventual | Read-your-writes | Creator hits "shorten," they must see the URL work immediately. Implemented via cache invalidation-on-write + client-signed cookie routing reads to primary for 60s |
| URL creation → read by 3rd party | Strong / Eventual | Eventual (<1s globally) | 3rd parties clicking a newly-created URL can tolerate a few hundred ms staleness. Async cross-region replication delivers this |
| Analytics counters (click count) | Strong / Eventual with monotonic | Eventual with monotonic reads | Real-time counter can lag by seconds (see Ch 7.5); refresh shows a value ≥ prior refresh (not going backwards) |
| User account state (upgraded to Pro tier) | Strong / Read-your-writes | Strong (multi-region sync write) | Money changed hands. If the user pays and the "you're now Pro" state isn't visible, they'll open a support ticket and rage-tweet |
| Rate-limit counters | Strong / Approximate | Approximate (bucket per region, converge async) | 1% over-serving during partition is acceptable; cross-region sync would 10x the latency |
| Malicious URL denylist | Strong / Eventual | Eventual (with fast propagation) | New malicious URL discovered in Region A; other regions can serve it for a few seconds before the denylist propagates. Trade-off: brief bad-URL exposure vs multi-region write coordination |
Argue the opposite: "Why don't you just make everything strong for simplicity?" Your answer: "Strong consistency across 3 regions costs 200-500ms per write (needs quorum ack across geographic distances). At 1M RPS with even 1% writes = 10K writes/sec, sync would either kill our write throughput or force us into a Spanner-class database with TrueTime — which starts at ~$50K/mo. Per-datatype consistency is architecturally cheaper AND semantically more precise."
Reference: Kleppmann DDIA Chapter 5 (Replication) + Chapter 9 (Consistency & Consensus). Every consistency word above ("read-your-writes", "monotonic reads") is a term of art with a formal definition — worth internalizing.
Matrix 4 — Scaling: vertical vs horizontal, sharding vs replication
The "how do we grow" trade-off, per component:
| Component | Vertical (bigger box) | Horizontal (more boxes) | Our choice at each scale | Why |
|---|---|---|---|---|
| App servers | Diminishing returns above 32 vCPU | Linear scale-out (stateless) | Horizontal from L4. ASG with health checks | Stateless = trivial to scale out. Cheaper per RPS. Better failure isolation |
| MySQL primary | m6i.large → 4xlarge → 8xlarge = ~3× throughput per 8× cost | Sharding = linear scale but complex | Vertical to L5 (m6i.4xlarge handles 100K RPS with cache); Horizontal from L6 (Vitess or Citus) | Sharding introduces cross-shard query complexity. Delay it until vertical hits the ceiling |
| MySQL reads | Same as primary | Add read replicas (cheap, async) | Horizontal from L5. 2-3 read replicas | Reads are 99% of load. Async replicas are near-linear scale for reads |
| Redis | Single-threaded per shard = vertical hits ceiling fast | Redis Cluster = 16,384 hash slots | Vertical to L5 (single node holds top-1M keys); Horizontal from L6 (cluster) | Same delay-sharding rule. Single-node Redis holds 100M keys before we bother clustering |
| Load balancer | ALB scales automatically | Multi-region LB (Route53 latency-based) | Vertical to L5 (single ALB); Multi-region from L6 (Route53 + regional ALBs) | ALB auto-scales; no reason to shard until multi-region |
| CDN | Doesn't apply — CDN vendors handle scale | Multi-vendor for redundancy | Cloudflare from L4 (free tier); multi-CDN from L7 (belt-and-suspenders) | CDN vendors solve scale. Multi-vendor is defensive (see 2019 Fastly outage) |
| Analytics (Kafka + Flink) | Not typically | Add partitions + consumer parallelism | Horizontal from L6 (see Ch 7.5) | Kafka is designed to shard; each partition adds parallel capacity |
Argue the opposite: "Why don't you just shard everything from day 1 so you never have to migrate?" Your answer: "Because sharding has a real engineering cost — cross-shard queries, rebalance procedures, hot-shard detection, connection pool per shard. At 10K RPS on one MySQL primary, adding shards buys me nothing except operational complexity. The RIGHT time to shard is when vertical scaling hits the ceiling AND you're within 6 months of that ceiling under organic growth. Sharding earlier is premature optimization; sharding later is an outage. Do it just before you need it."
Reference: Karger et al. 1997 "Consistent Hashing and Random Trees" (for the sharding algorithm), Kleppmann DDIA Chapter 6 (Partitioning). Both cover the underlying math.
The meta-matrix — how trade-offs shift across scales
This is the summary chart every senior candidate should be able to recite:
| Trade-off | L4 pick | L5 pick | L6 pick | L7 pick |
|---|---|---|---|---|
| Database | MySQL single-primary | + read replicas | Sharded (MySQL+Citus or MySQL+Vitess) | Depends on business (may buy DynamoDB) |
| Cache | None | Redis single | Redis Cluster + app LRU | CDN + app LRU + Redis + DB (4 tiers) |
| Consistency | Strong (one node) | Read-your-writes on cache invalidation | Per-datatype (see Matrix 3) | Same + capital-allocation cost analysis |
| Sharding | No | No | Yes, by short_code hash | Yes + region-prefixed IDs |
| Multi-region | No | No | Yes, active-active reads + primary writes | Yes, active-active everything + own CDN |
| Cost / RPS | $0.04 / 1K req | $0.02 / 1K req | $0.02 / 1K req | $0.01 / 1K req (own CDN economics) |
| Team size | 3 engineers | 5-10 | 20-40 | 200+ |
| Time-to-ship | 8 weeks | 3 months | 6-18 months | Ongoing |
The pattern: every 10× scale factor changes the answer to 3-5 of the trade-offs above. Which is why memorizing "the URL Shortener architecture" is useless — you must know how each trade-off shifts.
Interview soundbite
When the interviewer says "walk me through your trade-offs":
"Four big ones: database, cache pattern, consistency, and scaling axis. For each I picked the option that fits the current scale AND named the transition point where the answer changes. MySQL at L4-L6, may flip to DynamoDB at L7 if AWS-committed. Cache-aside for the 100:1 R:W read-heavy pattern; not write-through because writes are rare. Per-datatype consistency — read-your-writes on the creator's own URLs, eventual for analytics counters, strong on billing state. Vertical scaling until we hit the box ceiling, then horizontal — because sharding early is complexity for zero benefit. The core insight is that trade-offs shift as scale changes; every 10× requires re-visiting 3-5 of these choices."
That's a 60-second answer that demonstrates architectural maturity.
References (9 items)
- Kleppmann, Martin (2017) — Designing Data-Intensive Applications (O'Reilly). Chapter 5 (Replication), Chapter 6 (Partitioning), Chapter 9 (Consistency & Consensus). Every consistency term used above has a formal definition here.
- Karger et al. (1997) — "Consistent Hashing and Random Trees," STOC '97. dl.acm.org/doi/10.1145/258533.258660. The mathematical foundation of the sharding matrix.
- Amazon DynamoDB pricing: aws.amazon.com/dynamodb/pricing. Source for the "15-30× cost premium" claim.
- Vitess documentation: vitess.io. Source for "YouTube-scale sharding without downtime" claim.
- Fastly outage post-mortem (June 8, 2021): fastly.com/blog/summary-of-june-8-outage. Justifies multi-CDN defense at L7.
- Google SRE Book — Data Integrity: sre.google/sre-book/data-integrity. The definitive reference on per-datatype consistency in production.
- Chapter 5.5 of this journey — the full DB comparison with derivations.
- Chapter 6.5 of this journey — the full cache comparison with derivations.
- Chapter 7.5 of this journey — analytics pipeline consistency model.
Every argument above cross-references a specific deep-dive chapter. A trade-off matrix is a compressed teacher; the deep-dive chapter is where you actually learn. Use this matrix as a memory device, not a substitute for understanding.
Short-code generation
- Simple
- No coordinator needed
- Collisions grow with volume; requires retry-on-conflict
- No collisions
- Compact
- Needs a distributed counter (ZooKeeper, Redis INCR, DB sequence)
- Distributed, no coordinator
- Time-ordered (useful for TTL cleanup)
- Codes are longer (~12 chars) and can reveal time
Redirect status code
- Browsers cache; less origin load
- Analytics undercount (subsequent visits skip origin)
- Every click hits origin — accurate analytics
- More origin load
Every decision is a trade-off. A trade-off matrix makes alternatives explicit. Interviewers probe by asking 'why not X?' — you must be able to argue for the alternative before you argue against it.
- How do you structure a trade-off matrix?
- How does the answer change with scale?
- What does 'argue the opposite side' mean?
- What are the 4 biggest trade-offs for a URL shortener?
Chapter 11: the masterclass. This is where I show you what a Staff+ engineer produces beyond the design. An Architecture Decision Record. A business constraint exercise. A production incident. These are the three artifacts that turn 'designed a system' into 'led a system through its lifecycle.'
The masterclass
ADR, business exercise, production incident
We've now designed the system at 4 scales, catalogued its failures, and mapped its trade-offs. That's a great intermediate-senior answer.
But there are three artifacts a Staff/Principal engineer produces that separate them from senior engineers. In this chapter I'm going to walk you through one concrete example of each — an ADR, a business exercise, and a production incident. You'll see the shape. Then you can reproduce it for any system on the platform.
1. Architecture Decision Record (ADR) — When you make a big decision (MySQL vs DynamoDB), you document it. Context. Options considered. Chosen. Why. What we accepted as trade-off. What would make us reverse this decision. This is how real teams communicate architectural choices to future engineers. Interviewers ask "how would you document this?" and 90% of candidates say "in a design doc" — which is wrong. The right answer is "an ADR." Show them.
2. Business constraint exercise — Real design happens under constraints. 3 engineers, 8 weeks, $10K/month budget, CEO wants microservices. What do you build? How do you push back? This is where you show engineering judgment — not technical purity. The best answer is often "here's why we're NOT doing what the CEO asked, and here's the alternative that solves their actual problem."
3. Production incident scenario — 3:47am. P99 latency spiked from 50ms to 8 seconds. You're on-call. Walk me through the investigation. This is the interview equivalent of "prove you've actually run a system in production." Metrics interpretation, hypothesis formation, mitigation, post-mortem. All under time pressure.
Let me show you exactly what each looks like.
---
## Artifact 1 — The Architecture Decision Record (ADR)
An ADR is a short, immutable document that records a significant architectural decision. Michael Nygard invented the format in 2011 (his blog post "Documenting Architecture Decisions" — cognitect.com/blog/2011/11/15/documenting-architecture-decisions — is the founding text and worth reading in full).
Every ADR has the same 6 sections. Here's a real one for the URL Shortener's biggest decision:
markdown# ADR-0007: Choose MySQL as primary database for URL Shortener **Status:** Accepted · 2025-02-14 · sysd-eng-arch team **Deciders:** @lead-arch, @sre-lead, @platform-eng-lead **Consulted:** @db-team, @ops **Informed:** all-eng ## Context We're building a URL shortener targeting ~100K RPS peak in year 1 (anticipated growth to 1M by year 3). Workload is 100:1 read:write. Single-row point-lookups by short_code dominate. Write pattern is predictable (~1K/sec). We need a durable, indexed key-value store that our engineers can operate confidently. Four candidates evaluated: MySQL, MySQL+Vitess, DynamoDB, Cassandra. ## Decision We will use **MySQL 16 (RDS Multi-AZ)** as the primary database for URL storage and metadata. Sharding via Citus deferred to L6+. ## Consequences ### Positive - Team already runs MySQL in 3 other services; no operational learning curve - Rich SQL enables ad-hoc analytics without a second query engine - Cost is ~$170/mo at L4 scale, growing to ~$18K/mo at L6 (via Aurora Global Database) — 15-30× cheaper than DynamoDB at these scales per Chapter 5.5 pricing math - JSON column type available for future metadata expansion without schema-migration pain - Multi-AZ synchronous replication gives 99.95% availability out of the box ### Negative - Sharding is not native; will require Citus (mature) or application-level (custom) at L6+. We accept this as ~6 months of eng work in Q3 2026 when we hit 200K writes/sec ceiling - No native multi-region active-active writes; when we go global, we'll use Aurora Global (async) and accept eventual consistency for URL creation → global visibility (~200ms typical, up to 1s) - MySQL query planner can occasionally pick bad plans on parameterized queries; we mitigate with EXPLAIN ANALYZE in CI and pg_hint_plan when needed ## Alternatives considered | Option | Reason rejected | |---|---| | **DynamoDB** | 15-30× more expensive at scale ($642K/mo at 1M RPS vs $18K/mo MySQL per Ch 5.5). AWS lock-in. Would win if AWS-committed with different cost sensitivity, but not our case. | | **MySQL + Vitess** | Team doesn't run MySQL. Would require hiring / retraining. Vitess is superior to Citus for sharding but the team-cost is higher than the sharding-cost benefit for our expected scale trajectory. | | **Cassandra** | Over-engineered below L7. 5-node minimum (~$2K/mo before any workload) for correctness. Write-parallel scaling wasted on our 100:1 R:W workload. | ## Trade-offs accepted - Sharding will be a project (not free). Timeline: kick off at 200K writes/sec threshold. - Multi-region writes eventually consistent (not strongly). We disclose this in our SLA. - Cost floor of ~$170/mo dominated by Multi-AZ, not workload. At MVP scale we're paying for durability, not throughput. ## Conditions that would trigger reversal We would revisit this decision if: 1. Write volume exceeds 500K writes/sec and Citus operational cost exceeds engineering budget by >30% for two consecutive quarters 2. AWS reduces DynamoDB pricing by ≥50% or introduces a compatible open-source alternative 3. Team composition shifts to majority-MySQL experience 4. Compliance mandates a database engine we don't currently support ## Related ADRs - ADR-0003: Choose AWS as primary cloud - ADR-0005: Cache-aside pattern for read-heavy workloads - ADR-0009: Deferred sharding until saturation (referenced above) ## References (4 items) - Chapter 5.5 (Database decision head-to-head) of the design doc - Kleppmann DDIA Ch 3 (Storage & Retrieval) and Ch 6 (Partitioning) - MySQL official docs on Multi-AZ replication - Nygard, M. (2011) "Documenting Architecture Decisions" — [cognitect.com/blog/2011/11/15/documenting-architecture-decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)
What makes this ADR strong:
- It's immutable. Once accepted, an ADR is never edited. If we reverse the decision, we write ADR-0011 that supersedes ADR-0007. The record is a decision history, not a wiki.
- It names the trade-offs explicitly. "We accept this as ~6 months of eng work in Q3 2026" — no hedging.
- It documents the reversal conditions. New engineers three years later can look and say "have any of these fired?"
- It's short. ~1 page. If you can't summarize a decision in one page, you don't understand it yet.
- Cites its sources. Ch 5.5, DDIA, MySQL docs, Nygard. All verifiable.
Where ADRs live: typically in-repo at docs/adr/0001-*.md (see adr.github.io for the community-standard tooling). Some teams use Confluence but the in-repo pattern is stronger because ADRs get version-controlled alongside the code they describe.
Interview probe: "How would you document this decision?" Your answer: "I'd write an ADR — I've written dozens. The format is Nygard-style: context, decision, consequences (positive AND negative), alternatives considered, trade-offs accepted, and reversal conditions. Immutable. Version-controlled in-repo alongside the code. I can send you a template."
---
## Artifact 2 — The Business Constraint Exercise
Real design happens under constraints. The interviewer gives you 4 constraints and asks what you build. Your job isn't to design the "best" system — it's to design the RIGHT system for those constraints and defend it against the exec-level pushback that will inevitably come.
The scenario
"You're the tech lead for a URL shortener startup. Constraints: - Team: 3 engineers (you + 2 juniors) - Timeline: 8 weeks to MVP launch - Budget: $10K/month for infra (bootstrapped, this is real cost sensitivity) - CEO's ask: 'I want microservices from day 1 because our chief investor said monoliths don't scale' Design and defend. Then push back where you need to."
The senior-engineer response
Step 1 — What I would build.
"I'd build a Django or Rails or FastAPI monolith deployed as 3 EC2 t3.large behind an ALB, with an RDS Multi-AZ MySQL backing it. No cache, no Kafka, no microservices. Total monthly cost ~$400. Ships in 6 weeks with 2 weeks of buffer for iteration. That's the L4 design from Chapter 5. It handles 10K RPS which comfortably covers our first year of anticipated traffic. Uses 4% of our $10K/mo budget, giving us runway for the CEO's marketing budget."
Step 2 — How I would push back on the CEO's ask.
This is the part most engineers screw up. The wrong response is "the CEO doesn't understand technology." The right response is to reframe the ask.
"Chief, I want to make sure we solve the actual problem you're worried about. When your investor said 'monoliths don't scale,' they meant one of three things: 1. They meant: 'monoliths don't scale to Facebook size.' True, but we're not at Facebook size — we're 3 engineers building an MVP. A monolith is exactly right for us. When we hit ~500K RPS, we'll refactor toward services (there's a documented pattern for this called the Strangler Fig — I can show you). But that's a good-problem-to-have that's ~3 years away. 2. They meant: 'monoliths can't ship features fast.' False for our size. With 3 engineers, a monolith is FASTER to ship — one deploy, one codebase, no cross-service versioning, no distributed-transaction problems. 3. They meant: 'monoliths become unmaintainable at scale.' Only true if we don't invest in modularity. I'll structure the codebase with clear internal module boundaries (bounded contexts) so when we split later, the seams are already there. So my push-back is: let me build a modular monolith, ship the MVP in 6 weeks, prove PMF, and when we hit ~200K RPS I'll bring a proposal to split into services with concrete engineering ROI. Doing microservices now would ship 4-6 weeks LATER and cost 10× more in infra. Would that trade-off pass your board's smell test?"
Step 3 — The alternative I offer.
"If you really want the 'microservices story' for investors, we can position what we're building as 'microservices-ready architecture with a monolithic deployment' — this is honest. We WILL have clean module boundaries. We WILL be able to split when the load justifies it. And it lets me ship in 6 weeks instead of 6 months. That's the language I'd use with the investor if I were in your seat."
What this exercise tests
- Do you know when to say no? (Yes, but with an alternative.)
- Can you translate technical language to business consequences? (10× infra cost, 4-6 weeks slower launch — those are numbers a CEO understands.)
- Do you understand the difference between "scalability" as a technical concept and "scalability" as an investor pitch? (Very different audiences, very different meanings of the word.)
- Do you take ownership of the tradeoff or just refuse? (Offering the "microservices-ready modular monolith" framing shows ownership.)
What separates good from great answers
A junior engineer says: "Yes, we'll build microservices" and ships late.
A senior engineer says: "No, monoliths are fine at our scale" and gets overruled.
A Staff+ engineer says: "Here's the modular monolith we're going to build, here's how it becomes microservices when the numbers say so, and here's the language you can use with the investor. Do we have a deal?"
Notice the Staff+ engineer:
- Named the actual concern (investor optics)
- Offered a solution that satisfies both stakeholders
- Made the trade-off numeric
- Owned the outcome
That's the artifact. Interviewers weight this heavily at L5+.
References:
- Fowler, Martin — "MonolithFirst" (2015): martinfowler.com/bliki/MonolithFirst.html. The definitive counter to premature microservices.
- Newman, Sam — Building Microservices, 2nd ed. (O'Reilly 2021). Chapter 3 covers when microservices actually pay off.
- Fowler, Martin — "Strangler Fig Application" (2004): martinfowler.com/bliki/StranglerFigApplication.html. The pattern for evolving monolith → services.
Artifact 3 — The Production Incident
The final artifact tests whether you can operate a system, not just design it. This is a walk-through, not a document — the interviewer describes a live incident and you respond.
The scenario
"It's 3:47 AM Tuesday. PagerDuty wakes you up. The alert says: p99 latency on /r/<short_code> jumped from 50ms to 8,000ms; error rate 3% You're on-call. You have 20 minutes to lay out your investigation, identify the root cause, mitigate, and communicate. Go."The Staff+ response — walked through in the actual order I'd do it
0:00-0:02 — Acknowledge the page. Log in.
Open the PagerDuty ticket. Ack it (this stops the escalation to the backup on-call). Open the standard on-call laptop layout: Grafana dashboard, Kibana logs, deployment history, incident-response Slack channel. Do not touch the system until you understand the failure mode.
0:02-0:05 — First hypothesis: what's the fingerprint?
Look at the Grafana overview dashboard. Answer three questions immediately:
- Is this a total outage or a partial one? Look at request-count. If request-count is normal but latency is high → partial degradation. If request-count is 0 → total outage.
- Is it one region or global? Toggle the region filter on the latency graph. If it's only us-east-1 → regional issue. If global → shared-infra issue.
- Is it one endpoint or all? Toggle the endpoint filter. If
/r/*is the only bad one → likely cache/DB. If everything is bad → LB/network/auth.
Say out loud what you find. This is the interviewer's window into your diagnostic thinking. Suppose the answers are: partial degradation (traffic normal), us-east-1 only (eu-west-1 and ap-northeast-1 fine), */r/ only** (POST /v1/urls is normal).
0:05-0:08 — Second hypothesis: which layer?
The pattern above narrows to us-east-1 read-path. Layers to check, in order of probability:
- CDN? No — CDN issue would be global.
- Load balancer? Check ALB health-check pass rate. If 100% → LB is fine.
- App server? Check per-instance CPU and memory. If 6 of 8 are hot → deployment issue. If evenly spread → downstream issue.
- Cache? Check Redis cluster health. Look at cache hit rate.
- DB? Check MySQL CPU, connection queue depth, slow-query log.
Suppose you check: app servers evenly loaded (rules out deployment), Redis showing 45% cache hit rate (was 92% yesterday — this is the smoking gun).
0:08-0:12 — Third hypothesis: why is the cache hit rate dropping?
Options for why cache hit rate drops:
- Redis eviction (memory pressure) — check redis_evictions_per_sec (was 0, now 5000)
- Redis crash/failover — check redis_primary_uptime (24h, no failover)
- Key-space explosion (someone's shortening lots of unique URLs) — check url_creates_per_sec (normal ~10/sec)
- Manual FLUSHALL / config change — check ops-log for FLUSHALL. Nothing.
Interesting: high evictions with normal create volume means Redis memory is filling up FROM SOMEWHERE. Check what's in Redis: redis-cli --stat shows total-key-count growing at ~500/sec. Something is writing 500 keys/sec to Redis that we don't intend.
0:12-0:15 — Root cause identified.
Grep the app-server logs for recent Redis writes. Find a code path in a newly-deployed feature (rate-limiter refactor at 3:30 AM) that stores per-request rate-limit state in Redis with NO TTL. Every request adds a new key that never expires. At 100K RPS, that's 100K new keys/sec, filling up Redis in ~1 hour.
The root cause is a bad deploy (mirroring Failure Mode 4 from Chapter 9): a rate-limiter refactor that added Redis writes without TTL.
0:15-0:18 — Mitigation.
Two options:
1. Roll back the deploy (proper fix, but takes 10 min).
2. Emergency mitigation: redis-cli --scan --pattern 'ratelimit:*' | xargs redis-cli DEL — deletes the runaway keys immediately, buys us time. Then roll back.
I choose 2, then 1: buy time first, then apply the durable fix.
0:18-0:20 — Comms.
Post to #incidents Slack channel:
> "P99 spike on us-east-1 /r/* since 3:47 AM. Root cause: rate-limiter refactor at 3:30 wrote unbounded keys to Redis, causing eviction of hot URL cache. Mitigated at 4:05 by clearing rate-limit keyspace. Rolling back deploy now (ETA 4:15). p99 back to 60ms, error rate at 0.1%. Postmortem tomorrow at 10 AM."
Alert the deploy owner. Wake them up nicely (they'll be embarrassed enough tomorrow).
What made this response strong
- Systematic narrowing. Started at outage-shape (partial/regional/endpoint), narrowed to layer, then to metric, then to root cause. Never guessed.
- Named the alerts and dashboards. "Check
information_schema.processlist" — an interviewer can tell instantly whether you've ever been on-call for a real MySQL. - Mitigation before durable fix. Bought time with a targeted DEL, then rolled back cleanly. Junior mistake: try to fix live, break something else.
- Comms discipline. Notified stakeholders concisely, without over-explaining. This matters.
- Blame-free next-day post-mortem. Named the artifact (postmortem tomorrow) without villainizing the deploy owner.
What separates good from great
A junior engineer says "I'd look at the logs and see what's wrong."
A senior engineer says "I'd check CPU on all layers and find the bottleneck."
A Staff+ engineer walks through the exact metric-by-metric narrowing sequence, names the tool for each check, and mitigates before rolling back — the interviewer knows in 3 minutes that this candidate has been on-call at 3:47 AM many times.
Reference: the Google SRE Book Chapter 12 ("Effective Troubleshooting" — sre.google/sre-book/effective-troubleshooting) is the canonical reference for structured incident-response thinking. It codifies the "hypothesize, test, narrow, mitigate" loop above.
Summary — what makes Staff+ artifacts different
| Artifact | What junior does | What senior does | What Staff+ does |
|---|---|---|---|
| Design | Draws boxes | Explains trade-offs | Documents decisions AS ADRs so future engineers know why |
| Push-back | "OK boss" | "That's a bad idea" | Reframes the ask + offers alternative + names the metric that would trigger reconsidering |
| Incident | "I'd look at logs" | "I'd check layers systematically" | Walks the specific sequence + mitigates before rolling back + posts blame-free comms |
These three artifacts are what real architects produce. Practice them. When you can do all three in an interview, you're a Staff+ engineer. When you can do them in a real production job under real pressure, you're a good Staff+ engineer.
References (11 items)
- Nygard, Michael (2011) — "Documenting Architecture Decisions": cognitect.com/blog/2011/11/15/documenting-architecture-decisions. The founding ADR blog post.
- adr.github.io — community-standard ADR tooling and templates.
- Fowler, Martin — "MonolithFirst" (2015): martinfowler.com/bliki/MonolithFirst.html. Referenced in Business Exercise.
- Fowler, Martin — "Strangler Fig Application" (2004): martinfowler.com/bliki/StranglerFigApplication.html.
- Newman, Sam (2021) — Building Microservices, 2nd ed. (O'Reilly). Chapter 3 on when microservices pay off.
- Google SRE Book — Chapter 12: Effective Troubleshooting: sre.google/sre-book/effective-troubleshooting. Canonical reference for incident response.
- Google SRE Book — Chapter 14: Managing Incidents: sre.google/sre-book/managing-incidents. Incident-command structure at Google.
- Etsy debriefing guide (2016): etsy.com/codeascraft/debriefing-facilitation-guide-2. Etsy's public post-mortem methodology, blame-free-postmortem canon.
- Beyer et al. (2016) — Site Reliability Engineering (O'Reilly). Ch 15 on postmortem culture.
- Chapter 9 of this journey (Failure Modes) — every artifact references incident scenarios covered there.
- Chapter 5.5 — ADR references its content directly for the DB decision rationale.
These are the artifacts of Staff+ engineering. Read this chapter again in six months, and you'll notice how much of your production work already looks like one of these three shapes.
## The 3 artifacts side by side
Here is what each artifact demonstrates and when you use it. Study the ladder at the bottom — that's the promotion-signal an interviewer is grading against:
| 1. ADR | 2. Business Exercise | 3. Incident Walkthrough | |
|---|---|---|---|
| Format | ~1 page immutable git-versioned doc | ~30 min verbal boss demo | ~20 min verbal on-call walk |
| Demonstrates | Rigorous reasoning · trade-off naming · immutable record | Business fluency · push-back with alternative · numeric consequences | Operational depth · systematic diagnosis · mitigation before rollback |
| When used | Any major decision; interview probe on "how did you document?" | Ambiguous requirements from execs; interview probe on push-back | Real production incidents; interview "walk me through an incident..." |
| References | Nygard 2011 · Fowler ADR tooling | Fowler MonolithFirst · Newman Building Microservices | Google SRE Book Ch 12 (Effective Troubleshooting) |
The Junior / Senior / Staff+ ladder
| Level | On design | On push-back | On incidents |
|---|---|---|---|
| Junior | Draws boxes | "OK boss" | "I'd look at logs" |
| Senior | Explains trade-offs | "That's a bad idea" | "I'd check layers systematically" |
| Staff+ | Documents as ADR so future engineers know why | Reframes with alternative + names metric that would trigger reconsidering | Systematic narrowing + mitigate before rollback + blame-free post-mortem comms |
Continue in the Masterclass
The Masterclass is a dedicated deep-dive page with the full ADR (Architecture Decision Record), a business constraint exercise, and a production incident scenario. Read it after this journey.
ADRs, business exercises, and incident scenarios are the artifacts of Staff+ engineering. They demonstrate judgment beyond design — capital allocation, stakeholder management, operational excellence.
- What is an Architecture Decision Record?
- How do you push back on an executive request?
- How do you diagnose a P99 latency spike?
- What separates Staff+ engineering from senior?
Chapter 12: defend it. You've learned to build the system. Now practice defending it. AI Interviewer, deterministic rubric, and the cheat sheet. This is where you turn understanding into a spoken answer that survives an interview loop.
Defend it
Practice, refine, and go interview
Last chapter. You've come a long way.
You now know how to design a URL shortener at 4 scales, catalog its failures, map its trade-offs, produce an ADR, run a business exercise, and diagnose an incident. That's more depth than 95% of candidates bring to a Staff-level interview.
But — and I've said this since Chapter 0 — knowing is not doing. In an interview, you have to say all of this out loud, coherently, under time pressure, while a senior engineer probes you for weaknesses.
That's what defense practice is for. Three modes:
- AI Interviewer (BYOK) — Bring your OpenAI/Anthropic/Azure OpenAI key. The AI plays interviewer, coach, or debate partner. Runs unlimited practice. Zero telemetry on your prompts. Audio never leaves the browser.
- Deterministic rubric simulator — Scripted flow with 3-step hint ladder and objective scoring. No LLM needed. Great when you want a fixed rubric to grade yourself against.
- Cheat sheet — Print-optimized 1-page summary. Read this in the 5 minutes before your interview. It's the compressed version of everything above.
My final advice, from someone who's been on both sides of a lot of interviews:
The 14-day interview prep timeline
Here's the exact plan I give juniors preparing for Staff-level interviews. Working backwards from interview day:
| Days out | Focus | What to do | Time |
|---|---|---|---|
| T-14 days | 📚 READ | Read this URL Shortener journey again. Focus on chapters where you're weakest. Skip nothing. Even chapters you "know." | 4-6 hours over 2-3 days |
| T-10 days | 📝 PRACTICE THE ADR | Write out the ADR from Ch 11 artifact 1. Handwritten or Notion. No looking up. Then compare with the Ch 11 template. Notice gaps → read those chapters again. | 60 min |
| T-7 days | 🎯 PRACTICE THE BUSINESS EXERCISE | Ch 11 artifact 2. Verbal, out loud. Record yourself. Play it back tomorrow. You'll cringe — that's the point. Identify hedge words + filler. | 30 min |
| T-3 days | 🚨 PRACTICE THE INCIDENT WALKTHROUGH | Ch 11 artifact 3. Timer at 20 min. Walk yourself through the 3:47 AM Redis-crash scenario from Ch 9. Should feel automatic by now. | 20 min |
| T-1 day | 📄 REFRESH THE CHEAT SHEET | 1 page. Requirements + estimation formula + API skeleton + top-3 trade-offs + top-3 failure modes + interview soundbite. Read before bed. Not the morning of. | 30 min |
| T-0 day | 🎯 INTERVIEW DAY | Coffee. Confidence. Ask the 6 clarifying questions. Do the math on the whiteboard. Name every trade-off out loud. Be honest about what you don't know. Ship it. | Real interview |
Interviewers can tell in 5 minutes whether you did this preparation. Do the work. It shows.
Ok — the specific list:
- Don't memorize architecture. Practice deriving it from requirements.
- Don't skip the questions. The interviewer WANTS you to ask.
- Don't rush the math. Show your derivation, don't just drop numbers.
- Don't be dogmatic. Every "best practice" is wrong in some context.
- Don't hide your uncertainty. "Here's how I'd approach this — I'd want to validate X" is stronger than fake confidence.
- Do talk about failures. Every architecture fails; senior engineers know how.
- Do talk about trade-offs. Every decision has a cost; explain what you accepted.
- Do talk about business context. Cost, timeline, team, compliance are architectural inputs.
Now — practice. Go break the system in the failure simulator. Argue with the AI Interviewer. Read the ADR again. And when you get to the interview, remember:
They're not trying to trick you. They want to see how you think. Show them.
Good luck.
You've completed the journey. Now practice defending it aloud. AI Interviewer + deterministic rubric + cheat sheet. Interview success = derivation over memorization + trade-off fluency + honest uncertainty.
- How do you practice for a system design interview?
- What are the 3 modes of defense practice?
- What are the 8 rules for interview success?
- How do you show engineering judgment under pressure?
Components used in this design
Study each component's deep dive to understand it in isolation.
Load Balancer
Distributes incoming traffic across a pool of servers for scale and fault tolerance.
SQL Database
A row-oriented, ACID-compliant relational database — the default for transactional workloads.
Redis
An in-memory key-value store used for caching, pub/sub, rate limiting, distributed locks, and simple queues.
CDN (Content Delivery Network)
A globally distributed cache that serves static and cacheable dynamic content close to the user.
Rate Limiter
Enforces per-caller (per-user, per-IP, per-tenant) request budgets to protect downstream systems from abuse and overload.
Message Queue
Async task queue that decouples producers from consumers and smooths bursts. Each message is processed exactly once (per-message ACK model).
Object Storage (S3, GCS, Azure Blob)
Durable, cheap, flat-namespace storage for blobs — images, videos, backups, logs, and any large binary object.
Distributed Database (DynamoDB, Cassandra, Spanner)
Horizontally-scaled database with automatic partitioning and replication — the answer when one node can't hold the data or the traffic.
Patterns applied in this design
Study each pattern's deep dive for the recurring solution logic.
Cache-aside (Lazy loading)
Reads dominate your workload, and every read hits a slow store (database, disk, network). Latency and load on the primary storage climb until the primary becomes the bottleneck.
Circuit breaker
A downstream service is unhealthy. Your service keeps calling it, waiting the full timeout on every call, exhausting your thread pool, and cascading the failure back to your callers.
Sharding (Horizontal partitioning)
Your dataset or write throughput has outgrown a single node. A single primary DB or single cache node can't hold the data or handle the QPS.
Consistent hashing
You need to shard keys across N nodes, but N changes over time (nodes added, removed, or failed). Naive hash-mod-N reshuffles almost everything on every change — cache is wiped, migration cost is huge.
Rate limiting
One bad actor can consume all your capacity. And even good actors need bounds so you can capacity-plan.
Explore next — related systems
If you enjoyed this problem, these share similar patterns or challenges.