Designing Under Pressure
First principles for systems you can defend out loud
Learn to design backend systems from requirements and rough estimates through load balancing, caching, replication, consistency, queues and failure handling, and to explain each trade-off out loud. Written for backend engineers preparing for senior roles and system design interviews.
Free to read, link and share. How you can use this book
This book was written with AI by TailoredRead and checked by AI reviewers. It may contain errors and is not professional advice.
About this book
What if the best system design answer is not the one with the most boxes on the whiteboard? Many engineers prepare for senior interviews by memorizing diagrams of familiar products, only to stall when a question changes a single constraint. This book is built for a different kind of preparation. It starts from the questions that drive every real design: What are the requirements, how many users and requests are we talking about, and what happens when something breaks? From there, you will work through the building blocks you already use day to day, including load balancers, caches, CDNs, replicated and partitioned databases, queues, and retry strategies, and learn to explain why each one exists and what it costs. The book walks through back-of-the-envelope math so your estimates support your decisions rather than decorate them. It then turns to failure as the normal state of distributed systems, showing how to reason about timeouts, idempotency, and partial outages. Finally, it applies these principles to common systems designed end to end, and shows how to narrate your choices out loud the way a senior engineer does in an interview or a design review. This is a book for engineers who want to design a system, defend its trade-offs with confidence, and understand the reasoning well enough to adapt it to any question they haven't seen before.
Table of contents
- IntroductionWhy First Principles Beat Memorized Diagrams, What Interviewers Actually Listen For, How to Use This Book Effectively
- Chapter 1: Clarifying Requirements and ScopeFunctional Versus Nonfunctional Requirements, Asking Questions That Shape the Design, Defining Latency, Availability, and Durability Targets
- Chapter 2: Estimates and Back-of-the-Envelope MathPowers of Two and Latency Numbers, Estimating Traffic, Storage, and Bandwidth, Using Estimates to Drive Design Choices
- Chapter 3: Load Balancing and Traffic DistributionLayer 4 Versus Layer 7 Balancing, Round Robin, Least Connections, and Consistent Hashing, Health Checks and Failover Behavior
- Chapter 4: Caching Strategies and Content DeliveryCache-Aside, Write-Through, and Write-Back Patterns, Invalidation, Expiry, and Accepting Staleness, How CDNs Move Content Closer to Users
- Chapter 5: Database Replication and PartitioningLeader-Follower and Multi-Leader Replication, Partition Keys, Hotspots, and Rebalancing, Secondary Indexes Across Partitions
- Chapter 6: Consistency, Availability, and Trade-OffsThe Spectrum From Strong to Eventual Consistency, Quorums, Leases, and Consensus at a High Level, CAP Theorem and the Latency Trade-Off
- Chapter 7: Queues, Retries, and IdempotencyWhy Queues Decouple Producers and Consumers, Retry Budgets, Backoff, and Jitter, Designing Idempotent Operations With Keys
- Chapter 8: Reasoning About Failure and DegradationPartial Failures and the Meaning of Timeouts, Circuit Breakers, Bulkheads, and Graceful Degradation, Observability as a Design Requirement
- Chapter 9: Designing Common Systems End to EndWalkthrough: A URL Shortener Under Heavy Read Load, Walkthrough: A Social Feed With Fan-Out Choices, Walkthrough: A Payments Ledger Where Correctness Wins
- Chapter 10: Communicating Trade-Offs in the Interview RoomNarrating Decisions Without Losing the Interviewer, Handling Follow-Up Questions and Changed Constraints, Defending a Design Under Pushback and Admitting Limits
- ConclusionCarrying These Principles Into Your Next Design
- Further Reading and Resources
- Glossary
Introduction
A candidate is asked to design a ride-sharing dispatch system. For twelve minutes the whiteboard fills with boxes: a load balancer, a service layer, a database, a cache, a queue. The diagram looks like every reference architecture the candidate has studied. Then the interviewer changes one number: instead of 50,000 drivers in a city, assume 2 million drivers across a country, with location updates every two seconds. The diagram does not change, because the candidate memorized a shape rather than a set of reasons. The interview stalls, not from lack of knowledge, but from lack of a method for deciding what changes when the constraints do.
That failure mode is common and fixable. Most mid-level engineers already know what a cache does, what a queue does, and what replication buys a database. What they often lack is practice turning those facts into decisions under a specific set of numbers and failure assumptions. A senior-level system design interview rarely tests whether you have heard of consistent hashing. It tests whether you can explain why this particular system, with this traffic pattern and this correctness requirement, needs it or does not. The gap between knowing a component and knowing when to reach for it is exactly where this book aims.
The chapters ahead follow the order a real design actually unfolds: requirements first, then back-of-the-envelope math, then the building blocks (load balancing, caching, replication, partitioning, consistency, queues, failure handling), and finally full walkthroughs and interview technique. Each one treats a component as a trade-off, not a feature. A cache is not just a thing that makes reads faster. It is a thing that makes reads faster at the cost of occasionally returning wrong data, and the chapter on caching strategies will ask you to name that cost out loud rather than skip past it.
This introduction sets up the thinking before the toolkit arrives. The next three sections explain why reasoning from first principles outperforms memorized diagrams, what interviewers are actually scoring while you talk, and how to use the rest of the book so the practice sticks rather than evaporating after one read-through.
Why First Principles Beat Memorized Diagrams
A memorized diagram is brittle because it is tied to one specific set of assumptions that the person who drew it never had to state. Someone studies a "design Twitter" writeup, learns that it uses fan-out on write for the timeline, and treats that as the answer to "design a feed." But fan-out on write is a good fit only under certain conditions, such as users who mostly follow a bounded number of accounts. Change the assumption to a celebrity with 50 million followers, and the same pattern turns a single post into 50 million writes. The diagram was never wrong. It was an answer to a narrower question than the one now being asked.
First-principles reasoning starts from a different place: what does this specific system need to guarantee, and what does it need to survive? Instead of recalling "feeds use fan-out on write," you ask what happens on the read path versus the write path under the actual follower distribution described in this problem. That question has a different answer depending on whether most accounts have 200 followers or 50 million. Reasoning this way is slower at first, because you cannot shortcut to a remembered picture. It is also the only approach that survives a changed constraint, because the constraint is now a direct input to your reasoning instead of a mismatch you have to notice and repair.
Consider how differently two engineers respond to the instruction "assume the write volume just increased tenfold." An engineer who memorized a diagram has no lever to pull beyond redrawing boxes bigger. An engineer who reasons from first principles can trace the consequence directly: more writes per second against a single leader means replication lag likely grows, read replicas may serve staler data, and partitioning the write path by some key becomes worth evaluating instead of optional. The second engineer is not smarter. They are working from a model of cause and effect rather than a picture, so the new number has somewhere to go.
This pattern repeats across every topic in the outline. Partitioning and replication are often drawn as interchangeable "scaling" techniques in casual explanations, but they answer different questions: replication buys you availability and read capacity by copying the same data, while partitioning buys you write capacity and storage scale by splitting the data apart[1]. Someone who has only memorized "replicate for scale" will reach for the wrong tool when the real bottleneck is write throughput on a single shard, because the word "scale" hid two different mechanisms with two different costs behind one label.
The CAP theorem suffers the same flattening when reduced to a slogan. Many engineers can recite "consistency, availability, partition tolerance, pick two," but few can explain why that framing is actually a statement about what happens during a network partition specifically, not a general three-way trade-off you choose once and forget[2]. Reasoning from first principles means you can explain what your system does the moment a partition actually happens, rather than gesturing at a triangle. That distinction matters directly in the chapter on consistency and trade-offs, where the goal is to pick a point on a spectrum deliberately, not recite a theorem.
None of this means diagrams and known architectures are useless. A worked example is often the fastest way to build intuition, which is exactly why this book includes full walkthroughs of a URL shortener, a social feed, and a payments ledger. The difference is sequencing. Study the worked example after you understand the principles that produced it, so you can see why each decision was made and not just what the final boxes were. Read the diagram backward: for every component on the page, ask what requirement forced it to exist, and what it would cost to remove.
What Interviewers Actually Listen For
A system design interview has no single correct diagram, which surprises engineers used to coding interviews with a definite right answer. An interviewer evaluating a design is not checking boxes against a reference architecture pinned in some document. They are listening for a process: did the candidate ask what the system needs to do before deciding how, did they attach numbers to their claims, and did they notice and name the costs of their own choices. Two candidates can produce visibly different final diagrams and receive the same strong rating, because the rating is about the reasoning path, not the destination.
The earliest signal an interviewer looks for is whether you separate functional requirements from nonfunctional requirements before writing a single box. A candidate who starts sketching a load balancer in the first ninety seconds, before establishing whether the system needs strong consistency or can tolerate staleness, has skipped a step that every senior engineer takes. The chapter on clarifying requirements and scope builds this habit explicitly, but the short version matters here: an interviewer wants to see you ask "what does correct behavior look like, and what scale are we talking about" before any architecture appears.
A second signal is whether your numbers connect to your decisions, rather than sitting beside them decoratively. Saying "we expect 10,000 requests per second" and then never mentioning that number again is a common and avoidable mistake. A stronger answer uses the number as a lever: "at 10,000 requests per second, a single Postgres instance handling both reads and writes becomes the bottleneck around here, which is why I'd introduce read replicas at this point rather than earlier." That sentence does three things an interviewer is scoring at once: it states an estimate, it ties the estimate to a specific failure point, and it justifies the timing of a design decision instead of adding every component up front "to be safe."
A third and more subtle signal is whether you can state the cost of your own choice unprompted. Many candidates can describe what a cache does, but a materially smaller number can say, without being asked, "this introduces staleness, and here is how stale the data could get and why that's acceptable for this feature." That unprompted admission is one of the clearest markers of seniority an interviewer looks for, because it shows you are not selling a design, you are evaluating one. The chapter on caching strategies and the chapter on consistency both return to this skill directly: naming the trade-off before anyone asks is worth more than a clever component choice.
Interviewers also watch how you behave when they change a constraint mid-interview, which they almost always do on purpose. Introducing a sudden 100x traffic spike, or asking "what if this data must never be lost, even if the service is down for an hour," is a deliberate probe into whether your design was actually reasoned or merely recited. The decision tree below sketches, at a high level, how a candidate's response to a changed constraint signals the underlying quality of their reasoning, independent of which specific technology they name.
How a changed constraint reveals your reasoning
- Interviewer changes a constraint mid-design
- No new reasoning offered Candidate repeats the original diagram unchanged
- Reasoning re-engaged Candidate traces which component breaks first
- Adds a fix with no stated cost
- States the new bottleneck, the fix, and its cost
Communication style is its own signal, separate from technical correctness. An interviewer sitting through forty-five minutes of silent whiteboard sketching cannot evaluate reasoning they never hear. Narrating a decision out loud as you make it, including the alternative you considered and rejected, gives the interviewer the material they need to score you well. The chapter on communicating trade-offs in the interview room treats this as a skill distinct from technical knowledge, because engineers who design well in practice sometimes design silently, and silence reads as uncertainty in an interview setting even when it is not.
Finally, interviewers are listening for calibrated confidence rather than either extreme. A candidate who defends every choice as obviously correct, with no acknowledgment of a reasonable alternative, reads as inflexible. A candidate who hedges every sentence with "I'm not sure, maybe" reads as lacking conviction. The target is a middle register: "I'm choosing eventual consistency here because the business tolerates a few seconds of staleness on this read path, though if that assumption is wrong, strong consistency with a quorum read is the fallback." That sentence states a choice, its justification, and its boundary condition in one breath, which is the exact pattern the later chapters build toward.
How to Use This Book Effectively
The chapters are sequenced to mirror the order a real design unfolds, and that sequencing is deliberate rather than a convenient way to organize topics. Requirements and back-of-the-envelope math come first because every later decision depends on numbers established there. Reading the chapters out of order, skipping ahead to the queues chapter because it sounds more interesting, will work less well than it would for a book of independent essays, because later chapters assume the vocabulary and habits built earlier. If a term like SLO or QPS appears in chapter five and feels unfamiliar, that is a signal to go back, not a gap in that chapter's explanation.
Each core chapter follows a consistent internal shape: a mechanism is introduced, its benefit is stated plainly, and then its cost is named just as plainly, because the book's premise is that no technique is free. When the chapter on load balancing explains consistent hashing, it will not stop at "this reduces the number of keys that move when a node is added or removed." It will also explain when a naive hash ring creates uneven load across nodes and what a virtual node does to correct that, because stopping at the benefit is exactly the habit that produces brittle, diagram-memorizing engineers. Read for the cost sentence as carefully as the benefit sentence in every chapter.
The math in estimates and back-of-the-envelope math deserves particular attention because it is the chapter most readers are tempted to skim. Treat it instead as the chapter that calibrates every number used afterward. When a later chapter says "at this scale, a single leader becomes a bottleneck," that claim is only persuasive if you can do the arithmetic yourself and confirm it. Keep a notebook or a blank document open while reading that chapter, and redo every calculation by hand rather than trusting the printed answer. The goal is not to memorize that a modern SSD read takes roughly a certain number of microseconds, but to build enough fluency with the relevant latency numbers that you can produce a defensible estimate on the spot, under pressure, for a system you have never seen before.
The book includes several kinds of structured material beyond prose: callouts that flag a definition or a warning, checklists for process steps, worked tables comparing options, and short exercises. Treat the exercises as mandatory rather than optional, even though nothing enforces that. An exercise that asks you to estimate the storage needed for a photo-sharing app's thumbnails over five years is doing real work: it forces you to notice which assumptions you are uncertain about (average photo size, retention policy, compression ratio) before you can even begin, and that noticing is the actual skill being practiced, not the final number.
The checklist below gives a concrete way to work through each core chapter so the practice compounds instead of evaporating, since reading alone rarely transfers into interview-ready fluency.
Working through each core chapter
- Read the chapter once straight through without stopping to take notes
- Redo every worked calculation by hand, including the ones that look obvious
- Write one sentence stating the main trade-off the chapter introduces
- Name a scenario, different from the chapter's example, where the opposite choice would be correct
- Attempt the exercise before reading any provided solution or discussion
- Say your answer out loud as if an interviewer were listening, not just in your head
The final chapters, covering full system walkthroughs and interview communication, are best used actively rather than read passively. When the chapter walks through designing a payments ledger, pause before each design decision is revealed and attempt your own answer first, including your own justification and the alternative you are rejecting. Then compare your reasoning to the book's, not just your final diagram. Two different diagrams can both be defensible if the reasoning behind each is sound, and the walkthroughs are written to show the reasoning, not to hand down a single canonical architecture to memorize.
One last habit matters more than any single chapter: practice narrating trade-offs out loud, ideally to another person, well before an actual interview. Reading about trade-off narration is not the same skill as producing it under time pressure while someone watches you think. Find a peer, a mock interview partner, or even a recording of yourself, and talk through a design from the requirements chapter onward, stating assumptions and costs as you go. The gap between understanding a concept and being able to narrate it fluently under pressure is exactly the gap this book is built to close, and it closes only with repetition, not with reading alone.
Chapter 1: Clarifying Requirements and Scope
A senior engineer and a junior engineer given the same interview prompt will draw completely different systems, not because of knowledge, but because only one asked the right questions first. Hand both of them design a ride-sharing app
and the junior engineer often starts sketching services within thirty seconds: a matching service, a driver location service, a payments service. The senior engineer instead pauses and asks what design
even means here. Are they building the whole platform or just the piece that matches riders to drivers in real time? Is this for a city of two million people or a global rollout? That pause is not hesitation, it is the entire skill being tested.
Vague prompts are deliberate. Interviewers rarely hand over a fully specified system because the real job of a senior engineer is turning ambiguity into a bounded, measurable contract before any architecture gets chosen. Without that contract, two engineers solving design Twitter
might build completely incompatible systems, one optimized for a feed that updates instantly for ten users, another for a feed that tolerates a few seconds of delay for ten million. Neither is wrong in isolation, but only one of them answers the question that was actually being asked. Scoping is the filter that turns an open-ended prompt into a decision you can defend.
This chapter is about building that filter. It separates what the system must do from how well it must do it, gives you a concrete set of questions to ask before committing to any design, and shows how to translate soft words like fast
and reliable
into numbers you can design against. None of this requires new technology knowledge. It requires discipline, the same discipline a product manager uses when turning a stakeholder's wish into a specification, except here the stakeholder is an interviewer or a real production deadline.
Skipping this step is the single most common reason a design falls apart under follow-up questions. An engineer who assumes a system needs strong consistency everywhere will spend the next thirty minutes defending unnecessary complexity when the interviewer only cared about availability. An engineer who never asks about read-to-write ratio will pick a database partitioning strategy that collapses the moment the real ratio turns out to be inverted. The goal of this chapter is to make sure that never happens to you again, by giving you a repeatable way to extract the constraints that actually matter before you draw a single box.
Functional Versus Nonfunctional Requirements
Every system has two different kinds of requirements, and conflating them is the fastest way to lose control of a design conversation. A functional requirement describes what the system does: a user can post a message, a user can search for a product, a driver's location updates on a map. A nonfunctional requirement describes how well the system must do it: how fast, how reliably, at what scale, with what consistency guarantees. You can build the exact same functional feature in wildly different ways depending on the nonfunctional requirements attached to it, so naming both categories explicitly, out loud, is the first move in any serious design.
Take a concrete example: users can send a message to another user.
That single functional requirement hides at least five different systems depending on the nonfunctional answers. If messages must be delivered with very low latency to a very large number of concurrent users, you are building something like a chat backbone with persistent connections. If messages can arrive minutes later and volume is modest, a simple queue and database write might suffice. If messages must never be lost even during a data center outage, durability dominates the design. The functional requirement barely changed. The nonfunctional requirements completely changed the system.
A practical habit is to write functional requirements as short, testable sentences, the kind you could hand to a QA engineer. A user can upload a photo
is testable. The system should handle photos well
is not, because well
is a nonfunctional word hiding inside a functional sentence. Whenever a requirement contains a vague qualifier like fast
, scalable
, reliable
, or secure
, pull that qualifier out and turn it into its own nonfunctional line item. This separation keeps the conversation honest and prevents vague adjectives from silently inflating scope.
Nonfunctional requirements cluster around a recurring set of concerns worth memorizing, since almost every system design question touches some subset of them:
- Latency: how quickly must a request be answered
- Availability: what fraction of the time must the system be reachable and working
- Durability: once data is accepted, how certain is it to survive
- Consistency: how quickly must all readers see the same value after a write (detailed in the chapter on consistency, availability, and trade-offs)
- Scale: how many users, requests, or bytes the system must handle, and how that grows over time
- Cost: what budget constraints shape acceptable infrastructure choices
It helps to think of functional requirements as the shape of the building and nonfunctional requirements as the building code: how much weight the floors must bear, how fast people must be able to evacuate, how long the foundation must last. Two buildings can have the identical floor plan and be completely different structures once you apply different codes to them. A system design interview is testing whether you understand that the floor plan is the easy part, and the code is where the real engineering judgment lives.
A common mistake is treating nonfunctional requirements as an afterthought, something to mention briefly before diving into components. This produces designs that get restructured midway through the interview once the interviewer reveals a constraint that should have been asked about at minute two. Instead, nonfunctional requirements should be gathered before any component gets named, because they constrain which components even make sense. A caching layer is irrelevant if the system is write-heavy with rare reads. A multi-region replication strategy is irrelevant if the system only needs to serve one country. Asking nonfunctional questions first prunes the solution space before you waste time exploring dead ends.
Consider a worked contrast: designing a file storage service
for personal photo backups versus for a video editing studio's active project files. Functionally, both involve uploading, storing, and retrieving files, nearly identical on paper. Nonfunctionally, the photo backup service tolerates eventual availability and moderate latency since users rarely access old photos urgently, while the editing studio needs low latency for large file reads during active work and far higher durability guarantees since losing a day's edits is catastrophic for a paying client. The same functional shape, pointed at different nonfunctional targets, produces two unrecognizable systems.
The callout below gives a compact rule you can carry into any prompt, functional or not.
Key takeaway: The one-sentence test
If a requirement can be answered with yes or no by testing the system, it is functional. If answering it requires a number, a percentage, or a time bound, it is nonfunctional. Every vague adjective in a requirement (fast, reliable, scalable) is a nonfunctional requirement in disguise, waiting to be quantified.
It also pays to notice that nonfunctional requirements interact with each other, often in tension. Pushing durability higher, by replicating writes to more machines before acknowledging them, tends to push latency up, since more machines must confirm before the client gets a response. Pushing availability higher during a network partition tends to push consistency down, a tension formalized later in the discussion of the CAP theorem. None of these trade-offs can be reasoned about correctly until the nonfunctional targets are stated as explicit numbers rather than left as vague intentions, which is exactly the subject of the third part of this chapter.
One more distinction worth naming early: nonfunctional requirements are not uniform across every part of a system. A social media platform might need strict durability for the posts themselves but can tolerate soft, eventually consistent counts for likes
displayed under a post. Applying one uniform nonfunctional target across an entire system is almost always wasteful or wrong. Senior engineers routinely ask does this apply to all data, or just this part?
precisely because mixed requirements within one system are the norm, not the exception, and spotting where they diverge is itself a sign of design maturity.
Before moving to the next section, it is worth internalizing that this separation is not academic housekeeping, it is the scaffolding every later chapter depends on. Load balancing decisions, caching strategies, replication topology, and queue design are all, underneath the surface, responses to a specific nonfunctional target someone stated out loud. A load balancer's health check logic exists to protect an availability target. A cache's TTL exists to trade staleness for latency. None of those choices are defensible in an interview unless you can point back to the nonfunctional requirement that justifies them, which is why this separation deserves to be the very first thing stated in any design, before a single arrow is drawn.
Asking Questions That Shape the Design
Knowing the categories of requirements is only half the skill. The other half is knowing which specific questions to ask, in what order, so that a fifteen-minute clarification phase extracts the handful of facts that actually change the design, instead of collecting trivia that doesn't. A useful mental model is to imagine every question as a lever: pulling it either changes which components you need or it doesn't. If a question's answer would not change your architecture either way, it is not worth spending interview time on, no matter how naturally it comes to mind.
Start with scope boundaries, because they are the cheapest questions to ask and the ones most likely to save you from building the wrong thing entirely. For design a ride-sharing app,
scope questions include: are you building rider-to-driver matching only, or also payments, routing, and ratings? Is this for a single city or global? Is this a fresh system or does it plug into existing infrastructure? A design that spends forty minutes on payment processing when the interviewer only cared about the matching algorithm has failed the interview regardless of how well-engineered the payment piece was, because it answered a question nobody asked.
Next, move to scale questions, since scale estimates (covered in depth in the chapter on back-of-the-envelope calculations) determine whether a single database and a single server are even plausible answers, or whether distributed techniques are required at all. Useful scale questions include the number of active users, the read-to-write ratio, the size of a typical piece of data, and the expected growth rate over the next year or two. A system serving ten thousand users with a 50:50 read-write ratio might run comfortably on a single well-indexed relational database. The same functional system serving fifty million users with a 1000:1 read-to-write ratio demands caching, replication, and careful partition key choices from the very first sketch.
A third category covers behavior under unusual conditions, questions that reveal which nonfunctional target should dominate the design when trade-offs conflict. Is it acceptable for a user to briefly see a stale count of likes, or must every reader see the exact same number instantly? Is it acceptable for a payment to be delayed by a few seconds during a traffic spike, or must it either succeed immediately or fail cleanly? These questions surface the business's actual tolerance for being wrong, which is the single most important input into the consistency model you eventually choose.
The steps below give a repeatable order for the clarification phase, useful as a mental checklist whether in an interview or a real planning meeting.
A clarification sequence that holds up under time pressure
- Restate the prompt - Repeat the request in your own words and name what you think is in scope and out of scope, then confirm with the interviewer before proceeding.
- List the core functional requirements - Write three to five short, testable sentences describing what users can do. Resist the urge to add more than the prompt actually needs.
- Ask scale questions - Get rough numbers for users, requests per second, read-to-write ratio, and data size. Exact figures are less important than order of magnitude.
- Ask tolerance questions - Find out what the system is allowed to get wrong: stale data, delayed writes, occasional retries. This reveals which nonfunctional requirement dominates.
- State the nonfunctional targets out loud - Convert the answers into explicit latency, availability, and durability targets before naming a single component.
- Confirm and move on - Summarize the agreed scope in one or two sentences so the interviewer can correct you early, then begin the design.
It is worth practicing the restating step deliberately, since it is the one most engineers skip under pressure. Saying out loud, so to confirm, we're building the matching and dispatch piece only, for a single large city, assuming payments already exist as a separate service,
accomplishes two things at once. It gives the interviewer a chance to redirect you immediately if you misunderstood the scope, and it signals that you treat requirement gathering as a deliberate phase of the work rather than something to rush past. Interviewers consistently rate candidates higher when this step is visible and explicit, even when the eventual design is similar to a candidate who skipped it.
A frequent failure mode is asking too many questions that do not change anything, which wastes time and can read as stalling rather than rigor. Asking about the exact programming language used by existing services, or the specific cloud vendor, rarely changes the shape of a design discussion unless the prompt specifically invites that level of detail. A good filter is to ask yourself, before voicing a question, if the answer were the opposite of what I expect, would I change my design?
If the honest answer is no, skip the question and move to one that matters.
Another trap is accepting the first answer to a scale question without sanity-checking it against the functional requirements already gathered. If an interviewer says a system needs to support one billion users but the functional requirement is a tool for a single company's internal support team, the scale figure is almost certainly a misunderstanding worth double-checking immediately, rather than silently designing for a number that doesn't match the described use case. Spotting that mismatch and asking about it is itself a mark of careful thinking, not a sign of confusion.
The table below contrasts a shallow clarification pass against a rigorous one, using the same ride-sharing prompt, to make the difference concrete.
| Shallow pass | Rigorous pass |
|---|---|
| Assumes global scale by default | Asks whether this is one city, one country, or global |
| Jumps to database choice immediately | Establishes read-to-write ratio before naming any data store |
| Treats all data as needing strong consistency | Asks which pieces of data tolerate staleness and which do not |
| Designs for peak load only | Asks about growth rate and whether traffic is steady or spiky |
| Silently assumes payments are in scope | Confirms scope boundaries explicitly before drawing components |
Notice that none of the rigorous-pass questions in the table above require deep technical knowledge to ask. They require the discipline to pause and the vocabulary to phrase the question precisely, which is exactly what this section is meant to build. A junior engineer often has every bit of the technical knowledge needed to design a competent system, but loses the thread because they never extracted the handful of facts that would have told them which technical knowledge to apply. Asking the right questions is not a soft skill bolted onto engineering, it is the engineering.
Finally, keep the clarification phase bounded. Spending the entire interview asking questions without ever committing to a design is its own failure mode, just the opposite one. A reasonable target is five to ten minutes of clarification for a forty-five minute system design interview, enough to extract functional scope, rough numbers, and the one or two nonfunctional targets that matter most, before moving into architecture. If new information emerges later (a classic interviewer move covered further in the chapter on handling follow-up questions and changed constraints), you revisit scope then, rather than trying to front-load every possible question before writing a single box on the board.
Defining Latency, Availability, and Durability Targets
Once scope and scale are roughly understood, the next job is translating soft words into numbers, because a design cannot be evaluated against the system should be fast and reliable.
It can only be evaluated against a stated target such as 99 percent of read requests complete within 200 milliseconds
or the system is available 99.9 percent of the time, measured monthly.
This translation step is where many engineers lose precision, nodding along with fast
without ever pinning down what fast means for this particular system, for this particular user, under this particular load.
An SLO, short for service level objective, is the formal name for one of these targets: a measurable goal for a specific property of the system, stated with a number and a time window. A typical SLO reads something like 99 percent of API requests return within 300 milliseconds, measured over a rolling 30-day window.
Notice the structure: a percentage (how often the target must hold), a metric (latency, in this case), a threshold (300 milliseconds), and a measurement window. Every one of those four parts matters, because dropping any of them turns a precise engineering target back into a vague aspiration.
Latency targets are usually expressed as percentiles rather than averages, and this distinction matters enormously in practice. An average latency of 100 milliseconds can hide a system where most requests return in 20 milliseconds but five percent take over two seconds, which is miserable for the unlucky five percent of users. Stating a target as p99 latency under a defined threshold
means 99 percent of requests must beat that threshold, directly constraining the tail behavior that averages conceal. Senior engineers default to percentile language (p50, p95, p99) rather than averages precisely because it protects the worst-off users, not just the typical ones.
Availability targets are usually expressed as a percentage of uptime, often written in the number of nines
shorthand. The table below shows how small differences in the stated percentage translate into very different amounts of tolerated downtime over a year, which is a useful number to have memorized since interviewers frequently probe whether a candidate understands what a target like four nines
actually costs in practice.
| Availability target | Approximate downtime per year |
|---|---|
| 99% (two nines) | About 3.65 days |
| 99.9% (three nines) | About 8.76 hours |
| 99.99% (four nines) | About 52.6 minutes |
| 99.999% (five nines) | About 5.3 minutes |
That jump from three nines to four nines, from under nine hours of downtime a year to under an hour, is not a small engineering increment. It typically requires eliminating single points of failure, automating failover, and often accepting higher cost and operational complexity. This is exactly why stating the availability target early matters so much: a design built for 99.9 percent availability looks meaningfully different, with fewer redundant components and simpler failover logic, than one built for 99.999 percent, and committing to the wrong target wastes either engineering effort or reliability.
Durability targets are a different axis entirely and are frequently confused with availability, so it is worth separating them clearly. Availability asks whether the system is reachable and responding right now. Durability asks whether data, once accepted, will still exist later, even if the system itself is temporarily unreachable. A system can have poor availability (frequently down for maintenance) while still having excellent durability (no data is ever lost once written), and the reverse is also possible, a system that always responds but occasionally loses a write under rare failure conditions. Cloud storage providers often advertise durability figures like 99.999999999 percent
(eleven nines) for stored objects, a number achieved through replication and checksumming rather than anything related to how often the service answers requests.
Consider applying this to a concrete pair of examples. A payment ledger entry is irreplaceable once a transaction has been confirmed to a customer, so durability dominates, and the design should favor writing to multiple replicas before acknowledging success, even at some latency cost, a theme revisited in the payments ledger walkthrough later in the book. A cached product thumbnail, by contrast, can be regenerated from the original image at any time, so durability barely matters, and the design can instead optimize purely for latency and availability, accepting that an occasional cache miss just means a brief regeneration delay rather than any real loss.
Writing these targets down explicitly, even in rough form, also protects you during the rest of a design conversation, since every later component choice can be justified by pointing back to one of these numbers. If an interviewer asks why you chose synchronous replication over asynchronous for a particular write path, the answer should trace directly back to a durability or consistency target stated during clarification, not to a vague sense that synchronous seems safer.
Treat confirming these targets as the gate between the clarification phase and the architecture phase, not as a bureaucratic formality to rush through. Every target left unconfirmed becomes a landmine later in the conversation, a moment where a follow-up question exposes an assumption you never actually confirmed. Engineers who pass through this gate deliberately, stating each target out loud before sketching a single box, tend to produce designs that survive scrutiny, because every subsequent decision traces back to a number someone agreed on rather than a guess nobody questioned. That discipline, more than any specific architecture pattern, is what separates a design that holds up from one that quietly falls apart the moment someone asks why?
[1]
Chapter 2: Estimates and Back-of-the-Envelope Math
A single rough calculation, 100 million users times 1 KB average payload times 10 requests per day, can instantly rule out an entire class of architecture before you draw a single box. That multiplication takes ten seconds and produces a number, roughly 1 terabyte of daily payload traffic, that tells you whether a single relational database on one machine is even worth discussing. Interviewers who ask system design questions are rarely testing whether you remember a specific architecture. They are testing whether you can turn a vague scale ("millions of users") into a concrete number that changes what you build. Skipping this step is one of the most common reasons a technically sound design falls apart under questioning, because every later choice, from how many database shards to provision to whether a CDN is worth the complexity, depends on numbers nobody bothered to compute.
The habit worth building is simple: before naming a single component, spend two or three minutes turning the prompt's vague scale into QPS, storage, and bandwidth figures. This is the back-of-the-envelope calculation, and it does not need to be precise. It needs to be directionally correct and fast. A number that is off by a factor of two rarely changes the architecture; a number that is off by a factor of a thousand always does. The goal is to find which side of that gap you are on, not to compute an exact figure that would require data you do not have in an interview anyway.
Three ingredients make this kind of estimation possible without a calculator doing the heavy lifting. The first is fluency with powers of two, since almost every unit of storage and memory in computing scales that way. The second is a memorized sense of latency numbers every engineer should know, since those numbers tell you where time actually goes in a request. The third is a repeatable method for estimating traffic, storage, and bandwidth from a handful of assumptions about users and their behavior. Each of these gets its own subchapter below, and together they form the toolkit used throughout the rest of this book whenever a numeric constraint needs defending out loud.
None of this replaces the work of clarifying functional requirement and nonfunctional requirement scope, covered in the discussion of clarifying requirements and scope. Estimation picks up right after that scoping is done, turning a prompt like "read-heavy social feed" into numbers you can act on. The two steps are sequential and both necessary: scope without numbers leaves every later decision unjustified, and numbers without scope answer the wrong question entirely.
Powers of Two and Latency Numbers
Storage and memory scale in powers of two because computer hardware addresses memory in binary, so fluency with this ladder turns unit conversion from a stumbling block into a reflex. The sequence worth having memorized cold is 210 = 1,024 (roughly a thousand, a kilobyte), 220 (roughly a million, a megabyte), 230 (roughly a billion, a gigabyte), 240 (roughly a trillion, a terabyte), and 250 (a petabyte). The trick that makes this fast under pressure is treating each of these as approximately its decimal equivalent: a kilobyte is approximately 103, a megabyte approximately 106, and so on. The error introduced by this approximation compounds slowly (about 2 to 3 percent per step up), which is harmless for a design conversation where the goal is the right order of magnitude.
The second half of this toolkit is a short list of latency numbers every engineer should know, a concept popularized by a widely circulated document in the systems community[1]. The reason this list matters for estimation is that it tells you where time is actually spent inside a request, which determines whether a design needs caching, replication, or neither.
The table below lists the figures worth having ready, rounded to numbers that are easy to recall and reason with.
| Operation | Approximate latency |
|---|---|
| L1 cache reference | 1 nanosecond |
| Main memory (RAM) reference | 100 nanoseconds |
| Read 1 MB sequentially from memory | 10 microseconds |
| SSD random read | 100 microseconds |
| Read 1 MB sequentially from SSD | 1 millisecond |
| Round trip within the same data center | 500 microseconds |
| Read 1 MB over a 1 Gbps network | 10 milliseconds |
| Disk seek on a spinning disk | 10 milliseconds |
| Round trip from California to the Netherlands | 150 milliseconds |
Two patterns jump out once these numbers sit side by side. First, for sequential reads memory is roughly 100 times faster than SSD (about 1,000 times for random access), and SSD is roughly 100 times faster than a disk seek, so each layer you push data toward memory saves close to two orders of magnitude. Second, a cross-continent network round trip dwarfs almost every local operation, which is precisely why CDNs and regional deployments exist: no amount of server-side optimization beats simply moving the data physically closer to the request.
Consider a concrete worked example. A request that needs one SSD random read (100 microseconds) plus one in-datacenter round trip to a cache (500 microseconds) completes in under a millisecond. The same request, if it instead has to cross an ocean to reach a single-region database, costs 150 milliseconds just in transit, far slower, before any processing happens. That single comparison, computed in seconds at a whiteboard, is often enough to justify a multi-region replica or an edge cache without needing any further data.
A second example shows how latency numbers settle an architectural argument about batching. Reading 1 MB sequentially from SSD costs roughly 1 millisecond, but many small random reads of the same total data can cost roughly two orders of magnitude more, a hundred times slower for the same total data. This is the numeric reason behind batched writes, sequential log files, and the write-ahead log pattern used by most databases: sequential access beats random access by roughly two orders of magnitude, and that gap only widens on spinning disks.
Tip: A fast recall trick
Round every latency number to the nearest clean power of ten before comparing them. Precision past one significant figure rarely changes a design decision, and rounding early keeps the arithmetic fast enough to do out loud.
It helps to internalize a short ladder of ratios rather than nine separate numbers: memory is about 100 times faster than SSD, SSD is about 10 times faster than a network round trip across a data center, and a cross-region round trip is about 100 times slower again than that same local trip. Memorizing ratios rather than raw values makes the numbers transferable: if an interviewer changes the hardware assumption (say, from SSD to spinning disk), the ratio still tells you roughly how much worse things get.
These numbers also expose a common mistake: assuming that adding more application servers fixes a latency problem. If the bottleneck is a disk seek or a cross-region round trip, no amount of horizontal scaling at the application layer touches the actual cost, since every added server still pays the same per-request latency tax. Recognizing this early, from the latency table alone, saves the embarrassment of proposing a scaling fix for a problem that scaling cannot solve.
One frequent error worth flagging directly: treating "milliseconds" and "microseconds" as interchangeable when speaking quickly under pressure. A thousand-fold unit mistake here (confusing a 500 microsecond round trip with a 500 millisecond one) can make a design look either wildly over-provisioned or dangerously underpowered to an attentive interviewer. Saying the unit out loud every time, deliberately and slowly, is a small habit that prevents a costly slip.
Finally, these numbers are approximations drawn from one well-known data center snapshot, not universal physical constants. Modern NVMe SSDs and improved networking have shrunk some of these gaps since the original figures circulated, and the exact numbers shift with hardware generation. What stays stable, and what matters for an interview, is the relative ordering: memory beats SSD, SSD beats disk, and local beats cross-region, by roughly the same order-of-magnitude gaps regardless of which decade's hardware you are reasoning about.
The checklist below is worth running through before any estimation conversation, so that the vocabulary and reflexes are ready before the first question lands.
Before you estimate anything
- Recall 2^10, 2^20, 2^30, 2^40 as roughly thousand, million, billion, trillion
- Recall that memory beats SSD by roughly 100x, and SSD beats disk seek by roughly 100x
- Recall that an in-datacenter round trip is roughly 0.5 millisecond and a cross-continent one is roughly 150 milliseconds
- Say every unit out loud (nanosecond, microsecond, millisecond) to avoid a thousand-fold slip
- Round to one significant figure before comparing numbers
With this vocabulary in place, the next step is turning a vague user count into the three numbers every design conversation eventually needs: how many requests per second, how much data to store, and how much bandwidth to move.
Estimating Traffic, Storage, and Bandwidth
Every traffic estimate starts from the same chain of assumptions: total users, the fraction active in a day, the actions each active user takes, and the time window those actions spread across. The output is QPS, the single number that determines whether one server can handle the load or whether the design needs load balancing and partitioning discussed in later chapters. The chain looks like this: daily active users times actions per user per day gives total daily actions, and total daily actions divided by the number of seconds in a day gives average QPS. From there, a peak multiplier (commonly 2x to 3x average, sometimes much higher for bursty traffic) gives the number that actually determines capacity planning.
Walk through a concrete example: a social feed app with 50 million daily active users, where each user opens the feed 5 times a day on average. That gives 250 million feed-read requests per day. Dividing by 86,400 seconds in a day gives roughly 2,900 QPS average. If feed reads cluster around a few peak hours rather than spreading evenly (which is realistic for any app with regional usage patterns), a 3x peak multiplier puts the design target closer to 9,000 QPS. That single number now tells you whether one well-provisioned server (which might handle a few thousand QPS for a simple read) is enough, or whether you need load balancing across many instances from the start.
Storage estimates follow a similar chain, but the multiplier is the size of each stored record rather than a peak traffic factor. Take a URL shortener handling 100 million new short links created per month, where each stored record (the short code, the original URL, and some metadata) takes roughly 500 bytes. That gives 50 GB of new data every month, or about 600 GB a year. Scaling that out five years gives roughly 3 TB, comfortably inside what a single well-indexed database can hold with room to spare, which argues against partitioning purely for storage volume on this workload. This is the kind of estimate that, computed in under a minute, settles an entire branch of the design conversation.
Bandwidth estimation multiplies request volume by payload size to find out how much data moves across the network per second, which in turn determines whether a CDN or edge caching layer is worth its complexity. Returning to the feed example: if each feed-read response averages 50 KB (a page of posts with thumbnails and metadata), the average bandwidth is 2,900 QPS times 50 KB, which is about 145 MB per second, or roughly 1.16 Gbps sustained. At peak with the 3x multiplier, that climbs to about 3.5 Gbps, a figure that immediately justifies pushing static assets like images to a CDN rather than serving them from the origin.
The table below lays out these three worked examples side by side so the pattern (assumption in, order-of-magnitude number out) is easy to reuse on a new prompt.
| Scenario | Key assumption | Resulting estimate |
|---|---|---|
| Social feed QPS | 50M DAU, 5 reads/user/day, 3x peak | About 9,000 peak QPS |
| URL shortener storage | 100M new links/month, 500 bytes/record | About 3 TB after 5 years |
| Feed bandwidth | 9,000 peak QPS, 50 KB per response | About 3.5 Gbps at peak |
A second storage example worth working through is a chat system storing message history. Suppose 20 million daily active users send an average of 40 messages a day, each message averaging 100 bytes of text plus metadata. That is 800 million messages a day, or 80 GB of new message data daily, roughly 29 TB a year. Unlike the URL shortener, this volume is high enough that a single database instance becomes a real constraint within one or two years, which is the kind of finding that justifies introducing partitioning (covered in the chapter on database replication and partitioning) well before storage limits are actually hit.
Write versus read ratios deserve separate estimation, since the two place very different demands on a system. A social feed is read-heavy, often by a factor of 100 to 1 or more (far more views than posts), while a payments ledger is closer to balanced or even write-heavy relative to typical read patterns. Estimating both directions separately, rather than lumping them into one QPS figure, reveals whether the bottleneck is read capacity (which caching and read replicas address) or write capacity (which generally points toward partitioning instead). Skipping this split is a common mistake that leads candidates to propose a cache for a write-heavy workload where it does little good.
A frequent mistake worth naming directly is treating every number in the prompt as fixed rather than negotiable. An interviewer who says "millions of users" is often leaving the exact figure deliberately open, inviting a stated assumption like "let's say 10 million daily active users" rather than a request for more precision. Stating the assumption plainly, then moving forward with the arithmetic, demonstrates the trade-off narration this book returns to repeatedly, since it makes the reasoning auditable rather than hidden inside an unstated guess.
Another mistake is computing a number and then never referring back to it. An estimate that sits unused is wasted effort, so the better habit is explicitly connecting each number to a decision, phrased something like "at 9,000 peak QPS, a single database instance handling simple key lookups is plausible, but a join-heavy query pattern at this volume likely needs read replicas." That sentence does two things at once: it shows the arithmetic was not decorative, and it sets up the component discussion that follows naturally in the rest of a design conversation.
It also helps to sanity-check an estimate against a known reference point when one is available. If a calculation suggests a social app needs 50,000 QPS at peak from 10 million daily users, and that number feels far higher than publicly discussed figures for apps of similar scale, it is worth rechecking the multiplier or the per-user action count before building an architecture around a possibly inflated number. This kind of self-correction, done out loud, reads as rigor rather than uncertainty.
With average QPS, peak QPS, storage growth, and bandwidth now computable from a handful of stated assumptions, the next step is using those numbers to actually choose between architectural options rather than just producing figures for their own sake.
Using Estimates to Drive Design Choices
An estimate that never changes a decision is wasted arithmetic, so the purpose of this final piece is connecting the numbers from the previous two subchapters directly to architecture choices. The core discipline is asking, after every number is computed, "does this number change what I would otherwise build?" If a storage estimate comes out to 3 TB and a single modern database server can comfortably hold tens of terabytes, the answer is no, and partitioning is not yet justified on storage grounds alone. If the same estimate comes out to 300 TB, the answer is clearly yes, and the conversation shifts immediately toward partition key choices covered in the chapter on database replication and partitioning.
QPS estimates map onto a similar threshold logic, though the thresholds depend on what kind of work each request does. A single well-provisioned server handling simple key-value lookups might comfortably sustain 10,000 to 50,000 QPS, while a server running complex joins or heavy computation per request might struggle past a few hundred. The estimate itself does not know which kind of workload it describes, so pairing the QPS number with an honest sense of per-request work is what turns a raw figure into a decision. A design that computes 9,000 peak QPS for simple reads might stay on a single instance with a cache in front, while the same 9,000 QPS for write-heavy, join-heavy operations likely needs load balancing (covered in the next chapter) and probably partitioning as well.
Bandwidth estimates drive a different kind of decision: whether static or semi-static content needs a CDN, and whether payload size itself needs trimming. Returning to the feed example from the previous subchapter, 3.5 Gbps of peak bandwidth sustained from a single origin is a meaningful operational cost and a real point of failure, which argues for pushing images and other cacheable assets to a CDN, discussed in the chapter on caching strategies and content delivery. If instead the bandwidth estimate came out to 35 Mbps, the argument for that same CDN investment weakens considerably, since the origin servers could likely absorb that load directly without added complexity.
Estimates also expose hidden assumptions that a diagram alone would never surface. Consider a design for a ticket-booking system during a flash sale, where 2 million users might attempt to buy tickets within a 10-minute window for a popular event. Spreading that load evenly gives roughly 3,300 QPS, a manageable figure, but flash-sale traffic rarely spreads evenly. A more honest model assumes 80 percent of attempts cluster in the first 60 seconds, which pushes effective peak QPS closer to 26,000, nearly eight times the naive average. This is exactly the kind of gap that justifies discussing a queue-based buffering strategy (the subject of the chapter on queues, retries, and idempotency) rather than trying to provision enough database capacity to handle a raw spike directly.
It is worth stress-testing an estimate by asking what happens if a key assumption is wrong by 10x in either direction, since interviewers frequently probe exactly this. If the design for 9,000 peak QPS still functions reasonably at 900 QPS (it almost certainly does, just over-provisioned) and still has a credible next step at 90,000 QPS (add more cache layers, consider partitioning), the design demonstrates resilience to the estimate being wrong. A design that completely falls apart at 10x the stated scale, with no stated next move, suggests the estimate was treated as a fixed fact rather than a planning tool with built-in uncertainty.
The habit of pairing a number with a decision also protects against a subtler failure: over-engineering for a scale that was never actually justified. A candidate who jumps straight to a globally distributed, multi-region, fully partitioned design for a prompt that estimation shows needs only a few thousand QPS and a few hundred gigabytes of storage is demonstrating pattern-matching against memorized diagrams, not reasoning from first principles. The estimate is what licenses complexity. Without it, every added component is an assumption dressed up as a decision, and a sharp interviewer will ask exactly why that component is there.
None of this arithmetic replaces judgment about the business itself. A 3 TB storage estimate that looks trivial for a general-purpose application might still warrant early partitioning if the durability target or regulatory requirement demands strict data isolation by region, a nonfunctional requirement concern that sits outside the math itself. Estimation narrows the field of reasonable architectures; it does not replace the judgment calls that come next, about load balancing algorithms, caching patterns, and replication strategies, each of which takes the numbers computed here as its starting input rather than its final word.
Chapter 3: Load Balancing and Traffic Distribution
When a single IP address serves millions of requests per second, the machine answering that IP is almost certainly not the machine doing the work, and the strategy that decides who does the work changes everything about how the system fails. A DNS lookup for a popular service resolves to a handful of stable addresses, but behind those addresses sit fleets of machines that come and go, get overloaded, crash, and get replaced constantly. The component standing between the public address and that churn is the load balancer, and its job is deceptively simple to describe: take incoming requests and route each one to a server that can handle it. The hard part is doing that job while servers fail mid-request, while some servers are secretly slower than others, and while the balancer itself must not become the single point of failure for the entire system.
Treat a load balancer as a traffic cop standing at an intersection with several open lanes, deciding in real time which lane each car should take. A bad traffic cop sends every car down the same lane out of habit, creating a jam while three other lanes sit empty. A good one watches which lanes are backed up and adjusts. The same logic applies to servers behind a load balancer: naive distribution can leave one instance pinned at full CPU while its neighbors idle, and the algorithm chosen to pick a destination determines whether that happens. This chapter works through the mechanics of that decision at two different layers of the network stack, compares the main algorithms used to pick a destination, and covers how a load balancer detects a dead or struggling server before that failure reaches a user.
Load balancing connects directly to the capacity numbers from back-of-the-envelope calculation: once you know your target QPS and the capacity of a single server, the load balancer is the mechanism that turns one server's limit into a fleet's aggregate capacity. It also sits upstream of nearly every other failure-handling pattern discussed later in this book. A circuit breaker protects one service from a slow dependency, but the load balancer is often the first line of defense, pulling a slow or unhealthy node out of rotation before the circuit breaker ever has to trip. Getting load balancing wrong does not just cost performance, it changes the blast radius of every failure downstream.
Most production systems run load balancing at more than one layer simultaneously. A global DNS-based layer spreads traffic across regions, a hardware or managed load balancer spreads traffic across availability zones within a region, and often an internal, software-based layer spreads traffic between microservices inside a cluster. Each layer solves a narrower problem than the one below it, and conflating them is a common mistake in interview answers: a candidate who says we'll put a load balancer in front
without specifying which layer, which algorithm, and which failure behavior has described a box on a diagram, not a design decision.
Layer 4 Versus Layer 7 Balancing
The distinction between Layer 4 load balancing and Layer 7 load balancing comes from the OSI networking model, and it determines how much information the balancer sees when it makes a routing decision. A Layer 4 balancer operates at the transport layer, meaning it looks at IP addresses and TCP or UDP port numbers only. It does not open the packet to read HTTP headers, cookies, or the request path, it simply forwards packets based on connection-level information. This makes it extremely fast, because there is almost nothing to inspect, and it can handle raw throughput measured in millions of packets per second with modest hardware.
A Layer 7 balancer operates at the application layer, which means it terminates the connection, reads the actual HTTP request, and can make decisions based on the URL path, request headers, cookies, or even the request body. This unlocks routing logic that Layer 4 cannot do at all: sending /api/video traffic to a video-serving cluster while /api/checkout goes to a payments cluster, or routing based on a user's account tier carried in a header. The cost is CPU and latency, because every request must be parsed and often decrypted first if TLS terminates at the balancer. For a service with diverse endpoints and varying backend needs, that cost is usually worth paying.
A concrete way to see the difference is to imagine an e-commerce platform with three backend clusters: one for product search, one for the shopping cart, and one for payment processing. A Layer 4 balancer cannot tell these apart, since all three answer on the same port over the same protocol, so it would need three separate virtual IP addresses and three separate balancer configurations, one per cluster, with clients choosing which address to hit. A Layer 7 balancer can sit in front of a single public address, inspect the path of each incoming request, and route /search, /cart, and /checkout to their respective clusters automatically. This single capability is often the deciding factor in choosing Layer 7 for a public-facing API gateway.
Layer 4 still wins in scenarios where raw speed matters more than routing intelligence, such as balancing traffic for a high-throughput game server using UDP, or load balancing at the edge of a data center where every microsecond of added latency compounds across millions of connections. Many real systems use both: a Layer 4 balancer as the outermost tier, spreading connections across a pool of Layer 7 balancers, which then make the smarter, content-aware decisions before handing requests to backend services. This layered approach avoids making the fast outer tier do slow work, while still getting path-based and header-based routing where it is actually needed.
The table below summarizes the trade-off so it can be stated quickly and precisely in a design discussion, which matters because interviewers often probe exactly this distinction to check whether a candidate understands what is actually happening under a load balancer icon on a whiteboard.
| Aspect | Layer 4 | Layer 7 |
|---|---|---|
| Visibility | IP address and port only | Full HTTP request: path, headers, cookies, body |
| Routing logic | Connection-level only | Content-aware, path-based, header-based |
| Throughput | Very high, low CPU cost | Lower, due to parsing and TLS termination |
| Typical use | Edge of data center, UDP/TCP services | API gateways, microservice routing |
| SSL termination | Usually passthrough | Commonly terminates TLS at the balancer |
Both layers still need the same underlying algorithms to pick which backend instance gets a given connection or request, which is the next question once the layer is chosen. A Layer 4 balancer typically sticks with simpler algorithms because it has less information to work with, while Layer 7 balancers can incorporate request content into the decision, for example routing all requests from a given user to the same backend to preserve an in-memory session. That capability, called session affinity or sticky sessions, is itself a trade-off: it simplifies state management on the backend but it reintroduces the uneven-load problem if a handful of sticky users happen to generate outsized traffic on one instance.
It helps to connect this distinction back to the TLS handshake and connection setup costs covered under latency numbers every engineer should know: a Layer 7 balancer pays those costs once at the edge and often reuses a smaller number of persistent connections to backends, which can actually reduce total connection overhead across a fleet compared to naive Layer 4 passthrough. This is one reason large API providers standardize on Layer 7 balancers even though they cost more CPU per request, the aggregate system often ends up cheaper and more predictable.
A design mistake worth naming directly: treating load balancer
as a single undifferentiated concept in an interview answer. Stating explicitly which layer is in play, and why, signals the kind of first-principles reasoning this book is built around, rather than a memorized diagram with a box labeled LB
and no further explanation.
Round Robin, Least Connections, and Consistent Hashing
Once a load balancer has decided which backend pool a request belongs to, it still has to pick one specific server from that pool, and this is where the choice of algorithm has real consequences. The simplest option is round-robin, which cycles through the list of available servers in order, sending the first request to server one, the second to server two, and so on, wrapping back to the start. It requires almost no state and is trivial to implement, which is why it remains the default in many load balancer configurations. Its weakness shows up the moment backend servers are not identical in capacity or the moment requests are not identical in cost, since round-robin has no concept of either.
Picture a pool of five servers where one has twice the CPU of the others because it was provisioned on a larger instance type. Round-robin sends it the same share of traffic as its weaker siblings, wasting its extra capacity while the smaller instances strain to keep up. A weighted variant of round-robin fixes this specific problem by assigning each server a weight proportional to its capacity, so the stronger server receives, for example, two requests for every one the weaker servers receive. This is a cheap and common fix, but it still assumes request cost is uniform, which rarely holds in systems with a mix of lightweight reads and heavy writes or large file uploads.
Least-connections routing solves the uneven-request-cost problem directly by tracking how many active connections each backend currently holds and sending new requests to whichever server has the fewest. A server stuck processing a few slow, expensive requests naturally falls behind in traffic share until it catches up, because the balancer is reacting to real-time load rather than a fixed rotation. This makes least-connections a better default for systems with highly variable request durations, such as a service mixing quick metadata lookups with slow report generation. The cost is that the balancer must maintain live state about every backend's connection count, which adds a small amount of bookkeeping overhead and requires the balancer instances themselves to coordinate or share that state if there is more than one of them.
Consistent hashing solves a completely different problem: how to route a given key, such as a user ID or a cache key, to the same backend repeatedly, without having to add every request's history. The naive approach of hash(key) mod N, where N is the number of servers, works until N changes. Adding or removing a single server with that naive scheme reshuffles almost every key's assigned server, because the modulus changes for nearly every hash value, which would mean a near-total cache invalidation or session loss every time the fleet scales. Consistent hashing avoids this by mapping both servers and keys onto the same conceptual ring of hash values, typically visualized as a circle from 0 to some maximum value, and assigning a key to the next server found moving clockwise from the key's position on the ring.
The payoff is that adding or removing a server only affects the keys that land in the small arc of the ring near that server, leaving the rest of the mapping undisturbed. In a cache-backed system, this difference is the difference between losing a sliver of your cache when a node joins and losing almost the entire cache, directly affecting the staleness and thundering-herd concerns covered in the chapter on caching strategies. Imagine a distributed cache with 10 nodes serving a social media platform. With naive modulus hashing, scaling to 11 nodes invalidates the vast majority of cached entries, since nearly every key's modulus result changes, causing a thundering herd against the origin database. With consistent hashing, scaling to 11 nodes invalidates only a small fraction of the keys, those whose position on the ring now falls under the new node's arc.
Plain consistent hashing has its own weakness, though: if only a handful of points represent each physical server on the ring, the arcs can end up uneven by chance, giving one server a much larger slice of keys than another and creating a hotspot. The standard fix is virtual nodes, where each physical server is represented by many points scattered around the ring, often many points per machine, rather than just one. This smooths out the distribution statistically, the way rolling a hundred dice averages out closer to the expected mean than rolling just one. Virtual nodes also make capacity-aware scaling easier, because a more powerful machine can simply be assigned more virtual points on the ring, giving it a proportionally larger share of keys without changing the underlying algorithm at all.
Choosing among these three algorithms is a concrete design decision that should be stated and justified, not assumed. Round-robin or weighted round-robin fits stateless services with roughly uniform request costs, where simplicity and low overhead matter most. Least-connections fits services with variable request durations where real-time load matters more than simplicity. Consistent hashing fits any scenario where the same key must reliably land on the same backend, most commonly caching layers, sharded databases, and sticky session routing, and it should almost always be paired with virtual nodes in production rather than deployed in its plain form.
Choosing a Load Balancing Algorithm
- Does the same key need to repeatedly reach the same backend?
- Yes, e.g. cache keys or sessions Use consistent hashing with virtual nodes
- No Are request costs roughly uniform across requests?
- Yes, uniform cost Use weighted round-robin
- No, variable cost Use least-connections
A practical checklist helps when the question in an interview shifts from pick an algorithm
to justify it under a changed constraint,
which is exactly the kind of pressure this book is built to prepare for.
Before Committing to an Algorithm
- Confirm whether backend capacity is uniform or varies by instance type
- Confirm whether request cost is uniform or varies widely by endpoint
- Check whether any requests require session affinity or key stability
- If using consistent hashing, confirm virtual nodes are configured to avoid hotspots
- Decide whether the balancer needs live connection-count state, and how that state is shared if there are multiple balancer instances
Health Checks and Failover Behavior
None of these algorithms matter if the load balancer keeps sending traffic to a server that is dead, overloaded, or stuck in a bad state, which is why every production load balancer runs continuous health checks against the pool it manages. A health check is a small, repeated probe, typically an HTTP request to a dedicated endpoint like /health or a raw TCP connection attempt, sent on a fixed interval. If a server fails a configured number of consecutive checks, usually a small number in a row to avoid reacting to a single transient blip, the balancer marks it unhealthy and stops routing new traffic to it. When the server starts passing checks again, it is returned to rotation, often after a short grace period to avoid flapping it in and out repeatedly.
There are two broad categories of health check, and conflating them is a common gap in otherwise solid designs. A passive health check infers health from real traffic, noting that a server has returned a string of errors or timeouts on actual user requests and marking it unhealthy based on that observed behavior. An active health check sends synthetic probe requests independent of real user traffic, specifically to test liveness even during quiet periods when no real requests happen to be hitting that server. Active checks catch failures faster and more predictably, since they do not depend on traffic volume, but they add a small constant load to every backend, which matters at very large fleet sizes. Most production systems run both, using active checks as the primary signal and passive checks as a faster secondary trigger for obviously broken servers.
A health check endpoint deserves more thought than it usually gets. A shallow check that only confirms the process is running and answering on its port can return healthy even when the server cannot actually reach its database or a critical downstream dependency, which means the balancer keeps sending it traffic it cannot actually serve. A deeper check that verifies database connectivity and key dependency health gives a more honest signal, but it is also slower and more expensive to run on every interval, and a dependency outage can then cascade into marking an entire healthy fleet unhealthy at once, since every instance shares the same broken dependency. The right depth depends on what failure mode you are protecting against, and stating that trade-off out loud is exactly the kind of reasoning a senior-level design discussion rewards.
A shallow health check answers
is the process alive?A deep health check answerscan this process actually do its job right now?Neither answer is free, and picking the wrong depth either hides real failures or amplifies them across the whole fleet.
Failover is what happens after a health check marks a server, or an entire zone, as unavailable. At the server level, failover simply means the load balancer stops sending it new connections and redistributes that share of traffic across the remaining healthy servers using whatever algorithm is configured, whether that is weighted round-robin recalculating its weights or consistent hashing reassigning the arc that server used to own. In-flight requests already sent to the failing server are typically lost from the balancer's perspective, which is exactly why idempotency and safe client retry behavior, covered under retry budgets and backoff elsewhere in this book, matter so much at this boundary. A load balancer failing a server over without the client side handling the resulting error gracefully just moves the failure one layer up the stack instead of resolving it.
Failover at a larger scale, such as an entire availability zone going dark, works the same way in principle but with higher stakes. A regional load balancer or global DNS layer detects that an entire pool of backends behind one zone's address has failed its health checks and redirects all traffic to the remaining healthy zones. This only works if those remaining zones actually have spare capacity to absorb the extra load, which ties directly back to the capacity planning done with back-of-the-envelope calculation. A system provisioned at exactly 100 percent utilization across two zones has no headroom to absorb one zone's traffic if the other goes down, meaning the failover mechanism works correctly but the surviving zone simply falls over under the new load, trading one outage for another.
Consider a concrete scenario: a payments API runs across three availability zones, each provisioned to handle more than its equal share of peak traffic, giving the fleet excess capacity in aggregate. If one zone fails entirely, the remaining zones absorb its share, bringing each to a manageable level of its own capacity, which is uncomfortable but survivable. If the same fleet had been provisioned at exactly 100 percent aggregate capacity split evenly across three zones, a single zone failure would push the surviving zones beyond their own capacity, guaranteeing cascading timeouts and a fully visible outage rather than a contained one. This is the arithmetic behind the common guidance to provision for N+1 or N+2 zone failures rather than for exactly the traffic you expect on a good day.
Warning: Health Checks Without Headroom Are a Trap
A health check and a failover mechanism only protect you if the surviving capacity can actually absorb the redirected load. Provisioning at exactly 100% utilization across zones means a failover does not prevent an outage, it just relocates it. Plan spare capacity explicitly as part of the SLO, not as an afterthought.
Health checks and failover also interact with the balancer's own availability, since a load balancer that is itself a single machine is a single point of failure no matter how well it checks its backends. Production systems run load balancers in pairs or clusters with their own failover mechanism, often using a floating virtual IP address that can move to a standby balancer if the primary dies, or by running the balancing logic as a stateless layer behind DNS so that any instance can take over without coordination. This is the same pattern appearing one layer up: the thing that watches for failure must not itself be a hidden single point of failure, a principle that recurs throughout graceful degradation and bulkhead patterns discussed later in this book.
Interviewers frequently probe health checks by changing a constraint mid-discussion, for example asking what happens if the health check endpoint itself becomes the bottleneck under load, or if a server passes health checks but serves stale data due to a lagging replica. A strong answer names the specific risk directly: a health check that only measures liveness, not correctness, can mark a replica with severe replication lag as fully healthy, silently serving outdated reads to users even though every technical signal looks green. Catching this requires extending the health check to include a staleness threshold, trading a slightly more complex probe for the ability to protect read correctness, a trade-off worth stating explicitly rather than assuming the health check already covers it[1].
The foundational ideas behind consistent hashing trace back to early work on web caching systems, where researchers needed a way to add and remove cache servers without invalidating the majority of cached content on every change[2]. That same mechanism, originally designed for web caches, now underlies load balancer routing, distributed cache sharding, and partitioned database key assignment, which is a useful thing to mention out loud in an interview since it signals that the technique is understood as a general tool rather than a fact memorized for one specific use case.
Chapter 4: Caching Strategies and Content Delivery
Cache invalidation is famously one of the two hard problems in computer science, but the real difficulty is not the mechanics. It is deciding how wrong your data is allowed to be, and for how long. A cache is a bet: you are betting that the value you stored a moment ago is still close enough to the truth to serve again, and that serving it faster is worth the risk of serving it stale. Every caching decision in a system design interview, and in production, comes down to naming that bet explicitly rather than hiding it behind a diagram box labeled cache
.
The appeal of caching is simple arithmetic. If a database read costs a few milliseconds plus contention under load, and a cache read costs a fraction of a millisecond from memory, moving repeated reads to the cache multiplies the system's effective capacity without multiplying its hardware. This connects directly to the latency numbers every engineer should know from the back-of-the-envelope calculation discussion: a trip to main memory and a trip to a remote database are not the same order of magnitude, and a well-placed cache is how you collapse that gap into your favor for the requests that matter most.
But caching is never free, and the cost does not show up as money. It shows up as a new category of bug: the stale read, the double-write race, and the cold cache that collapses under a thundering herd the moment it empties out. Those failure modes are predictable once you understand the pattern you chose, which is why this chapter treats cache-aside, write-through, and write-back as distinct contracts rather than interchangeable implementation details.
The same logic that governs an in-process or a shared cache also governs a CDN, just at a different point in the request path. A CDN is a cache, full stop, except that it sits geographically close to the user instead of close to your database. Understanding caching as a single discipline, applied at different layers, makes a CDN feel far less mysterious than treating it as a separate black box bolted onto the front of the architecture.
By the end of this chapter you should be able to pick a caching pattern for a given read and write pattern, defend a TTL value with a reason instead of a guess, and explain how a CDN changes both latency and the invalidation problem. Each of those three skills maps to one of the subchapters ahead.
Cache-Aside, Write-Through, and Write-Back Patterns
The cache-aside pattern (also called lazy loading) is the default most engineers reach for first, because it requires no change to how the database is written. The application checks the cache on a read; if the value is present (a hit), it returns immediately; if absent (a miss), the application reads from the database, writes the result into the cache, and then returns it. Writes go straight to the database, and the cache entry for that key is typically deleted or left to expire, not updated in place. This keeps the cache logic entirely in the hands of the application, which makes it easy to reason about but also easy to get wrong if a developer forgets to invalidate on write.
Cache-aside shines for read-heavy workloads with uneven access patterns, such as a product catalog page or a user profile lookup, where a small number of keys account for most of the traffic. It tolerates a cold start gracefully: an empty cache simply means every read takes the slow path once, then speeds up. The tradeoff is a guaranteed miss on first access to any key, and a window between a database write and the next read where an old cached value can still be served if invalidation is not handled carefully. A common implementation deletes the cache key on write rather than updating it, because deleting is simpler to get correct than keeping two copies in sync.
The write-through pattern takes a different stance: every write goes to the cache and the database together, synchronously, before the write is considered complete. The cache is always a faithful mirror of the database (ignoring replication lag within the cache itself), so reads never need to worry about a stale entry caused by a missed invalidation. This removes an entire class of bugs that cache-aside is prone to, at the cost of making every write slower, since it now waits on two systems instead of one. Write-through fits workloads where reads vastly outnumber writes and correctness on read matters more than raw write throughput, such as configuration data or pricing tables that change rarely but get read constantly.
The write-back pattern (sometimes called write-behind) flips the latency trade the other way. A write lands in the cache and is acknowledged to the caller immediately, and the cache asynchronously flushes the change to the database later, often batched with other pending writes. This gives the lowest possible write latency and can dramatically cut database load by coalescing many rapid writes to the same key into one eventual database update. The risk is durability: if the cache node crashes before the flush completes, that write is gone, which is why write-back is rarely used for data a business cannot afford to lose, and far more common for things like view counters, session state, or analytics counters where losing a few recent increments is an acceptable cost.
The table below lines up the three patterns against the dimensions that actually matter when choosing between them.
| Pattern | Write latency | Read consistency | Durability risk |
|---|---|---|---|
| Cache-aside | Fast (writes bypass cache) | Weak if invalidation is missed | None added by caching |
| Write-through | Slower (writes hit both) | Strong, cache mirrors database | None added by caching |
| Write-back | Fastest (cache only, then async) | Strong for cache reads | Data loss if cache fails before flush |
A worked example clarifies the choice. Imagine a system tracking likes
on a social post. A write-back approach would accept a like, bump an in-memory counter, acknowledge the user instantly, and flush the aggregate count to the database every few seconds. Losing the last two seconds of likes in a crash is a shrug; the business impact is near zero. Now imagine the same system tracking an account balance after a purchase. Write-back is disqualified immediately, because losing an acknowledged write means a customer paid and the ledger disagrees. That calls for write-through, or more likely no cache on the write path at all, with caching reserved for read-only derived views.
A second worked example shows cache-aside in its natural habitat: a get user profile by ID
endpoint behind a web application. On a cache miss, the application fetches from the primary database, stores the result under the key user:{id} with a TTL, and returns it. On a profile update, the application deletes user:{id} from the cache rather than rewriting it, so the next read repopulates it from the single source of truth. This single rule, delete, don't update, on write,
prevents a whole class of race conditions where two concurrent writes could otherwise leave a newer write overwritten by a slower, stale one landing in the cache afterward.
Choosing between these patterns is rarely about which one is best in the abstract; it is about matching the pattern to the read-to-write ratio and the cost of being wrong. A rough mental checklist helps during an interview:
- If reads vastly outnumber writes and staleness is tolerable for a short window, reach for cache-aside.
- If reads must never see stale data and writes can absorb extra latency, reach for write-through.
- If writes are frequent, bursty, and individually low-stakes, reach for write-back.
- If a single lost write would cause real harm (money, inventory, safety), do not put that specific write path behind write-back under any circumstances.
It helps to separate what is cached from how it is written. Many real systems use more than one pattern simultaneously: write-through for a small set of critical, frequently read fields, and cache-aside for everything else, because not every field on a record carries the same cost of staleness. A product record's price might be write-through, while its long-form description, which changes rarely and tolerates being a few minutes old, sits comfortably under cache-aside.
Combining patterns within one service is not a sign of an inconsistent design, it is a sign of a design that has actually reasoned about which fields are expensive to get wrong. Naming that distinction out loud in an interview, rather than treating the cache
as one monolithic layer, is exactly the kind of granularity that separates a senior answer from a memorized one.
One frequent mistake is caching at the wrong granularity: caching an entire API response object when only one field inside it changes often, forcing an invalidation of the whole object on every minor update. A better approach decomposes the cached value into smaller keys aligned with how the data actually changes, so a price update invalidates only the price key, not the whole product page. This is a design decision made at cache-key design time, not an afterthought bolted on later.
Another common mistake is applying write-through to a system with high write volume and few reads, which pays the double-write cost on every single write while gaining almost nothing, since there is barely any read traffic to benefit from the always-fresh cache. Matching the pattern to the actual read-to-write ratio, measured rather than assumed, avoids this kind of wasted latency.
The original formalization of these ideas traces back to classic caching and buffer-management research in operating systems and databases, long before web caches existed, and the same reasoning about write-back versus write-through buffer policies in disk systems carries over almost unchanged to today's distributed caches[1].
Invalidation, Expiry, and Accepting Staleness
Every cached value has an expiration policy, whether or not anyone wrote one down. A TTL (time to live) is the explicit version: a timestamp or countdown attached to a cache entry after which it is treated as absent, forcing the next read to go back to the source of truth. Choosing a TTL is not a technical decision so much as a business one, because it directly answers the question this chapter opened with: how wrong is this data allowed to be, and for how long? A TTL of five seconds and a TTL of one hour both work
in the sense that neither will crash the system, but they produce very different user experiences and very different database loads.
Short TTLs reduce staleness, the gap between what is cached and what is currently true in the source of truth, but they also reduce the cache hit ratio, the fraction of reads served from cache rather than falling through to the database. A hit ratio of 95 percent means the database only sees 5 percent of read traffic, which might be the difference between a database comfortably handling load and one falling over. Pushing the TTL down to chase freshness can quietly erode that ratio and push real load back onto the exact system the cache was meant to protect, so TTL tuning is always a negotiation between freshness and protection, not a free freshness upgrade.
The bar chart below illustrates this trade-off with an illustrative example of how hit ratio tends to move as TTL increases, holding traffic constant.
| TTL | Hit ratio | |
|---|---|---|
| 1 second | 40% | |
| 10 seconds | 72% | |
| 60 seconds | 88% | |
| 10 minutes | 95% |
Explicit invalidation is the alternative to waiting out a TTL: actively telling the cache a specific key is no longer valid the moment the underlying data changes, usually by deleting that key. This is more precise than a TTL, because it removes stale data at the exact moment it goes stale rather than at some arbitrary countdown, but it requires every code path that writes to the source of truth to remember to also invalidate the cache. Missing even one write path, such as a backfill script or an admin tool that bypasses the normal application layer, silently reintroduces staleness that no amount of careful application code elsewhere will fix. Many production incidents trace back to exactly this: a second write path nobody remembered to wire into the invalidation logic.
A practical pattern combines both: use explicit invalidation as the primary mechanism for correctness, and attach a TTL as a safety net in case an invalidation call is ever missed, dropped, or delayed by a network issue. This way, a missed invalidation only produces staleness bounded by the TTL window, rather than staleness that lasts indefinitely until someone notices and manually flushes the cache. Treating the TTL as a backstop rather than the primary mechanism is the difference between staleness being a controlled, named risk and staleness being an open-ended unknown.
The thundering herd problem appears at the exact moment a popular cache entry expires or is invalidated. If a key is read thousands of times per second and it suddenly disappears, every one of those reads misses simultaneously and stampedes the database at once, often with identical queries, which can overload or even crash a database that was otherwise comfortably within capacity. This is especially dangerous for celebrity or viral content: a popular post's cache key expiring during a traffic spike can take down the exact system meant to handle that spike.
Several mitigations exist, and naming more than one in an interview signals real understanding rather than a memorized fix:
- Request coalescing (single-flight): when a key misses, let only the first request go to the database while concurrent requests for the same key wait on that result, rather than each triggering its own database call.
- Jittered TTLs: instead of every related key expiring at exactly the same second, randomize each TTL slightly (for example, a base TTL plus or minus a small random offset) so expirations spread out over time instead of clustering.
- Stale-while-revalidate: serve the expired value for a short grace period while one background request refreshes it, so users never see a miss at all, only a slightly older value for a few extra seconds.
- Precomputed refresh: for known hot keys, refresh the cache proactively on a schedule, before expiry, rather than waiting for a user request to trigger the miss.
The callout below captures the central reframe this subchapter is built around, worth stating plainly because it is easy to lose sight of once the mechanics take over the conversation.
Key takeaway: Staleness is a parameter, not a bug
A cache without a named staleness tolerance is not a design decision, it is an accident waiting to be discovered in production. State the acceptable staleness window out loud (for example, up to 10 seconds old) before choosing a TTL or an invalidation strategy, so the number is chosen on purpose rather than inherited from a framework default.
Consider a concrete scenario to make this tangible: an e-commerce product page showing 12 left in stock.
If inventory is cached with a 60-second TTL, a shopper could see 12 left
and place an order after it actually hit zero, creating an overselling problem the checkout flow then has to handle gracefully, perhaps by checking real inventory again at checkout time regardless of what the cached page showed. This is a case where the cached value is explicitly allowed to be wrong for browsing purposes, because enforcing strict accuracy at checkout time is cheaper than keeping every product page perfectly live. Naming that split, loose caching for browsing, strict checking for the transaction itself, is a textbook example of trade-off narration applied to caching specifically.
A failure mode worth naming separately from thundering herd is cache stampede on startup, sometimes called a cold cache: when a brand-new cache instance, or one that was just flushed or restarted, has nothing in it at all, so the very first wave of traffic after a deploy or a cache cluster restart behaves like a 0 percent hit ratio event across the board. Warming the cache before cutting traffic over to it, by pre-loading the most frequently accessed keys from a snapshot or a known hot-key list, avoids subjecting the database to a sudden full-traffic cold start.
The do's and don'ts table below collects the practical habits that separate a resilient caching layer from one waiting to cause an incident.
| Do This | Don't Do This |
|---|---|
| Set a TTL as a backstop even when using explicit invalidation | Rely on explicit invalidation alone with no expiry safety net |
| Jitter TTLs on related keys to avoid synchronized expiry | Set every related key to expire at the exact same instant |
| State the acceptable staleness window explicitly in design docs | Leave staleness as an unstated side effect of the framework default |
| Audit every write path (including admin tools, backfills) for invalidation calls | Assume the normal application write path is the only one that matters |
The two generals' problem and related consistency research explain why cache invalidation is formally hard: you cannot always guarantee that an invalidation message arrives before a conflicting read, especially across a network, which is part of why the TTL-as-backstop approach is standard practice rather than a workaround for lazy engineering[2].
How CDNs Move Content Closer to Users
A CDN (content delivery network) is a geographically distributed set of cache servers, often called edge nodes or points of presence, positioned physically close to end users around the world. The principle is the same as any other cache: store a value close to where it will be read, so the read is fast. The difference is which distance is being shortened. A local cache shortens the distance between an application server and its database. A CDN shortens the distance between a user's device and the origin server, which can be thousands of kilometers away, by serving a cached copy from an edge node a few dozen kilometers from the user instead.
That distance matters because network latency is bound by the speed of light, not just by server processing time. A round trip from New York to a server in Singapore takes roughly 200 to 250 milliseconds no matter how fast that server itself responds, simply because of the physical distance the signal must travel. An edge node close to the user collapses that same request to single-digit milliseconds for a cache hit. For static assets like images, videos, stylesheets, and JavaScript bundles, this difference is often the single largest lever available for improving perceived page load time, larger than almost any backend optimization.
CDNs were originally built for static content: files that never change once published, such as a product image or a compiled script bundle with a version number in its filename. Caching these is close to risk-free, because the content genuinely does not change, so staleness is a non-issue; the cache can hold the value indefinitely, or until a new version is published under a new URL. Modern CDNs have expanded well beyond this into caching dynamic content too, including full HTML pages and even API responses, which reintroduces every staleness and invalidation question from the previous subchapter, just now distributed across potentially hundreds of edge locations instead of one central cache.
This is the critical point to carry into an interview: a CDN does not escape the invalidation problem, it multiplies it. Invalidating a single key in a centralized Redis cache is one operation. Invalidating a cached page across every edge node of a global CDN requires a purge request that must propagate to every point of presence, which takes measurable time, often seconds, and during that propagation window different users in different regions can see different versions of the same page. A design that assumes a CDN purge is instantaneous and globally atomic will be caught immediately by a sharp interviewer.
CDNs commonly expose a small set of mechanisms for controlling this, each with its own trade-off:
- Cache-Control headers with max-age: the origin tells the CDN how long a response can be cached, functioning exactly like a TTL, chosen using the same staleness-tolerance reasoning as any other cache.
- Cache-busting via versioned URLs: rather than invalidating a cached asset, publish a new asset under a new URL (for example, appending a content hash) and simply stop referencing the old one, sidestepping invalidation entirely for immutable content.
- Active purge or invalidation API: explicitly tell the CDN to drop a specific cached path across its edge network, used for content that changed unexpectedly and cannot wait out its TTL.
- Stale-while-revalidate at the edge: the same pattern discussed earlier, applied at the CDN layer, serving a slightly outdated page while fetching a fresh one in the background.
A worked example: a news site publishes a breaking story and then issues a correction five minutes later. If the article page was cached at the edge with a given TTL, readers worldwide could see the uncorrected version for up to that full TTL window unless the site actively triggers a purge across its CDN's edge network. Many CDN providers support purge operations that complete globally within some bounded time window, which is fast but not instant, so a site with a strict correction policy needs to design around that propagation delay rather than assume it away. This is exactly the kind of constraint worth naming out loud when discussing a design: I'm assuming CDN purge propagation takes a measurable but bounded amount of time, which bounds our worst-case staleness for corrections.
CDNs also serve a role beyond raw latency reduction: they absorb traffic load away from the origin server entirely. During a traffic spike, such as a flash sale or a viral moment, the vast majority of requests for static assets never reach the origin at all, because the edge nodes answer them directly from cache. This is a second, distinct benefit from latency: it is a form of load shedding that protects origin capacity the same way the circuit breaker and bulkhead patterns protect a service from an overloaded dependency, just applied proactively rather than reactively.
A frequent mistake in interview answers is treating put a CDN in front of it
as a universal fix without acknowledging what cannot be cached this way: anything that must reflect the absolute current state at read time, such as an account balance or a live auction bid count, should bypass the CDN cache layer (often via a Cache-Control: no-store or private header) and hit the origin directly. A design that routes every single request through a CDN cache without distinguishing cacheable from non-cacheable paths either leaks stale sensitive data or defeats the purpose of caching by setting TTLs so short the CDN provides no benefit at all.
The broader lesson connects back to the chapter's opening framing: a CDN is not a separate technology to memorize as a box on a diagram, it is the same cache-aside and TTL reasoning applied at a different point in the network, at a larger scale, with a longer and less certain invalidation propagation delay. Once that connection is explicit, a question that swaps design a caching layer for this service
for design a CDN strategy for this product
stops feeling like a different problem, because it is the same problem answered at a different radius from the user.
Chapter 5: Database Replication and Partitioning
The moment you split a database across two machines, you have made a promise you cannot fully keep: either the machines will sometimes disagree, or one of them will sometimes refuse to answer. That tension is not a bug to be engineered away. It is the permanent cost of growing past a single box, and every technique that follows is a different way of paying that cost on your own terms instead of letting an outage choose for you.
Two separate problems get lumped together under "scaling the database," and confusing them is the single most common mistake in interview answers. The first problem is that one machine cannot serve enough reads, or cannot survive losing its disk. The answer to that is replication: keeping copies of the same data on multiple machines. The second problem is that one machine cannot hold all the data, or cannot absorb all the writes. The answer to that is partitioning (also called sharding): splitting the data itself across machines, so each one holds only a slice.
These two techniques are often used together, but they solve different constraints and should be justified separately. A read-heavy product catalog with modest total size might need only replication. A write-heavy events table that will outgrow any single disk needs partitioning, with replication layered on top for durability. Reaching for both by default, without naming which problem each one solves, is the kind of answer that makes an interviewer start probing.
This chapter builds the vocabulary for both halves of that story. First, how copies of data stay in sync across replicas, and what breaks when they fall behind each other. Second, how to split data across partitions without creating a machine that gets ten times more traffic than its neighbors. Third, the awkward problem of looking something up by a field that is not the partition key, which is where many partitioned designs quietly fall apart.
Leader-Follower and Multi-Leader Replication
In leader-follower replication, one node (the leader) accepts all writes, and the other nodes (followers) continuously copy the leader's changes.[1] The leader typically streams this copy using its write-ahead log, the same durable record it uses internally to recover from crashes. Followers replay that log in order, which means they end up in the same state the leader passed through, just slightly delayed. Reads can be served by any follower, which is why this pattern is the default answer to "the database can't handle read traffic": you are not making the data smaller, you are making more places to read it from.
That delay has a name: replication lag, the gap between a write landing on the leader and that same write becoming visible on a follower. Lag is usually milliseconds under normal load, but it can stretch into seconds or more during a traffic spike, a slow network link, or a follower that is busy running a large query. Lag is not a flaw to be apologized for. It is the direct, unavoidable cost of spreading reads across machines instead of forcing every read through one leader. Any design that claims "we replicate for scale" without naming this cost has skipped a step.
Lag causes a specific, recognizable bug: a user writes something, then immediately reads it back from a follower that has not caught up, and sees stale or missing data. This is called read-your-own-writes inconsistency, and it is the most common user-visible symptom of leader-follower replication done carelessly. A concrete case: a user updates their profile photo, refreshes the page, and still sees the old photo because the read was routed to a lagging follower. The fix is usually to route that specific user's reads to the leader (or to a follower known to be caught up) for some short window right after they write, then fall back to any follower afterward.
Synchronous replication waits for at least one follower to confirm the write before telling the client it succeeded, which guarantees that follower is never behind but adds latency to every write and risks blocking writes entirely if that follower is slow or down. Asynchronous replication tells the client the write succeeded as soon as the leader has it, then streams the change to followers in the background, trading a small risk of data loss on leader failure for much lower write latency. Most production systems pick asynchronous replication for most followers and, if they need stronger durability, make one follower synchronous as a compromise, sometimes called semi-synchronous replication.
Failover is the other half of this design: when the leader dies, something has to pick a new one. A naive approach promotes the follower with the most recent data and redirects traffic to it, but this hides sharp edges. If the old leader committed a write that never reached any follower before it crashed, that write is gone. A second danger, called split-brain, appears if the old leader later comes back online and both nodes think they are the leader. Real systems guard against this with fencing tokens, timeouts tuned against known failure-detection delays, and sometimes a human in the loop for anything high-stakes, rather than fully automatic failover on every signal.
Multi-leader replication relaxes the single-writer rule: more than one node accepts writes, and each propagates its changes to the others. This shows up naturally in multi-datacenter deployments, where forcing every write in Europe to cross an ocean to a single leader in the United States would add unacceptable latency. Each region gets its own leader, writes locally, and replicates asynchronously to the other regions. The price is that two leaders can accept conflicting writes to the same record at nearly the same time, for example two users in different regions both renaming the same shared document, and the system has to decide which version wins or how to merge them.
Conflict resolution strategies range from simple to involved. Last-write-wins (keep whichever write has the later timestamp) is easy to implement but silently discards one user's change. Application-level merging (combine both edits, as collaborative document editors do) preserves more intent but requires the application to understand what a "merge" means for its data, which a generic database cannot know on its own. Vector clocks and conflict-free replicated data types (CRDTs) are more principled tools for specific shapes of data, such as counters or sets, but they are not a general substitute for thinking through what conflict means for the record you are replicating.
Leaderless replication drops the leader concept entirely: a client sends a write to several replicas directly, and sends a read to several replicas as well, trusting the overlap between the two to surface the latest value.[2] This is the design behind systems like Amazon's Dynamo and its descendants. It avoids a single point of coordination, which is attractive for availability, but it pushes the question of "what counts as success" onto the client and the quorum math, which belongs more to the consistency and quorum material; this chapter's job is to flag that leaderless replication exists as a third major shape alongside single-leader and multi-leader, each with a different default toward consistency versus availability.
Choosing between these three shapes comes down to one honest question: how many places need to accept writes at the same time, and how much coordination can you afford between them? Single-leader is the simplest to reason about and the right default when one region or one write path is enough, which covers most applications most of the time. Multi-leader earns its complexity only when write latency across distant locations genuinely matters, such as a global collaboration tool. Leaderless earns its place when the priority is surviving node failures without anyone coordinating a failover at all, at the cost of pushing conflict handling into the application.
The table below summarizes the trade-offs across the three shapes, which is worth having memorized well enough to sketch from memory in an interview.
| Topology | Write path | Main benefit | Main cost |
|---|---|---|---|
| Leader-follower | All writes go to one leader | Simple to reason about, no write conflicts | Replication lag, failover complexity |
| Multi-leader | Writes accepted at multiple nodes | Low write latency across regions | Conflicting writes need resolution |
| Leaderless | Writes sent to several replicas directly | High availability, no single failover event | Client/quorum must resolve staleness |
A practical habit for an interview: when asked to add replication, state which topology you are picking and why, in one sentence, before drawing anything. I'll use leader-follower replication because this system has one natural write path and read traffic is the bottleneck
tells the interviewer you understand the trade-off rather than defaulting to a diagram you memorized. If a follow-up question changes the constraint, for instance adding a second region with strict latency requirements, naming multi-leader replication and its conflict cost as the next step shows the same reasoning extended, not abandoned.
Key takeaway: Replication answers availability and read scale
Replication does not make the database bigger or faster at absorbing writes. It makes copies, which buys read throughput and survival of node loss, at the cost of staleness (replication lag) or write latency (synchronous confirmation), or both.
One failure mode worth naming directly: treating replication as a substitute for backups. A corrupted or accidentally deleted row replicates just as faithfully as a correct one, often within milliseconds, which means replication protects against a machine dying, not against a bad write. Durability targets (the SLO family covering how much data loss is tolerable) still require point-in-time backups or snapshotting on top of replication, a distinction that is easy to skip over when a design sounds complete on a whiteboard but falls apart under a single pointed follow-up question about accidental deletion.
Partition Keys, Hotspots, and Rebalancing
Partitioning splits a dataset across machines so that no single node has to store or process all of it. The decision that drives everything downstream is the choice of partition key: the field (or combination of fields) used to decide which partition a given row belongs to. Pick user ID as the partition key for a messaging app, and every row for a given user lands on the same partition, which makes "fetch all messages for this user" a single-partition query. Pick it badly, and that same kind of query has to visit every partition, which brings the cost back almost to square one.
Range partitioning assigns contiguous ranges of the key to each partition, for example users with IDs 1 to 1,000,000 on partition A, 1,000,001 to 2,000,000 on partition B, and so on. This makes range queries ("give me all events between these two timestamps") efficient, because the relevant rows sit in one or a few adjacent partitions rather than scattered everywhere. The weakness shows up when the key correlates with time or popularity. A chat application partitioned by date, with "today" always being the latest range, sends every single write for the entire system into whichever partition owns today, while every other partition sits idle.
Hash partitioning applies a hash function to the key and uses the result to pick a partition, which scatters keys roughly evenly regardless of their natural ordering. This fixes the "everyone writes to today's partition" problem, because consecutive user IDs or timestamps no longer land near each other. The trade-off is that range queries become expensive again, since rows that were adjacent in the original key are now scattered across every partition, turning a simple range scan into a scatter-gather query that must ask every partition and merge the results.
A hotspot is a partition that receives disproportionate load relative to its peers, and it is the single most common way a reasonable-looking partitioning scheme fails in production. The classic example: a social platform partitions posts by user ID, which works fine for ordinary users, but then a celebrity account with a massive follower count generates a volume of likes and comments that overwhelms the one partition holding that account's data, while every other partition is fine. No amount of adding more partitions elsewhere fixes this, because the problem is not total capacity, it is that one key is simply too hot for any single machine to hold.
The standard fix for a hot key is key salting: appending a small random or rotating suffix to the hot key so its writes spread across several partitions instead of one, for example writing to celebrity_123#0 through celebrity_123#9 instead of only celebrity_123. Reads then have to fan out across those sub-keys and merge the results, which is a direct, explicit trade of read complexity for write capacity. This only helps the write side; if the same key is also read far more often than others, salting does not reduce read hotspot pressure the same way, and caching the hot key's read path (covered under caching strategies) becomes the more relevant tool.
Rebalancing is the process of moving data between partitions as the dataset grows, shrinks, or shifts in shape, and it is where a naive scheme breaks in a second, quieter way. A simple modulo-based scheme, assigning key k to partition k mod N, looks fine until you add a node and N changes: almost every key's assigned partition changes at once, since k mod N and k mod (N+1) rarely agree, forcing a near-total reshuffle of the dataset for what should have been a routine capacity addition. This is the same failure that consistent hashing (covered under load balancing) was designed to solve, and the fix generalizes directly: arrange partitions on a ring so that adding or removing one node only reassigns the keys adjacent to it, not the whole dataset.
Most real partitioned databases combine this with virtual nodes, giving each physical machine several positions on the hash ring instead of one, which spreads the keys that do move during rebalancing across many machines rather than dumping them all onto whichever single node happens to be the new neighbor. Concretely, if a cluster adds a fourth node to a three-node ring, virtual nodes mean that new node absorbs a slice of data from several existing nodes simultaneously instead of taking a sudden, large bite out of just one neighbor, which keeps the rebalancing operation itself from becoming a hotspot.
A separate, less obvious kind of hotspot comes not from one key being popular, but from the physical layout of range partitions combined with an auto-incrementing key. If every new row gets the next sequential integer ID, and partitions are assigned by range, every single insert goes to the newest, highest-numbered partition, regardless of how many partitions exist. This is exactly why many systems recommend a randomized or reverse-ordered ID (or hash partitioning outright) for high-write tables, even when range queries would otherwise be convenient. Recognizing this pattern, and naming it unprompted, is a strong signal in an interview that you have actually operated a partitioned write path rather than only read about one.
The checklist below is a useful pass to run through before committing to a partition key in a design interview, since each item maps to a real failure mode covered above.
Before committing to a partition key
- Confirm which queries must hit a single partition versus which can tolerate scatter-gather
- Check whether the key correlates with time, popularity, or any other skewed distribution
- Decide whether range or hash partitioning fits the dominant query pattern
- Plan for at least one plausible hot key (a celebrity account, a trending topic) and name the mitigation
- Confirm the rebalancing strategy does not require reshuffling the whole dataset on every capacity change
Interviewers often probe partitioning by changing a single assumption mid-conversation, for example asking what happens if one customer suddenly accounts for a dominant share of traffic. The strongest response names the hotspot directly, states that it is a hotspot (using that exact term), and proposes a concrete mitigation such as salting or a dedicated partition for that one key, rather than vaguely gesturing at "adding more servers." Adding more servers does not help a hotspot at all; it only helps when load is evenly distributed and the bottleneck is aggregate capacity, which is precisely the distinction this section exists to sharpen.
Secondary Indexes Across Partitions
Everything in the previous section assumed queries arrive filtered by the partition key, but real applications rarely cooperate that cleanly. A secondary index is an index built on a field other than the partition key, for example looking up orders by customer email when the orders table is partitioned by order ID. On a single machine, building a secondary index is routine: the database maintains a separate structure mapping email to row location, and a lookup is fast regardless of how the main table is organized. Partitioning breaks that simplicity, because the index itself now has to live somewhere, and that "somewhere" decision has exactly two shapes, each with a different cost.
The first shape is a local secondary index (sometimes called document-partitioned indexing): each partition maintains an index covering only its own rows. Writing a new order is cheap, since the partition that receives the write also updates its own local index in the same operation, with no coordination across machines. The cost shows up on reads: a query for "find the order with this customer email" has no way of knowing which partition holds that customer's rows, since the partitioning was done by order ID, not email. The database must query every partition's local index and merge the results, which is exactly the scatter-gather pattern introduced earlier, now applied to index lookups instead of range scans.
The second shape is a global secondary index (sometimes called term-partitioned indexing): the index itself is partitioned separately, by the secondary field rather than the primary partition key, so all entries for a given email land together regardless of which partition holds the underlying order row. Reads become fast and precise again, a single partition of the index answers the query directly. The cost moves to writes: inserting one order now has to update both the main table's partition and the index's partition, which are different machines holding different keys, so the write is no longer a single-partition operation and either needs a transaction across partitions or has to tolerate the index catching up slightly later (a form of the same consistency-versus-latency trade-off the quorum and consensus material covers in more depth).
A concrete comparison makes the trade-off tangible. Imagine an e-commerce platform partitioned by order ID that wants to support "show me all orders from this customer." With a local secondary index, placing an order is a single, fast, single-partition write, but answering the customer-lookup query means asking every partition in the cluster and combining the results, which gets slower as the cluster grows, since you are paying the scatter-gather cost on every single lookup. With a global secondary index on customer ID, that lookup query hits one index partition and returns instantly, but every order placement now has to also write to the separate index partition, adding latency and a small window where the index might lag the source table.
| Approach | Write cost | Read cost | Best fit |
|---|---|---|---|
| Local (document-partitioned) | Low: single-partition write | High: scatter-gather across all partitions | Write-heavy workloads, infrequent cross-key lookups |
| Global (term-partitioned) | Higher: cross-partition write, possible lag | Low: single-partition read | Read-heavy lookups on a field other than the partition key |
Picking between these is really the same question asked earlier about hot keys, restated for indexes: which side of this system, reads or writes, is the actual bottleneck you measured or estimated? A back-of-the-envelope calculation (the tool from the earlier chapter on estimates) should answer this before committing: if the order-placement rate is high and customer-email lookups happen rarely, a local index that keeps writes cheap is the right call, accepting slow scatter-gather reads as an acceptable rare cost. If customer lookups are a core, frequent product feature, like a customer-support tool that constantly searches orders by email, a global index earns its write overhead because reads are the path that matters most.
Many managed databases make this choice visible in their own terminology, which is useful to recognize rather than memorize verbatim. Amazon DynamoDB's global secondary index is a term-partitioned structure in the global sense described above, maintained asynchronously, which is why DynamoDB documentation explicitly warns that a newly written item may not appear in a global secondary index query for a short window afterward.[3] Apache Cassandra's local secondary indexes are the document-partitioned kind, and its documentation steers users away from querying them without also supplying the partition key whenever possible, precisely because of the scatter-gather cost described above. Recognizing these two shapes under a real product's name is a strong signal that the underlying concept, not a memorized feature list, is what is actually understood.
A failure worth naming directly: adding a secondary index without ever asking which shape it is, or assuming a single-machine mental model of "indexes are just fast" carries over unchanged once data is partitioned. An interview answer that says I'll add an index on customer email
without acknowledging that this index must live somewhere, and that "somewhere" has a write-cost or read-cost consequence, reads as surface-level to anyone who has operated a partitioned system. Naming the local-versus-global distinction unprompted, even briefly, signals the deeper understanding this section is built around.
The practical takeaway for an interview setting is to treat every proposed secondary index as a decision, not an afterthought. State the query pattern it serves, name whether it is local or global, and state the cost on the other side explicitly. If asked to add a second lookup field later, for instance adding a search by product category on top of the existing customer-email lookup, walk through the same local-versus-global reasoning again rather than assuming the first index's shape automatically applies to the second one; different fields can have very different read and write frequencies, and the right shape for one is not automatically right for the other.
Partitioning and its indexing consequences connect directly back to the chapter's opening tension. A local index trades read speed for write simplicity, a global index trades write simplicity for read speed, and a hot key forces the same kind of trade between write distribution and read complexity. None of these are solved once and forgotten. They are decisions that have to be re-justified every time the query pattern, the traffic shape, or the dominant key changes, which is exactly the kind of reasoning the next chapter picks up when it turns from where data lives to how strongly different copies of it are required to agree.
Chapter 6: Consistency, Availability, and Trade-Offs
Eric Brewer conjectured that no distributed system can simultaneously guarantee consistency, availability, and partition tolerance, and the engineering decisions that followed that conjecture still shape every database you choose today[1]. Seth Gilbert and Nancy Lynch later proved the conjecture formally, turning it into what the industry now calls the CAP theorem[2]. The previous chapter on database replication and partitioning showed how spreading data across machines buys availability and scale. This chapter picks up the bill that comes with that spread: once data lives in more than one place, you must decide how much disagreement between copies you are willing to tolerate, and for how long.
Consistency is not a single switch you flip on or off. It is a spectrum of guarantees, each one costing you something in latency, availability, or engineering complexity. A banking ledger and a social media like counter sit at opposite ends of that spectrum, and treating them the same way is a common, expensive mistake. The goal of this chapter is to give you a vocabulary precise enough to say exactly what guarantee a system offers, and a way of reasoning that connects that guarantee to a concrete business cost.
Get this wrong in an interview and it shows immediately. A candidate who says we'll use strong consistency everywhere to be safe
has not thought about the latency and availability that choice destroys. A candidate who says eventual consistency is fine
without naming how eventual, and what happens if a user acts on stale data, has not thought about the business impact. The strongest answers name a specific consistency model, explain what it costs, and tie that cost back to what the product actually needs.
This chapter builds that reasoning in three steps. First, it lays out the spectrum of consistency models, from the strictest guarantee down to the loosest, with concrete examples of what each one promises and what it does not. Second, it opens up the machinery, quorums, leases, and consensus, that makes these guarantees possible, at a level deep enough to reason about but without drowning in protocol detail. Third, it returns to CAP and its sharper successor, PACELC, to connect the theory to the latency trade-off you actually face even when no partition is happening.
The Spectrum From Strong to Eventual Consistency
Strong consistency means that once a write succeeds, every subsequent read, from any replica, sees that write or a later one. The strictest form of this is linearizability, which requires that operations appear to take effect instantaneously at some single point in time, in an order consistent with real-time ordering across all clients[3]. Picture a single shared clock that every client's operation can be placed on, with no two operations ever overlapping in a way that contradicts the order anyone observed. This is the guarantee a bank account balance check needs. If you just transferred your last $50 and immediately check your balance, you need to see the transfer, not a stale number from a second ago.
Linearizability is expensive because enforcing a single global order typically requires coordination between replicas on every operation, which adds latency and reduces how many nodes can keep serving requests during a network partition. Few systems need it end to end. More commonly, systems adopt it only for the specific operations where correctness cannot bend, such as the row that holds an account balance or an inventory count, while leaving everything else looser. That selective use is itself a trade-off worth naming out loud in a design discussion.
One step down the spectrum sits sequential consistency, which requires all operations to appear in a single global order that respects each client's own program order, but that order does not need to match real-world wall-clock time. This is subtler than linearizability and rarely comes up by name in interviews, but it explains why some systems feel consistent even without instant global agreement: they agree on an order, just not necessarily the order a wall clock would record.
Below full ordering guarantees sit a cluster of client-centric guarantees that are cheaper to provide and solve real, common frustrations. Read-your-writes consistency promises that a client always sees its own writes, even if it might not immediately see someone else's. Picture a user who updates their profile photo and then reloads the page, only to see the old photo because the read hit a replica that has not caught up yet. Read-your-writes consistency fixes exactly that complaint without requiring the whole system to agree on a global order.
Monotonic reads is a related but distinct guarantee: once a client has seen a particular value, it will never see an older value on a later read, even if that later read happens to land on a different, lagging replica. Without this guarantee, a user could refresh a page and watch a comment they just saw disappear, only to reappear a moment later, because successive reads bounced between replicas at different points in the replication timeline. Both of these guarantees are usually implemented by routing a given client's reads to the same replica, or by having the client track a version number and refusing reads from a replica that has not reached it.
Causal consistency is a stronger and more interesting guarantee: operations that are causally related must be seen by everyone in the same order, while operations with no causal relationship can be seen in different orders by different observers. The textbook example is a comment thread: if someone posts Is anyone else seeing this outage?
and a second person replies Yes, it's down for me too,
every reader must see the question before the reply. Two unrelated comments posted around the same time can appear in any order without breaking anyone's sense of the conversation[4].
At the loosest end of the spectrum sits eventual consistency: if no new writes occur, all replicas will eventually converge on the same value, but there is no bound on how long eventually
takes, and no guarantee about what order intermediate reads see. This is the right model for a view counter, a like count, or a product's star rating, where a slightly stale number costs nothing and forcing agreement on every read would be wasteful. The staleness window is usually small in practice, but a design that depends on eventual consistency must assume that window could stretch much longer during a backlog or partition.
The common beginner mistake is treating these as a strict ladder where higher is always safer. It is more useful to think of them as tools for specific jobs, each with a cost. Here is a quick way to compare them.
| Model | What it guarantees | Typical cost | Good fit |
|---|---|---|---|
| Linearizability | Reads always reflect the latest completed write, in real-time order | Highest latency, coordination on every operation | Account balances, inventory counts, locks |
| Causal consistency | Causally related operations are seen in the same order by everyone | Moderate, needs causal metadata tracking | Comment threads, chat, collaborative editing |
| Read-your-writes | A client always sees its own prior writes | Low, route client to a consistent replica | Profile edits, settings changes |
| Monotonic reads | A client never sees an older value after a newer one | Low, pin reads or track a version watermark | Feeds, dashboards |
| Eventual consistency | Replicas converge given enough time with no new writes | Lowest, highest availability and throughput | Like counts, search index updates, caches |
Worth sitting with for a moment: these guarantees can be mixed within a single system, and almost always should be. A social media platform might use linearizable reads for a user's own account settings, causal consistency for the comment thread, and eventual consistency for the like counter on each post, all inside the same product. Naming which guarantee applies to which piece of data, rather than picking one model for the whole system, is exactly the kind of granularity that distinguishes a senior-level answer from a memorized one.
A second mistake worth naming explicitly: assuming the database vendor's marketing term tells you the whole story. A system advertised as strongly consistent
might only guarantee that within a single region, falling back to eventual consistency across regions during normal operation. Always ask, or state as an assumption, the scope over which a consistency guarantee holds: within one replica set, within one region, or globally. That scope boundary is often where production incidents happen, because engineers assumed a guarantee extended further than it did.
Practically, deciding where on this spectrum a given piece of data belongs starts with one question: what does it cost the business if a read is stale, and for how long? If a stale read could let two customers buy the last unit of inventory, or let a user overdraw an account, the cost is high and you move toward the strong end. If a stale read just shows a like count that is off by one for a few seconds, the cost is effectively zero and you move toward the eventual end. This single question, asked per data type rather than per system, is the fastest way to place any new requirement correctly on the spectrum.
Key takeaway: Ask per data type, not per system
Different pieces of data inside the same product can sit at different points on the consistency spectrum. Decide guarantee by guarantee, tied to the cost of a stale read for that specific field, rather than picking one consistency model for an entire architecture.
Quorums, Leases, and Consensus at a High Level
Strong consistency guarantees do not appear by magic. They are built out of a small set of coordination mechanisms that force multiple replicas to agree before a write or read is allowed to proceed. The first and most widely used of these is the quorum. A quorum system replicates data across N nodes and requires a write to be acknowledged by W of them, and a read to collect responses from R of them, before either operation is considered successful. The key arithmetic rule is this.
W + R > N
When that inequality holds, every read quorum is guaranteed to overlap with every write quorum by at least one node, which means at least one node in any read set has seen the most recent write. This is how a quorum system achieves strong-ish consistency without requiring every single replica to participate in every operation. A common configuration is N = 3, W = 2, R = 2: a write needs two of three nodes to confirm, a read needs two of three to respond, and 2 + 2 = 4 is greater than N = 3, so overlap is guaranteed. This setup tolerates one node being down or slow for both reads and writes, which is why it shows up so often in practice.
Changing W and R shifts where the cost lands. Setting W = N and R = 1 makes every write wait for all replicas but lets any single replica answer a read instantly, which favors read-heavy workloads at the cost of making writes fragile to any one slow or failed node. Setting W = 1 and R = N does the opposite, favoring fast writes while making reads expensive and slow. Neither extreme is wrong by itself. The decision maps directly onto whether your workload is read-heavy or write-heavy, which is exactly the kind of number a back-of-the-envelope calculation from earlier in the book should have already told you.
Quorums alone do not solve every problem. Two clients can still race to write at nearly the same time, and the system needs a way to decide which write wins and to prevent the other from quietly corrupting state. This is where a lease comes in: a time-bound grant of exclusive ownership over a resource, given to one node or one client at a time. A lease holder is trusted to act on behalf of that resource until the lease expires, after which someone else can acquire it. Leases are deliberately time-bound rather than permanent, because a crashed lease holder that never releases its claim would otherwise block the system forever.
The subtlety with leases is clock skew. If a lease holder's clock runs slow, it might believe its lease is still valid after another node has already declared it expired and granted the lease to someone else, producing a brief window where two nodes both believe they are in charge. Real systems handle this with conservative lease durations and clock synchronization bounds, accepting a small window of unavailability (waiting out the maximum possible clock drift) rather than risking two simultaneous writers. This is a concrete, nameable cost: a lease-based system trades a small amount of guaranteed unavailability during handover for the certainty that only one writer is ever active.
Above quorums and leases sits consensus, the general problem of getting a group of nodes to agree on a single value even when some of them might fail or be slow, and even when messages between them might be delayed or lost. Consensus is the mechanism behind leader election, behind atomic configuration changes, and behind making a cluster agree on the order of operations in a replicated log. The two consensus algorithms you should be able to name are Paxos, the original and notoriously difficult-to-explain protocol[5], and Raft, designed specifically to be more understandable while solving the same problem[6].
You do not need to reproduce either protocol's full state machine in an interview. What you do need is the shape of the guarantee: consensus lets a cluster elect a single leader, agree on the order of a sequence of operations, and continue making progress as long as a majority of nodes are reachable and functioning. That majority requirement is the same arithmetic as a quorum, which is not a coincidence: quorum overlap is the core trick that both replicated-storage consistency and leader-election consensus are built on. Naming that connection out loud (this is a quorum-based decision, which is the same majority logic Raft uses for leader election
) signals a level of understanding well beyond memorized vocabulary.
A practical example ties these three mechanisms together. Consider a distributed lock service used to make sure only one worker processes a given job at a time. The service uses consensus (commonly Raft) among its own internal replicas to agree on who holds each lock and in what order requests arrived. It grants the lock as a lease with a bounded time-to-live, so a crashed worker's lock is automatically reclaimed rather than held forever. And clients acquiring or renewing the lock effectively perform a quorum write across the lock service's replicas, so the grant survives the failure of any single replica node. Each piece solves a distinct problem: consensus for agreement, the lease for crash recovery, the quorum for durability.
Here is a decision tree summarizing how to choose a coordination mechanism when a design calls for a shared resource or ordering guarantee.
Choosing a coordination mechanism
- Do multiple nodes need to agree on exactly one value or owner?
- Yes Need temporary exclusive ownership of a resource?
- Yes Use a lease with a bounded time-to-live
- No Need a durable, ordered log replicated nodes agree on?
- Yes Use a consensus protocol such as Raft or Paxos
- No Just need reads to overlap with recent writes?
- Yes Use a quorum with W + R > N
- No Allow eventual consistency, skip coordination
- Yes Need temporary exclusive ownership of a resource?
One caution worth stating directly: do not reach for consensus or distributed locking as a default. Each adds coordination overhead, latency, and a new failure mode (what happens if the consensus cluster itself loses its majority). Many designs that ask for exactly one worker processes this job
can be solved more cheaply with idempotency, covered later in the chapter on queues and retries, rather than a full lock service. Reach for the heaviest tool only when the correctness requirement genuinely demands it, and say so out loud when you make that call.
CAP Theorem and the Latency Trade-Off
The CAP theorem states that a distributed system can provide at most two of three properties at the same time: consistency (every read sees the latest write), availability (every request gets a response, even if not the latest data), and partition tolerance (the system keeps working despite network messages being dropped or delayed between nodes)[2]. Because real networks partition, sometimes briefly, sometimes for extended periods, partition tolerance is not optional in practice. That leaves the actual choice as consistency versus availability, and only during an active partition.
That last qualifier is the single most important and most frequently misunderstood part of CAP. The theorem only forces a choice while a partition is actually happening. A common interview mistake is talking about CAP as if a system is permanently stuck choosing consistency or availability at all times. In reality, under normal operating conditions with no partition, a well-designed system can offer both. The trade-off only bites during the partition itself: if a write request arrives at a node that cannot currently reach the rest of the cluster, that node must choose between answering anyway (sacrificing consistency, because other replicas have not seen this write) or refusing to answer (sacrificing availability).
Concretely: imagine a banking service split by a network partition into two halves, each holding a copy of an account balance. A CP design, consistency over availability, refuses to process a withdrawal on the minority side of the partition, returning an error rather than risk a double-spend once the partition heals. An AP design, availability over consistency, processes the withdrawal on both sides and reconciles afterward, accepting the risk that the account could briefly go negative if both sides allowed a withdrawal. Most banking systems lean CP for balance-changing operations precisely because an incorrect balance is a worse outcome than a rejected transaction during a rare partition.
Contrast that with a shopping cart service. If a partition splits the cluster and a customer's add to cart
request hits the minority side, refusing the request (CP behavior) costs a sale and frustrates a customer over a guarantee the business barely needs. An AP design that accepts the write locally and merges carts once the partition heals, possibly combining duplicate items, serves the customer far better. Amazon's own Dynamo paper is explicit about this choice, treating shopping cart availability as more valuable to the business than instant consistency[7].
CAP's narrow focus on behavior during partitions is also its biggest limitation as a design tool. Daniel Abadi pointed out that CAP says nothing about the far more common situation: no partition at all, but a system still choosing to trade consistency for lower latency, because waiting for every replica to agree is slow even on a perfectly healthy network[8]. Abadi's PACELC framework extends the reasoning to cover this everyday case, and it is the sharper tool for most design discussions.
PACELC: if Partition, choose Availability or Consistency; Else, choose Latency or Consistency
Read it as two separate questions joined by a rule for when each applies. During a partition (the P
branch), you choose between availability and consistency, exactly as CAP describes. Else, during normal operation (the E
branch), you choose between latency and consistency: do you wait for enough replicas to acknowledge a write before confirming it to the client (consistency, at the cost of latency), or do you confirm immediately and let replication catch up in the background (latency, at the cost of possible staleness)? This second choice happens on every single write, every day, with no partition in sight, which is why it matters far more to everyday system behavior than the rarer partition scenario.
A concrete illustration makes the E
branch tangible. A leader-follower database can be configured to wait for synchronous acknowledgment from one or more followers before confirming a write to the client, which raises write latency but guarantees the write survives a leader crash. Or it can confirm the write as soon as the leader itself has it, replicating to followers asynchronously, which meaningfully cuts latency but risks losing the most recent writes if the leader crashes before they replicate. Most production systems lean async for everyday writes and only pay the synchronous cost for operations where losing data is unacceptable, such as a payment confirmation.
Here is a pyramid that frames these trade-offs as a layered decision, from the rarest and most catastrophic failure down to the everyday cost every write pays.
Layers of the consistency trade-off
Partition: Availability vs Consistency (20)
Normal operation: Latency vs Consistency (60)
Per-field choice: which guarantee this data needs (100)
The pyramid's base is the widest and most frequent decision: for each piece of data in the system, which consistency guarantee from the earlier spectrum does it actually need. The middle layer is the everyday PACELC trade-off that every write pays, partition or not. The narrow top is the rare, high-stakes moment CAP describes, when an actual network partition forces an explicit choice. A strong design answer touches all three layers, but should spend the most airtime on the base, because it is the decision made most often and with the most daily business impact.
Bringing this back to an interview setting, the strongest way to use CAP and PACELC is not to recite the theorem but to apply it to the specific data type under discussion, exactly as the consistency spectrum section urged. State the partition behavior you are choosing (on a partition, this service favors availability, because a stale product listing is a smaller cost than an unresponsive storefront
), then separately state the everyday latency trade-off (under normal operation, writes confirm asynchronously to keep latency low, accepting a small replication lag window
). Two sentences, each naming a specific axis of the trade-off, communicate more engineering judgment than a long explanation of the theorem itself.
The deepest lesson of this chapter is that consistency is never free in either direction. Choosing strong guarantees costs latency and availability. Choosing loose guarantees costs correctness and puts the burden of handling staleness onto whoever reads the data next, often a part of the system (or a product manager) that never asked for that burden. The replication chapter already showed how spreading data buys availability and scale; this chapter has shown the bill that follows. The next chapter, on queues and retries, picks up a related thread: once you accept that writes can be delayed, retried, or duplicated, idempotency becomes the tool that keeps those weaker guarantees from turning into silent corruption.
Chapter 7: Queues, Retries, and Idempotency
Every retry is an optimistic bet that the world has changed since the last attempt, and without idempotency, each of those bets can silently corrupt your data. A client sends a request, hears nothing back in time, and tries again, assuming the first attempt failed cleanly. But a timeout tells you nothing about what actually happened on the other side. The request might have never arrived. It might have arrived and succeeded, with only the response lost in transit. It might be sitting half-processed right now. A retry fired blindly into that uncertainty can double-charge a customer, double-send a notification, or double-insert a row, and the system will have no idea anything went wrong.
This chapter deals with two closely related tools that backend engineers reach for constantly and reason about poorly under interview pressure: message queues and retry logic. Both exist to handle a world where calls fail, systems slow down, and work needs to survive a crash. A queue gives you a buffer between the part of your system that creates work and the part that does it. A retry gives you a second chance when a call fails. Used carelessly, both can turn a minor hiccup into a cascading outage. Used deliberately, with the right guardrails, they make a system resilient instead of brittle.
The guardrail that ties the two together is idempotency: the property that doing an operation twice produces the same result as doing it once. Without it, retries are reckless. With it, retries become safe, because the system can absorb duplicates without damage. This chapter builds up to that idea by first explaining why queues exist and what they cost, then covering how to retry safely with budgets and backoff, and finally showing how to design idempotent operations using keys that any backend engineer can implement starting this week.
None of this is abstract plumbing. A payments team that ships a naive retry loop against a non-idempotent charge endpoint will eventually double-charge a real customer, and it usually happens during a traffic spike when timeouts are already common. A notifications team without a dead-letter queue will eventually lose a batch of messages to a single malformed payload that jams the pipeline for everyone behind it. These are not edge cases reserved for exotic systems. They are the default failure mode of any queue-and-retry setup built without dead-letter queue and idempotency key as explicit design decisions from the start.
Why Queues Decouple Producers and Consumers
A message queue sits between a producer, the part of the system generating work, and a consumer, the part of the system doing that work. Instead of the producer calling the consumer directly and waiting for a response, it drops a message onto the queue and moves on. The consumer pulls messages off the queue at its own pace. This is producer-consumer decoupling, and it solves a problem that direct calls cannot: it lets two parts of a system operate independently, at different speeds, without either one blocking on the other.
Picture an e-commerce checkout flow. When an order is placed, several things need to happen: charge the card, send a confirmation email, update inventory, and notify the warehouse. If the checkout service called each of those four services synchronously and waited for all of them to finish, the customer would stare at a loading spinner while the slowest of the four crawled along, and a single failure anywhere in that chain would fail the whole checkout. Instead, the checkout service can charge the card synchronously, since that result matters immediately, then drop a message onto a queue for the rest. The email, inventory update, and warehouse notification happen asynchronously, each consumer reading from the queue whenever it is ready.
Decoupling buys three concrete benefits. First, it smooths out traffic spikes: if a flash sale generates ten times the normal order volume, the queue simply grows longer while consumers keep processing at a steady rate, instead of the downstream services getting overwhelmed and falling over. Second, it isolates failures: if the warehouse notification service goes down for an hour, orders keep flowing and messages pile up safely in the queue, to be processed once the service recovers, rather than blocking checkouts entirely. Third, it lets each part of the system scale independently, since the number of consumer workers can grow or shrink based on queue depth without touching the producer at all.
That resilience comes with a cost, and the cost shows up in exactly the areas a senior interviewer will probe: delivery guarantees, ordering, and backlog. A queue typically offers one of three delivery guarantees. At-most-once delivery means a message is delivered zero or one times, never more. It is simple and fast, but any dropped message is gone for good, which rules it out for anything where losing data matters, like a payment event. At-least-once delivery means a message is delivered one or more times. It is the most common guarantee in production systems because it never silently loses a message, but it means consumers must tolerate duplicates. Exactly-once delivery means each message is processed precisely once, which sounds like the ideal but is extremely expensive to guarantee across a network, typically requiring distributed transactions or careful deduplication layered on top of at-least-once semantics[1].
Most production queues, including widely used systems like Amazon SQS and Apache Kafka in its default configuration, offer at-least-once delivery, not exactly-once. This is a deliberate trade-off: guaranteeing exactly-once requires either a two-phase commit across producer, queue, and consumer, which hurts latency and availability, or a deduplication mechanism, which pushes the real work back onto the application. In practice, the industry has settled on a pattern: build the transport layer for at-least-once delivery, because that is achievable without heavy coordination, and make consumers idempotent so that duplicate deliveries are harmless. This is exactly the link between this subsection and the idempotency subsection that follows: the queue's honesty about possible duplicates is what makes idempotent design non-negotiable.
Ordering is the second cost. A strictly ordered queue, such as an SQS FIFO queue or a single Kafka partition, read by a single consumer preserves the order messages were produced in, but the moment you add more consumer workers to scale throughput, you lose any guarantee about order, since two workers might finish out of sequence. Some queue systems, like Kafka, solve this with partitions: messages with the same key (say, the same customer ID) always land in the same partition and are processed by the same consumer in order, while different keys can be processed in parallel across partitions. This is the same partition key idea covered under database partitioning, applied to message routing instead of storage. If a system genuinely needs strict ordering (processing a customer's events in the sequence they happened), the partition key has to be chosen carefully, or correctness breaks silently under load.
Backlog is the third cost, and it is the one engineers underestimate most. A queue that grows faster than consumers can drain it is not "buffering," it is failing slowly. If checkout messages pile up at ten thousand per minute and consumers process six thousand per minute, the backlog grows by four thousand messages every minute, and customers eventually wait hours for a confirmation email that should arrive in seconds. The fix is not a bigger queue, it is matching consumer throughput to producer throughput, usually by adding consumer workers, or by shedding load upstream (a graceful degradation choice covered elsewhere). A design that proposes a queue without a plan for monitoring queue depth and auto-scaling consumers has only moved the bottleneck, not solved it.
A poison pill is a message that a consumer can never successfully process, no matter how many times it retries. It might be malformed JSON, reference a deleted record, or trigger a bug in the consumer's code. Left unchecked, a poison pill can jam an entire queue: if the consumer retries the same failing message indefinitely before moving to the next one, every message behind it waits forever. This single failure mode has taken down real production pipelines, where one bad event froze an entire ordered partition until an engineer manually intervened.
The standard defense against poison pills is the dead-letter queue, a separate queue where messages get routed after failing processing some fixed number of times, say five attempts. Once in the dead-letter queue, the message is out of the main processing path, so it stops blocking everything behind it, but it is not lost: an engineer (or an automated alert) can inspect it, fix the underlying bug or data issue, and reprocess it later. Think of the dead-letter queue below as a quarantine, not a trash can.
Key takeaway: The dead-letter queue is a safety valve, not an afterthought
Any consumer design that retries a failing message forever, or drops it silently after one failure, is incomplete. Route repeatedly-failing messages to a dead-letter queue after a bounded number of attempts, alert on anything landing there, and keep a process for inspecting and replaying them.
Designing a queue-based system well means answering a short checklist before writing any consumer code: what delivery guarantee does this queue provide, does message order matter for this use case and if so how is it preserved, what happens when consumers fall behind producers, and where do permanently failing messages go. Skipping any of these questions is how a team ends up debugging a frozen pipeline in production instead of catching the gap on a whiteboard.
Before committing to a queue design
- Confirm the delivery guarantee (at-most-once, at-least-once, or exactly-once) and design consumers accordingly
- Decide whether message order matters, and if so, choose a partition key that groups related events together
- Plan for backlog: monitor queue depth and define how consumer count scales with it
- Set a maximum retry count per message before routing it to a dead-letter queue
- Define who gets alerted when the dead-letter queue receives a message, and how it gets replayed
It helps to contrast a queue with a direct synchronous call, since interviewers often ask when one beats the other. A direct call is simpler, gives an immediate result, and is the right choice when the caller genuinely needs to know the outcome before moving on, such as checking whether a payment was authorized. A queue is the right choice when the work can happen later, when the producer should not be blocked by a slow or unreliable downstream step, or when multiple independent consumers need to react to the same event (a pattern sometimes called fan-out, distinct from the feed fan-out on write versus fan-out on read trade-off covered in the social feed walkthrough). Choosing between them is a judgment about whether the caller needs an answer now or just needs the work to happen eventually.
One more subtlety worth internalizing: a queue does not remove failure, it relocates it. Before the queue, failure showed up as a slow or failed API call. After the queue, failure shows up as backlog growth, duplicate messages, or poison pills. A design that introduces a queue and then stops reasoning about failure has not made the system more reliable, it has only changed where the reliability work has to happen. The remaining two subchapters are about doing that work properly, first for retries in general, then for the idempotency that makes retries and at-least-once queues safe together.
Retry Budgets, Backoff, and Jitter
A retry sounds like a free safety net: if a call fails, just try again. In practice, naive retries are one of the most common causes of cascading outages in distributed systems. Picture a service that is struggling under load and starting to time out. Every client calling it sees a timeout and immediately retries, which adds more load to an already struggling service, which causes more timeouts, which causes more retries. This feedback loop has a name, the retry storm, and it has taken down real production systems within minutes of a routine degradation that should have been minor[2]. The fix requires bounding how aggressively a client retries, and spacing retries out so they do not all collide.
The first discipline is a retry budget: a hard limit on how many retries a client will attempt, and often a limit on what fraction of total requests are allowed to be retries at all. A simple version caps each individual request at, say, three retry attempts before giving up and surfacing an error. A more sophisticated version tracks retries as a percentage of overall traffic (say, some bounded fraction of outgoing requests may be retries at any given time) and stops retrying altogether once that budget is exhausted, even if individual requests have retry attempts left. This second form protects the system as a whole during a widespread outage, rather than protecting each request in isolation while the aggregate traffic still spikes.
The second discipline is exponential backoff: instead of retrying immediately, the client waits progressively longer between attempts. A typical scheme doubles the wait time after each failure: wait a short interval before the first retry, doubling each time, and so on, often capped at some maximum so the wait does not grow unbounded. The formula is simple:
waitn = min(base × 2n, max_wait)
where base is the initial delay, n is the retry attempt number starting at zero, and max_wait caps the delay. This backs off pressure on a struggling service precisely when it needs relief most, since every failed call buys a little more breathing room before the next attempt. Without backoff, a client that retries immediately after every failure just adds a steady stream of extra load on top of a service that is already behind, which is the opposite of helpful.
Backoff alone is not enough, though, because it has a subtle flaw: if every client backs off using the exact same schedule, they stay synchronized with each other. Imagine ten thousand clients all time out at the same moment because a service blipped, and all of them wait exactly 100 milliseconds, then all retry at the exact same instant, creating another spike of exactly the kind backoff was supposed to prevent. The fix is jitter: adding randomness to the wait time so retries from different clients spread out instead of clustering. A common approach, full jitter, picks a random wait time between zero and the backoff ceiling for that attempt:
waitn = random(0, min(base × 2n, max_wait))
This single change, random jitter layered on top of exponential backoff, is generally recognized as smoothing out retry storms far more effectively than backoff alone, and it costs nothing more than a single call to a random number generator in the retry loop.
Implementing a safe retry policy
- Set a maximum attempt count - Decide upfront how many times a single request may retry, typically 3 to 5, and stop after that, surfacing an error to the caller instead of retrying forever.
- Apply exponential backoff - Double the wait time after each failed attempt, starting from a small base delay like 100 milliseconds, and cap it at a maximum like 10 to 30 seconds.
- Add full jitter - Replace the fixed backoff delay with a random value between zero and that delay, so retries from many clients spread out instead of synchronizing.
- Track a system-wide retry budget - Limit the overall fraction of traffic that may be retries, so a widespread outage does not get amplified by every client retrying at once.
- Fail fast when the budget is exhausted - Once the retry budget or attempt count is spent, return an error immediately rather than queueing up more waiting, and let graceful degradation patterns take over from there.
A related judgment call is deciding which failures deserve a retry at all. A timeout or a 503 Service Unavailable response usually means the server was overloaded or transiently unreachable, and retrying later has a reasonable chance of success. A 400 Bad Request means the request itself was malformed, and retrying the exact same request will fail identically every time, wasting an attempt and adding load for nothing. A well-designed client distinguishes between retryable and non-retryable errors, and this distinction matters just as much as the backoff math itself. Blindly retrying every non-2xx response is a common mistake that burns through retry budgets on errors that can never succeed.
Interaction with a circuit breaker (covered in depth as part of graceful degradation) is worth flagging here because the two are often confused. A circuit breaker tracks the failure rate of calls to a dependency and, once it crosses a threshold, stops sending traffic to that dependency entirely for a cooldown period, rather than continuing to time out on every call. Retry logic decides how to handle a single failed call; a circuit breaker decides whether to even attempt a call at all, based on recent history. The two work together: a client applies backoff and jitter per attempt, while a circuit breaker above it decides when to stop attempting entirely because the dependency is clearly down.
Picking concrete retry parameters for an interview answer is more persuasive than describing the mechanism in the abstract. A reasonable default for a backend service calling another internal service might be a short base delay, exponential growth up to a reasonable cap, full jitter applied at each step, and a bounded maximum number of attempts, with a circuit breaker tripping after a significant fraction of calls fail within a rolling window. Stating numbers like these, even as illustrative defaults rather than universal constants, signals that the retry policy was actually thought through rather than hand-waved.
The deepest problem retries expose, though, has nothing to do with timing. Consider a retry that fires because a response was lost, even though the original request actually succeeded on the server. The client, having no way to know this, retries the exact same operation. If that operation is "charge this customer's card," the customer gets charged twice. Backoff and jitter make retries polite to the system, but they do nothing to make a retried operation safe to repeat. That safety has to come from how the operation itself is designed, which is the subject of the next subchapter.
This is the moment where retry logic and queue delivery guarantees converge on the same requirement. An at-least-once queue will redeliver a message if a consumer crashes mid-processing, exactly as a retrying client resends a request after a lost response. Both situations produce the identical symptom: an operation that might run more than once. Treating retries and queue redelivery as two instances of the same underlying problem, rather than two unrelated mechanisms, is what separates a design that merely sounds resilient from one that actually is.
Designing Idempotent Operations With Keys
An operation is idempotent if performing it multiple times produces the same result as performing it once. Some operations are naturally idempotent without any extra work. Setting a user's email address to new@example.com is idempotent: running that update five times leaves the exact same final state as running it once. Deleting a record by ID is idempotent too, since deleting it a second time just finds nothing there and does nothing. But many of the operations that matter most, charging a credit card, sending an email, incrementing an inventory counter, are not naturally idempotent, because each execution causes a new side effect on top of the last one.
Consider the difference directly. An HTTP PUT that sets a resource's full state is idempotent by design, matching its semantics in the HTTP specification. An HTTP POST that creates a new resource or appends a charge is not, by default, because calling it twice creates two resources or two charges. This is why payment APIs, booking systems, and messaging systems all need a deliberate mechanism to make their non-idempotent operations safe to retry, rather than relying on the HTTP verb alone.
The standard mechanism is an idempotency key: a unique identifier, generated by the client, attached to a specific logical operation, and sent along with every attempt to perform that operation, including retries. The server keeps a record of idempotency keys it has already processed, along with the result of that processing. When a request arrives with a key the server has seen before, instead of executing the operation again, the server simply returns the stored result from the first execution. The operation runs exactly once from the customer's point of view, no matter how many times the request is retried underneath.
Walk through a concrete example to see why this matters. A checkout service calls a payment API to charge a customer's card. The client generates a UUID, say a1b2c3d4-..., as the idempotency key for this specific charge attempt, and sends it in a header alongside the request. The payment API tries to process the charge, but the network times out before the client receives a response, even though the charge actually succeeded on the server. The client's retry logic, following the backoff and jitter pattern from before, fires a second attempt, this time including the identical idempotency key. The payment API recognizes the key, finds the stored result from the first (successful) charge, and returns that same result immediately, without charging the card a second time.
CREATE TABLE idempotency_keys (
idempotency_key UUID PRIMARY KEY,
request_hash TEXT NOT NULL,
response_status INT,
response_body JSONB,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
Implementing this on the server side follows a short, repeatable pattern, and it is worth walking through as a sequence because each step closes a specific race condition that a naive version would miss. The steps below assume a relational database backing the idempotency key table, with a unique constraint on the key column doing the heavy lifting against concurrent duplicate requests.
Handling a request with an idempotency key
- Look up the key - On receiving a request, check whether its idempotency key already exists in the idempotency key store.
- Return the stored result if found - If the key exists and the stored request matches (same operation, same parameters), return the previously stored response immediately without redoing the work.
- Reserve the key before processing - If the key is new, insert it into the store first, relying on a unique constraint to guard against two concurrent requests with the same key racing each other.
- Perform the actual operation - Only after the key is safely reserved, execute the real side effect, such as charging the card or inserting the order.
- Store the result against the key - Write the outcome (success or failure, and the response body) back into the idempotency key record, so future retries of this exact key return it directly.
Two details in that sequence separate a correct implementation from a broken one. First, reserving the key before doing the real work, rather than after, is what prevents two near-simultaneous retries from both slipping through and both charging the card, since the second one will hit the unique constraint and know a request is already in flight or already completed. Second, storing the request's parameters (or a hash of them) alongside the key, not just the key itself, catches a dangerous bug: a client accidentally reusing the same idempotency key for a genuinely different operation, such as charging a different amount. A correct server should reject that mismatch rather than silently returning a stale result for the wrong request.
Idempotency keys apply just as directly to queue consumers as they do to HTTP APIs, which is where this subchapter connects back to the chapter's opening thread. Because most production queues offer at-least-once delivery, a consumer must assume it will occasionally receive the same message more than once, whether due to a consumer crash before acknowledging a message, a network blip during acknowledgment, or a redelivery after a visibility timeout expires. The fix is identical in shape: extract a unique identifier from the message (an order ID, an event ID set by the producer) and treat it exactly like an idempotency key, checking whether that ID has already been processed before doing the real work. A consumer that processes a specific order without checking whether it was already charged is one redelivered message away from a duplicate charge.
Choosing what to use as the key matters as much as the mechanism itself. A client-generated UUID is appropriate when the client controls the retry and wants to guarantee uniqueness per logical attempt, such as the checkout example above. A natural business identifier, like an order ID or a message's own event ID, is appropriate inside a queue consumer, since the message already carries an ID that uniquely identifies the logical unit of work, and reusing that ID as the key avoids inventing a parallel identifier system. The wrong choice is deriving the key from something that changes between retries, like a timestamp or a random value generated server-side on each attempt, since that produces a different key every time and silently disables the protection entirely.
Idempotency keys have a precise scope. They protect against a specific operation being repeated, but they do nothing for ordering problems, where two different operations arrive out of sequence (an update followed by an earlier, delayed update overwriting it), and they do nothing for operations that were never idempotency-protected in the first place, such as a background job that increments a counter directly without checking any key. Idempotency keys are a targeted tool for a targeted problem: making a specific, identifiable operation safe to repeat. They are not a general substitute for careful state management, and pairing them with the backoff and jitter from the previous subchapter, plus a dead-letter queue for anything that fails repeatedly even with a key in place, is what turns a retry-driven system from fragile into genuinely resilient.
Bring all three pieces together and the shape of a defensible design becomes clear: a queue decouples the checkout flow from slow downstream work, at-least-once delivery means consumers will sometimes see a message twice, retries with exponential backoff and jitter handle transient failures without amplifying them into an outage, and idempotency keys make every one of those retried or redelivered operations safe to repeat. Drop any one of the three and the others cannot fully compensate. A queue without idempotent consumers turns reliability into a liability the moment a message is redelivered. Retries without backoff turn a minor blip into a storm. And idempotency keys without a bounded retry policy behind them just mean the system fails safely and slowly instead of safely and quickly. Interviewers who ask about queues are, more often than not, really asking whether you have internalized that these three pieces are one design decision, not three separate ones.
Chapter 8: Reasoning About Failure and Degradation
In a system of ten services each with 99.9% uptime, the probability that all ten are healthy at the same moment is only about 99%, which means the composite system is down for a meaningful number of hours every year before a single line of buggy code is written. That number comes from simple multiplication: 0.999 raised to the tenth power is approximately 0.99, and the remaining 1% translates into a significant number of hours of annual downtime if spread evenly, though in practice it clusters into outages. This is the arithmetic of partial failure, and it does not care how carefully each individual service was built. Add a load balancer, a cache, a database replica set, and a queue, and the service count climbs past ten before lunch.
The uncomfortable implication is that failure is not an edge case to patch later. It is the default condition of any system with enough moving parts, and the job of a senior engineer is to design for it from the first sketch, not bolt it on after an incident review. This chapter is where every previous building block (the load balancer, the cache, the replicated database, the queue) gets reexamined through the lens of what happens when this piece is slow or gone.
Nonfunctional requirements like availability and durability targets, discussed earlier, only mean something once you can describe the specific failure modes they are meant to survive.
Three ideas carry the weight of this chapter. First, a partial failure is different from a clean failure, and a timeout is the tool that turns an ambiguous hang into a decision. Second, graceful degradation patterns such as circuit breakers and bulkheads stop one failing dependency from taking down everything connected to it. Third, none of this is defensible in an interview or in production without observability: the metrics, logs, and traces that let you prove a system is behaving the way you claim.
Treat this chapter as the one where trade-off narration becomes concrete. Instead of saying the system is resilient,
you will be able to say exactly which dependency can fail, what the user sees when it does, and how fast the system notices and recovers. That specificity is what separates a senior answer from a list of buzzwords.
Partial Failures and the Meaning of Timeouts
A clean failure is easy to reason about: a service returns an error, the caller sees the error, and everyone moves on. A partial failure is harder, because the caller does not know whether the request succeeded, failed, or is still being processed somewhere on the other end of a slow network link. The request might have reached the server and died before responding. It might never have arrived at all. This ambiguity is the root of nearly every retry bug and every duplicate-charge incident discussed under idempotency, and it is also the root of cascading outages when nobody bounds how long a caller waits for an answer.
The tool that converts this ambiguity into a decision is the timeout. A timeout says: after waiting T milliseconds without a response, stop waiting and treat the call as failed, even though the real answer might still be in flight. Without a timeout, a single slow dependency can hold a thread, a connection, or a memory buffer open indefinitely, and if enough callers pile up waiting the same way, the caller's own service runs out of capacity and fails too. This is how one slow database query can take down a web tier three layers upstream, a pattern network engineers have documented since at least the early distributed systems literature on partial failure[1].
Choosing the right timeout value is itself a design decision, not a default left over from a library. A timeout set too long defeats its own purpose, because callers still pile up waiting almost as long as if there were no timeout at all. A timeout set too short causes the caller to abandon requests that would have succeeded a few milliseconds later, wasting work and potentially triggering unnecessary retries. A reasonable starting point is to look at the p99 latency of the dependency under normal load and set the timeout at some multiple of that, often 2x to 3x, then adjust based on what the retry policy and user experience can tolerate.
Timeouts need to be applied at every hop, not just at the outermost edge. If a user-facing request calls service A, which calls service B, which calls a database, each of those three hops needs its own timeout, and the budgets need to add up sensibly. A common mistake is giving the outer request a 2 second timeout while the database call three hops down has no timeout at all, which means the outer timeout fires while the inner call is still silently consuming resources. The fix is a deadline propagation pattern: pass a shrinking time budget down the call chain so each hop knows how much time is left and can fail fast instead of guessing.
Consider a product page that needs data from an inventory service, a pricing service, and a reviews service, each called in parallel with a 500 millisecond timeout. If the reviews service is healthy 99% of the time but occasionally hangs for 5 seconds during a garbage collection pause, a well-chosen timeout turns that rare hang into a page that loads without reviews, rather than a page that hangs for 5 seconds for every user unlucky enough to hit that window. This is the seed of graceful degradation, covered next, but it only works because the timeout drew a clear line between waiting
and giving up and moving on.
A system without timeouts does not avoid failure. It just waits longer to notice it, and the waiting itself becomes the outage.
Timeouts interact with retries in a way that can quietly amplify an outage if the interaction is not thought through. If a dependency slows down and every caller times out after 500 milliseconds and immediately retries, the retry traffic adds load to an already struggling dependency, which slows it down further, which triggers more timeouts and more retries. This feedback loop is called a retry storm, and it has caused real outages at large companies where a transient blip turned into a multi-hour incident purely because of retry amplification. Exponential backoff and jitter, covered in the chapter on queues and retries, exist specifically to break this feedback loop.
A less obvious but equally important distinction is between a timeout on the network call itself and a timeout on the overall user-facing operation. A network timeout tells you a single TCP connection did not get a response in time. An operation timeout tells you the entire logical unit of work, possibly spanning several retries and several dependencies, has exceeded its budget and should be abandoned. Conflating the two leads to code that retries individual network calls so many times that the user-facing operation blows well past any reasonable latency expectation, even though every single retry technically respected its own small timeout.
Partial failure also shows up when a write might have succeeded on the server even though the client never received the acknowledgment, for example, due to a dropped response packet. This is the scenario that makes idempotency essential rather than optional. Without an idempotency key, a client that times out and retries a payment request risks charging the customer twice, because from the server's point of view the second request looks like a brand new payment. The timeout did its job by giving the client a clear signal to act on. What the client does with that signal is a separate design decision that depends on whether the underlying operation is safe to repeat.
A practical habit for identifying where partial failure can bite is to draw out every network hop in a design and ask, for each one, three questions: what happens if this call never returns, what happens if it returns successfully after the caller has already given up, and what happens if it returns an error that does not distinguish between nothing happened
and something happened, but you will never know what.
Most system design interviews reward a candidate who raises this line of questioning unprompted, because it shows the difference between knowing the vocabulary and knowing how distributed systems actually misbehave.
The checklist below captures the minimum set of timeout-related questions worth asking about any dependency before calling a design complete.
Timeout design checklist for every dependency
- Does this call have an explicit timeout, rather than relying on a language or library default?
- Is the timeout value justified by the dependency's observed p99 latency, not guessed?
- Does the timeout budget shrink correctly as the request passes through multiple hops?
- Is there a difference enforced between a per-call network timeout and a per-operation overall deadline?
- Does a timeout at this hop trigger an unbounded retry, or a bounded one with backoff?
- If this call times out, does the caller know whether the underlying write might have actually succeeded?
One more wrinkle worth naming explicitly: a timeout is a guess about the server, not a fact about it. The server might be alive and about to respond in 10 more milliseconds when the client gives up at the 500 millisecond mark. This means timeouts trade a small amount of wasted work (occasionally abandoning a request that would have succeeded) for a large amount of protection against the worst case (a request that never would have returned). That trade is almost always worth making, but it is worth saying out loud in an interview, because it shows awareness that timeouts are a probabilistic tool, not a guarantee.
Finally, timeouts should be visible in monitoring, not just configured in code. A spike in timeout-triggered errors for a specific dependency is one of the earliest signals that something downstream is degrading, often appearing well before that dependency's own health checks or alarms fire. Treating timeout counts as a first-class metric, not an incidental log line, is part of what connects this section to the discussion of observability later in the chapter.
Circuit Breakers, Bulkheads, and Graceful Degradation
Knowing that a dependency has failed is only half the problem. The other half is deciding what the system does next, and the answer that separates resilient systems from fragile ones is graceful degradation: the system keeps working, just with reduced functionality, rather than failing completely because one piece broke. A product page that normally shows personalized recommendations, real-time inventory counts, and user reviews can still render successfully with a generic recommendation list and a cached inventory estimate if the personalization service and the live inventory service are both struggling. The core purchase flow stays intact even though some secondary features are temporarily missing.
The circuit breaker pattern is the mechanism most commonly used to make this automatic rather than something an engineer has to notice and flip manually during an incident. A circuit breaker sits in front of a call to a dependency and tracks the recent success and failure rate of that call. When failures cross a threshold, the breaker opens,
meaning it stops sending requests to the failing dependency entirely for a cooldown period and instead returns a fast, predefined fallback response. After the cooldown, the breaker allows a small number of test requests through in a half-open
state, and if those succeed, it closes again and resumes normal traffic. This pattern, popularized in Michael Nygard's writing on production resilience, mirrors the electrical circuit breaker it is named after: it trips to prevent cascading damage rather than letting current (or requests) keep flowing into a short circuit[2].
The states of a circuit breaker and the transitions between them are easiest to see as a flow, shown in the decision tree below.
Circuit breaker state transitions
- Closed: requests pass through normally
- Monitoring ongoing Failure rate crosses threshold (No: return to "Closed: requests pass through normally")
- Yes Open: fail fast, return fallback immediately
- Cooldown timer expires
- Half-open: allow a few test requests
- Success Test requests succeed, return to closed
- Failure Test requests fail, return to open
- Half-open: allow a few test requests
- Cooldown timer expires
- Yes Open: fail fast, return fallback immediately
- Monitoring ongoing Failure rate crosses threshold (No: return to "Closed: requests pass through normally")
The critical benefit of the open state is speed. Without a circuit breaker, every caller still has to wait out a full timeout before discovering the dependency is down, which wastes time and resources on requests that are almost certain to fail anyway. With the breaker open, failures become near-instant, which protects the caller's own thread pool and connection limits from being exhausted. This matters enormously when the caller itself serves many other callers upstream, because a thread pool pinned waiting on a dead dependency cannot serve any other request, healthy or not.
The bulkhead pattern solves a related but distinct problem: isolating failure so it cannot spread from one part of a system to another, even within the same process. The name comes from a ship's hull, which is divided into watertight compartments so that a puncture in one section floods only that section instead of sinking the entire vessel. In software, a bulkhead typically means giving each downstream dependency its own dedicated pool of threads or connections, so that if calls to the reviews service start hanging, they exhaust only the reviews thread pool, leaving the pricing and inventory calls able to proceed on their own separate pools.
Without a bulkhead, a single shared thread pool across all outbound calls means one misbehaving dependency can starve every other call, even perfectly healthy ones, simply because all the threads are stuck waiting on the slow dependency. The table below contrasts a shared-pool design against a bulkheaded one for the product page example.
Design Behavior when reviews service hangs Impact on inventory and pricing calls Shared pool for all outbound calls All threads eventually block waiting on reviews Inventory and pricing calls also stall, full page failure Dedicated pool per dependency (bulkhead) Only the reviews pool is exhausted Inventory and pricing calls proceed normally, page loads without reviews
Circuit breakers and bulkheads work best together rather than as substitutes for one another. The bulkhead limits the blast radius of a slow dependency to its own pool of resources. The circuit breaker, operating within that bulkhead, reduces how long the pool stays under pressure by failing fast once the dependency is clearly unhealthy. A system that has one but not the other is still exposed: bulkheads alone still let a slow dependency saturate its own pool and keep every caller waiting the full timeout, while a circuit breaker alone does not stop a different kind of shared-resource exhaustion, like a shared database connection pool, from being drained by one bad actor.
Deciding what a fallback actually returns is where graceful degradation becomes a product decision as much as an engineering one, and it deserves explicit discussion with stakeholders rather than being left to whichever response an engineer found convenient. Some reasonable fallback strategies, roughly in order from least to most disruptive to the user:
- Cached or stale data: show the last known good value, clearly tolerable for things like recommendation lists or view counts.
- A simplified default: show a generic experience instead of a personalized one, such as a popular-items list instead of tailored recommendations.
- An empty or hidden section: simply omit the feature, acceptable for secondary content like reviews or related items.
- A clear error to the user: appropriate when the failing dependency is core to the request, such as the payment processor during checkout, where pretending success would be worse than admitting failure.
The payments example is worth dwelling on because it illustrates that graceful degradation is not always find a way to say yes.
Sometimes the correct degraded behavior is to fail the user-facing request honestly and quickly, rather than accepting a write the system cannot later verify. The same logic applies to the payments ledger walkthrough later in the book, where correctness constraints take precedence. A senior engineer distinguishes between dependencies where degrading is safe (recommendations, reviews, view counts) and dependencies where degrading means silently corrupting data (inventory decrements, payment authorization, ledger writes), and designs the fallback behavior differently for each.
A good way to pressure-test a design in an interview is to list every external dependency and assign each one a degradation strategy before being asked. For the product page example, that might look like the table below.
| Dependency | If it fails | User-visible effect |
|---|---|---|
| Recommendation service | Show generic popular items | No personalization, page still loads |
| Reviews service | Hide the reviews section | Section missing, rest of page intact |
| Inventory service | Show last cached stock estimate | Possible stale stock count, flagged as approximate |
| Payment processor | Fail the checkout with a clear retry message | User sees an honest error, no silent charge risk |
Naming these strategies up front, before any failure actually happens, is the practical output of this entire subsection. It turns the system should be resilient
into a concrete, reviewable table that a team can actually implement and test, and it gives an interview answer the specificity that distinguishes a senior engineer from someone reciting pattern names without having thought through what each pattern is protecting.
Observability as a Design Requirement
Observability is the ability to ask a question about a running system's internal state using only the data it already emits, without having to ship new code to answer it. This is different from traditional monitoring, which checks a predefined list of known failure conditions. A dashboard that alerts when CPU exceeds 90% is monitoring. The ability to ask, after an incident, which specific user requests touched the service that was slow, and what did each one return,
without having anticipated that exact question in advance, is observability. The distinction matters because in a distributed system, the failure that actually happens is rarely the one anyone predicted, so the tooling needs to answer questions nobody wrote down ahead of time.
Observability rests on three complementary data types, and a mature system needs all three because each answers a different kind of question. Metrics are numeric time series, like requests per second or error rate, cheap to store and good for dashboards and alerting on aggregate trends. Structured logging means emitting log lines as structured, queryable fields such as {"service": "pricing", "request_id": "abc123", "latency_ms": 420, "status": "timeout"} rather than free-text sentences, so that logs can be filtered and aggregated reliably instead of grepped. Distributed tracing follows a single request as it travels across every service it touches, stitching together timing data so you can see exactly which hop in a multi-service call chain consumed the 400 milliseconds a user actually experienced.
Tracing deserves extra attention because it is the piece most directly tied to partial failure: when a request is ambiguous or slow, a trace is what turns that ambiguity into a precise timeline. Each request is tagged with a unique trace identifier the moment it enters the system, and that identifier is propagated through every downstream call, often alongside the deadline budget discussed earlier. When a request is slow, instead of guessing which of the ten services it touched was the culprit, an engineer can pull up the trace and see a timeline: a small amount of time in the API gateway, a small amount in the auth service, the bulk of the time stuck waiting on the pricing service, and a small amount assembling the response. The idea was formalized in Google's Dapper paper, which described how large-scale tracing could be added to a system without imposing meaningful performance overhead[3], and the open standard that grew from similar ideas, OpenTelemetry, is now the common way teams instrument this across languages and frameworks.
None of this data is useful if it is not tied back to what the business actually promised. This is where the SLI, or service level indicator, connects observability to the SLO discussed in the requirements chapter. An SLI is a specific, measured quantity, such as the percentage of requests in a recent window that completed within a defined latency threshold,
and the SLO is the target for that indicator, such as a high percentage of requests meet that bar over a rolling window.
Without an SLI actually being measured and reported, an SLO is just an aspiration printed in a document, disconnected from anything the system can prove.
The error budget is what turns an SLO into an operational decision-making tool rather than a static number. If the SLO allows a small percentage of requests to fail over the measurement window, that allowance is the error budget, a quantity of acceptable failure the team can spend deliberately. A team that has used up its error budget for the month, because of a rough week of incidents, has a concrete, numeric reason to freeze risky deploys and focus on stability instead of shipping new features. A team sitting comfortably within budget has an equally concrete, numeric justification for taking on more risk, for example rolling out a new caching layer that might introduce occasional staleness. This reframes reliability conversations from subjective arguments about risk tolerance into a shared number everyone can look at.
Observability is also what makes graceful degradation verifiable rather than aspirational. A circuit breaker that opens silently, with no metric tracking how often it trips or how long it stays open, is a liability disguised as a safety feature, because nobody will notice if it is tripping constantly in production due to a misconfigured threshold. The same goes for fallback paths: if the fallback that serves generic recommendations instead of personalized ones is never measured, a team has no way to know how often users are actually seeing the degraded experience, which makes it impossible to decide whether the underlying dependency needs real investment or whether the fallback is good enough to leave alone indefinitely.
A concrete example ties these threads together. Imagine an on-call engineer paged at 2 a.m. because the checkout success rate has dropped significantly. Metrics show the drop started a short time earlier and correlates with elevated latency on the inventory service. A trace for one of the failed requests shows the request spent several seconds waiting on an inventory call that eventually timed out, then fell through to a fallback path that itself failed because the fallback assumed inventory data would already be in cache and it was not. Structured logs confirm the inventory service's own database connections were exhausted. With all three signals available, the engineer can distinguish, in minutes rather than hours, between a networking problem, a code bug, and a resource exhaustion problem, and can make a targeted fix instead of restarting services at random.
Without that observability stack, the same incident looks like checkout is broken, nobody knows why,
and the team is reduced to guessing, often reaching for the blunt instrument of rolling back the most recent deploy even when that deploy had nothing to do with the actual cause. This is the strongest argument for treating observability as a design requirement to be planned alongside the architecture, not an afterthought added once something has already gone wrong in production. A system design interview answer that mentions, unprompted, how a specific failure would actually be detected and diagnosed signals exactly this kind of production-tested judgment.
Chapter 9: Designing Common Systems End to End
A URL shortener sounds trivial until the read-to-write ratio hits 10,000-to-1 and a single viral link turns your redirect service into an accidental DDoS on your own database. That single number changes everything about the design: it tells you where to put your caching layer, how much you can get away with on consistency, and why your intuition about "just use a relational database" needs a second look. This chapter does not introduce new tools. It takes the requirements discipline, the back-of-the-envelope calculation, the load balancer, the cache, the replicated and partitioned database, the consistency menu, the queue, and the failure-handling patterns from earlier chapters and puts them to work together, in order, on three systems that show up constantly in interviews.
The goal of each walkthrough is not to produce a single correct diagram. Two engineers can design the same system differently and both be right, as long as each decision traces back to a stated requirement and a named trade-off. What separates a senior answer from a junior one is not cleverness, it is the discipline of saying out loud why a choice was made, what it costs, and what would make you choose differently. These three systems were picked because they stress different parts of the toolkit: the URL shortener is a read-heavy system where caching and partitioning dominate, the social feed forces a direct choice between fan-out on write and fan-out on read, and the payments ledger is a correctness-critical system where every shortcut that helped the first two systems becomes a liability.
Treat each walkthrough as a template, not a script. The specific numbers, such as request volume or storage size, exist to anchor the reasoning in something concrete, the way the estimates chapter trained you to anchor every design decision in a number rather than a guess. When the numbers in a real interview differ from the ones used here, the method for using them does not change. Pin down functional requirement and nonfunctional requirement, run a quick back-of-the-envelope calculation, then let the resulting constraints eliminate options before you draw a single box.
By the end of this chapter, the pattern connecting all three systems should be visible: requirements and scale numbers point to one or two dominant constraints, those constraints rule out whole categories of solution, and only then do you pick specific components. A URL shortener's dominant constraint is read volume. A social feed's dominant constraint is fan-out cost at scale. A payments ledger's dominant constraint is correctness under concurrent writes and partial failure. None of these systems need every tool in the book. Knowing which tools to leave out, and saying why, is as much a signal of seniority as knowing which ones to use.
Walkthrough: A URL Shortener Under Heavy Read Load
Start with requirements, the way the opening chapters insist. The functional requirement is simple: given a long URL, generate a short code, and given a short code, redirect to the original URL. The nonfunctional requirement is where the real design lives. Redirects must be fast, ideally under 100 milliseconds, because a slow redirect degrades every page that links through the shortener. Availability must be high, because a down shortener breaks links across the internet instantly and visibly. Durability of the mapping matters too, since losing a stored URL breaks a link permanently, but the system can tolerate a short window of staleness on less critical metadata like click counts.
Run the back-of-the-envelope calculation next. Suppose the service handles 100 million new short links per day, which averages to roughly 1,200 writes per second, and suppose the read-to-write ratio is 100:1 under normal conditions, rising far higher during a viral spike. That puts baseline read traffic around 120,000 QPS, with spikes that can be an order of magnitude higher on a single hot key. Storage is cheap by comparison: a short code plus a long URL and some metadata is well under a kilobyte per row, so even 100 million new rows a day (roughly 50 GB a day at 500 bytes each, or about 18 TB a year) land in a manageable storage range, comfortably within what a partitioned key-value store handles without strain.
That ratio is the whole story. A system that is read-heavy by two or three orders of magnitude should spend its design budget on caching and replication for reads, not on write throughput. This is where the cache-aside pattern earns its place: a request for a short code first checks a cache, and only on a miss does it fall through to the database, with the result written back into the cache for next time. Because short URL mappings are immutable once created, a long TTL, or no TTL at all until explicit deletion, is appropriate, since staleness risk is essentially zero for a mapping that never changes.
Generating the short code itself needs a decision too. One option is a hash of the long URL truncated to a few characters, with collision checks. A simpler and more scalable option is a counter-based scheme, where each write claims the next value from a distributed counter or a pre-allocated block of IDs, then encodes that integer in base 62. The counter approach avoids collision handling entirely and is trivial to partition, since each application server can claim a block of IDs at a time and hand them out locally before requesting a new block, which keeps write latency low and avoids a single point of contention.
Storage and partitioning follow directly from the traffic shape. A key-value store partitioned by the hash of the short code spreads both the data and the request load evenly, since a short code's popularity is unrelated to its lexical value, which avoids the hotspot problems that would occur if codes were partitioned by creation time or by sequential ID. Each partition can use leader-follower replication, with reads served from followers to absorb the bulk of the 120,000 QPS without overloading leaders, which only need to handle the much smaller write volume and the (bounded) replication lag that comes with it.
A CDN sits in front of the redirect endpoint for an additional layer of relief. Since a redirect response is just an HTTP 301 or 302 pointing at the destination, and since that response rarely changes once set, many CDNs can cache redirect responses at the edge, serving a viral link's flood of requests without any of them reaching the origin servers at all. This is the single highest-leverage decision in the whole design: it converts a request that would otherwise fan out through load balancer, application server, cache, and database into one that terminates at an edge node geographically close to the user.
Consider what happens without this edge caching layer during a real viral spike, like a link shared by a large social media account that drives a sudden large increase in read traffic within seconds. Without the CDN, that spike lands on the application tier and the cache layer simultaneously, and if the cache ever misses (for example, right after a cache node restarts) the resulting thundering herd of requests can overwhelm the database before the cache repopulates. With the CDN layer, the same spike is absorbed at the edge almost entirely, and the origin only sees a small, steady trickle of cache-fill requests.
The table below sets out how each nonfunctional requirement maps to a specific design choice in this system, which is a useful habit to narrate explicitly in an interview rather than leaving the mapping implicit.
| Requirement | Design choice | Why it fits |
|---|---|---|
| Low redirect latency | CDN caching of redirect responses | Edge nodes serve repeat requests without reaching origin |
| High read QPS | Cache-aside pattern plus read replicas | Reads are absorbed by cache and followers, not the leader |
| High write availability | Counter-based ID generation in blocks | Avoids collision checks and central write contention |
| Even load distribution | Partitioning by hash of short code | Popularity is independent of code value, so load spreads evenly |
| Durability of mappings | Leader-follower replication with durable storage | A created link must never silently disappear |
Now layer in a deliberate constraint change, since interviewers almost always introduce one. Suppose the requirement shifts to support custom aliases, where a user picks their own short code instead of receiving a generated one. This breaks the counter-based generation scheme, because now two users might request the same alias concurrently, so the write path needs a uniqueness check, typically an atomic compare-and-set against the partition holding that key before the write is accepted. This is a small, localized change, not a redesign, and recognizing that it only touches the write path (not the read path, cache, or CDN) is exactly the kind of targeted response that signals strong trade-off narration under a changed constraint.
Analytics is a second common twist worth anticipating. If the business wants click counts per link, writing a counter increment synchronously on every redirect would reintroduce write contention on the hottest keys, undermining the very read-scaling work just done. The better answer is to decouple that write: emit a lightweight event to a queue on each redirect and let a separate consumer batch-update click counts asynchronously, accepting eventual consistency on the analytics number in exchange for keeping the hot redirect path untouched by write load. This is a direct application of queues decoupling producers from consumers, applied to protect a read-heavy path from an unrelated write workload.
One failure mode worth naming explicitly: what happens to a redirect request when the database partition holding that short code is unreachable, but the CDN and cache layers are healthy? If the code was recently created or was recently accessed, the cache likely still has it and the user never notices the outage. If the code is cold and the cache has evicted it, the right behavior is a clean, fast error, not a hanging request, which is where a short, explicit timeout and a circuit breaker on the database call prevent one degraded partition from stalling the application tier.
A realistic point to flag unprompted in an interview: this design accepts that a newly created link might take a moment to propagate to all read replicas before it is reliably redirectable everywhere, a small and generally acceptable amount of replication lag given the read-heavy, write-light nature of the traffic. Naming that gap before being asked about it is a stronger signal than waiting for the interviewer to find it.
Put together, the URL shortener shows the clearest possible case for letting a single measured ratio (reads to writes) drive nearly every architectural decision that follows. Caching, CDN placement, replication strategy, and even ID generation all trace back to that one number, and a candidate who states that ratio early and references it repeatedly is demonstrating the exact kind of first-principles reasoning this book has been building toward.
Walkthrough: A Social Feed With Fan-Out Choices
The functional requirement for a social feed is that a user who follows other accounts sees their posts, roughly in reverse chronological order, when they open the app. The nonfunctional requirement that dominates this design is latency on feed load, typically expected well under a second, combined with a write pattern that is wildly uneven across users. Some accounts have a few hundred followers. A small number have tens of millions. That skew, more than raw volume, is what makes this system interesting, and it is the reason the central decision in feed design is not about databases or caches in isolation, but about when the work of assembling a feed actually happens.
Two fundamentally different strategies exist, and naming both before picking one is the mark of a strong answer. Fan-out on write means that when a user posts, the system immediately pushes that post into the precomputed feed of every follower, so that reading a feed later is just a fast lookup of an already-assembled list. Fan-out on read means the opposite: nothing happens at post time beyond storing the post, and when a user opens their feed, the system queries the posts of everyone they follow and merges them on demand, which is a scatter-gather operation across many sources at read time.
Run the back-of-the-envelope math on fan-out on write for an average user first. If a typical account has a modest number of followers and posts a few times a day, each post triggers a corresponding number of writes into follower feed stores, which is a manageable multiplication. Across a platform at that scale, the aggregate write volume into feed stores lands in a very large number per day, which is well within reach of a partitioned, write-optimized store, especially since each individual write is small, often just a post identifier and a timestamp appended to a list.
Now run the same math for a celebrity account with 50 million followers. A single post from that account would need to fan out to tens of millions of feed stores immediately, which at any reasonable write rate takes real wall-clock time to complete, floods the write path, and risks every one of those fan-out writes competing with ordinary traffic. This is the celebrity problem, and it is the single most common twist interviewers introduce specifically to see whether a candidate defends a pure fan-out on write design past the point where the math stops supporting it.
The practical answer is a hybrid, and stating this hybrid explicitly, rather than discovering it reluctantly under pushback, is the stronger move. Use fan-out on write for accounts below some follower threshold, say 100,000 followers, since the write amplification stays bounded and feed reads stay cheap for ordinary users. Above that threshold, fall back to fan-out on read for that specific account's posts, meaning a follower's feed assembly pulls celebrity posts in at read time and merges them with the precomputed portion of the feed from fan-out on write. The decision tree below captures this branching logic in the shape an interviewer would want to hear narrated out loud.
Choosing a fan-out strategy per account
- How many followers does this account have?
- Below follower threshold Fan-out on write: push post into every follower's precomputed feed
- Merge precomputed feed with scatter-gather results, sort by time
- Above follower threshold Skip fan-out at write time, mark post for read-time merge
- Feed read performs scatter-gather against this account's recent posts
- Merge precomputed feed with scatter-gather results, sort by time
- Feed read performs scatter-gather against this account's recent posts
- Below follower threshold Fan-out on write: push post into every follower's precomputed feed
Storage choices follow the same split. The precomputed feed for an ordinary user is naturally a per-user list, partitioned by user ID, which keeps feed reads to a single partition lookup, fast and cheap. Posts themselves live in a separate store, partitioned by post ID or by author ID, replicated with leader-follower replication so that the read-time merge for celebrity accounts can be served from followers rather than hammering a leader. Caching the most recent posts from high-follower accounts in a fast in-memory store further reduces the cost of the read-time scatter-gather, since the same celebrity posts are being merged into millions of feed reads in a short window after posting.
A staleness trade-off sits underneath this whole design, and naming it precisely matters. Fan-out on write means a follower's feed can briefly be slightly behind if the fan-out write hasn't completed across all replicas, which is acceptable because a feed that is a few seconds stale is invisible to the user. Fan-out on read has a different staleness profile: it is always as fresh as the underlying post store, but it trades that freshness for higher and more variable read latency, since the read path now depends on the health and latency of every source it scatters to. A strong answer states which kind of staleness it is willing to accept and why, rather than treating "eventual consistency" as a single undifferentiated idea.
Another twist worth anticipating unprompted: what happens when a user unfollows someone after already receiving fan-out writes from them? The precomputed feed may still contain posts from the unfollowed account for a short time, until the next feed refresh or a background cleanup pass removes them. This is a deliberate, bounded staleness window, not a bug, and calling it out before being asked shows the same proactive habit rewarded in the URL shortener walkthrough.
Failure handling in the read-time merge path deserves specific attention, because scatter-gather is exactly the pattern where one slow dependency can stall an entire request. If the feed read needs to query posts from many accounts a user follows and one of those partitions is slow, the feed assembly should apply a short, strict timeout per source and simply omit that source's posts from this particular read rather than blocking the whole feed, which is graceful degradation applied directly to a scatter-gather call rather than a theoretical idea on its own.
The checklist below is a useful pre-flight run-through before presenting a feed design in an interview, since it surfaces the exact details that interviewers probe for when they suspect a candidate has memorized "fan-out on write" as a buzzword rather than understanding its cost.
Before presenting a feed design, confirm you can answer
- What follower-count threshold separates fan-out on write from fan-out on read, and why that number
- What happens to feed freshness during fan-out write propagation
- How a celebrity post avoids flooding the write path
- How the read path handles one slow or unavailable source during scatter-gather
- How unfollowing an account is reflected in an already-fanned-out feed
- Whether ranking or purely chronological ordering changes any of these choices
Ranking is worth a brief closing note, since many real feeds are not strictly chronological but ranked by an engagement model. That ranking step is itself a largely separate concern from fan-out strategy: it can sit on top of either approach, scoring and reordering whatever candidate posts the fan-out (write or read) surfaced, which is a useful thing to say explicitly if an interviewer asks about ranking, so the conversation does not derail into machine learning territory when the fan-out trade-off is the actual subject under evaluation.
Walkthrough: A Payments Ledger Where Correctness Wins
Everything about the first two systems bends toward availability and low latency, with staleness treated as an acceptable cost. A payments ledger inverts that priority completely. The functional requirement is to record money moving between accounts such that balances are always correct and every transaction is auditable after the fact. The nonfunctional requirement that dominates here is correctness under concurrent writes and partial failure, specifically that money is never created, destroyed, or applied twice, even when a client retries a request, a network call times out, or a server crashes mid-transaction. Latency targets still exist, but they are secondary to getting the number right every single time.
Start by naming the sharpest consistency decision up front: a payments ledger needs strong consistency on account balances, not eventual consistency. This rules out several patterns that served the earlier two systems well. Fan-out on write style asynchronous propagation, feed-style staleness windows, and read-replica serving of balance queries without a freshness guarantee are all unacceptable here, because a user who checks a balance and sees stale data before making a second payment could overdraw an account that a stale read reported as having sufficient funds. This single requirement pushes the entire design toward leader-based writes with synchronous confirmation before a transaction is considered committed.
The structural foundation of a correct ledger is the write-ahead log: before any balance is updated in the primary data store, the intended change is first appended to a durable, ordered, append-only log. If the system crashes after the log entry is written but before the balance update is applied, recovery replays the log and finishes the update, guaranteeing that no committed transaction is ever lost, and that no partially applied transaction is left silently invisible. This is the same mechanism underlying most relational database engines internally, and recognizing it as a general design pattern, not just a database internal, is useful when a design calls for building a custom ledger service rather than relying entirely on an off-the-shelf database's transaction guarantees.
Concurrency is the next sharp edge. Two transfers debiting the same account at nearly the same moment must not both read the same starting balance and both succeed, since that would effectively double-spend the account's funds. The standard defense is to make the balance update a single atomic operation at the database level, typically a conditional update that only succeeds if the balance still matches what was read, retried on conflict, rather than a read-then-write pair of separate operations that leaves a window for a race. For higher-throughput ledgers, this often means partitioning accounts so that all operations on a given account are serialized through a single partition, trading some horizontal write scalability for a strong guarantee that updates to one account never race against each other.
Idempotency is where this walkthrough connects most directly to a theme threaded through the whole book: retries are inevitable, and without protection they are dangerous. Consider a client that initiates a payment, the server processes it and debits the account, but the response is lost in transit due to a network blip. The client, seeing no response, retries the exact same payment request. Without protection, this doubles the debit. The fix is an idempotency key, a unique identifier generated by the client (often a UUID) and attached to the payment request, where the server stores a record of every key it has processed and, on seeing a repeat, returns the original result instead of reprocessing the payment.
Implementing this correctly requires more care than it first appears, and walking through the mechanism concretely is worth doing in an interview rather than naming the term and moving on. The server needs to atomically check whether a given idempotency key has already been seen and, if not, mark it as in-progress before beginning the actual balance update, so that two near-simultaneous retries of the same request don't both pass the check and both execute. The steps below lay out one safe sequence for processing a payment with an idempotency key, the kind of concrete mechanism a strong answer sketches rather than hand-waves.
Processing a payment request safely with an idempotency key
- Receive request - Client sends a payment request including a unique idempotency key generated once per logical transaction, even across retries.
- Atomic check-and-insert - Server attempts to insert the idempotency key into a dedicated table with status 'in progress', using a database constraint that rejects a duplicate key.
- Handle duplicate - If the insert fails because the key already exists, look up the stored result for that key and return it directly without touching balances again, or ask the client to retry later if the first request is still in progress.
- Append to write-ahead log - On a successful insert, append the intended balance change to the write-ahead log before applying it, so a crash mid-transaction can be recovered.
- Apply the conditional balance update - Update the account balance using a conditional operation that fails safely on a concurrent conflict, then retry the update, not the whole transaction, on conflict.
- Mark complete and store result - Update the idempotency key's status to 'complete' along with the final result, so future retries of the same key return this stored outcome.
This sequence is a concrete instance of exactly-once delivery semantics implemented on top of an underlying system that only truly guarantees at-least-once delivery. The network, the queue (if one sits in the payment path), and the client's own retry logic can all duplicate a request, but the idempotency key table converts that duplication into a safe no-op on the server. This is worth saying explicitly, since conflating "idempotent" with "exactly-once at the network level" is a common and revealing mistake. The guarantee is constructed, not assumed, through this specific storage-level mechanism.
Now consider where a queue fits into this design at all, given that the earlier chapter on queues emphasized decoupling producers from consumers. A payments system often does use a queue for the parts of the process that are not on the strict consistency critical path, such as sending a confirmation email, updating a fraud-detection model's features, or notifying a downstream reporting system. The actual balance mutation, however, generally should not be queued asynchronously, because that would reintroduce exactly the staleness window a correctness-critical system cannot tolerate on its core balance state. The pattern is to commit the balance change synchronously first, then publish an event to a queue afterward for every consumer that can tolerate eventual consistency.
A dead-letter queue still has a role even in this design, specifically for the asynchronous side effects just described. If the email service is down and a confirmation-email event fails repeatedly, that event should move to a dead-letter queue for later inspection rather than blocking the queue or being silently dropped, while the payment itself has already committed successfully and is unaffected by that downstream failure. This distinction, between the synchronous correctness-critical path and the asynchronous best-effort path, is one of the clearest illustrations in this book of how the right answer depends entirely on what a specific piece of data can afford to get wrong temporarily.
Auditability deserves its own mention, since it is a requirement specific to financial systems that neither of the first two walkthroughs needed. Every balance-changing operation should be recorded as an immutable entry, typically a double-entry style record showing a debit and a corresponding credit, rather than the ledger only storing current balances. This lets an auditor or an automated reconciliation job reconstruct exactly how any balance reached its current value, and it gives recovery logic something concrete to replay if a dispute arises. Storing only current balances, with no transaction history, is a common shortcut that works until the first dispute or the first regulatory audit, at which point it becomes a serious liability.
Replication still matters here, but its purpose shifts from the read-scaling role it played in the URL shortener. A payments ledger's leader handles all writes for a given partition of accounts, with followers providing durability and failover capability rather than serving live balance reads. If the leader fails, a follower is promoted, and the system must accept a brief availability gap during that failover rather than silently serving a stale balance from an unpromoted follower, a direct, deliberate application of choosing strong consistency and durability over availability in this one, specific corner of the system, even while the rest of the platform (say, the transaction history feed shown to a user) can tolerate more relaxed guarantees.
Across these three walkthroughs, the common thread is that nothing about the toolkit changed. The same load balancer, cache, replication, partitioning, queue, and retry patterns show up in all three, but the weight given to each one flips completely depending on what the system is allowed to get wrong. A URL shortener can be wrong about a click count for a few seconds and nobody notices. A feed can show a post a few seconds late and nobody notices. A ledger cannot be wrong about a balance for even a moment, and that single difference is what should drive every subsequent design choice, loudly and explicitly, the next time a constraint like this shows up in a design discussion.
Chapter 10: Communicating Trade-Offs in the Interview Room
Two candidates can propose identical architectures and receive opposite hiring decisions, and the difference is almost always whether the interviewer could follow the reasoning, not whether the design was correct. One candidate draws a leader-follower setup, mentions a cache, and moves on. The other draws the same boxes but narrates why each one exists, what it costs, and what alternative was rejected. The second candidate gets the offer, because a senior engineer is not hired to produce a diagram. A senior engineer is hired to make decisions that other people can trust, question, and build on, which means the decision-making process has to be visible, not just the outcome.
This is the part of system design interviews that trips up strong engineers who have done everything right up to this point. They have asked good clarifying questions, run sound back-of-the-envelope calculations, and chosen reasonable components for load balancing, caching, replication, and queues. Then they freeze when the interviewer interrupts, changes a number, or asks what if that service goes down?
The freeze is not a knowledge gap. It is a communication gap, and it is fixable with specific habits.
Trade-off narration is the skill of saying out loud what is normally left implicit: the options considered, the one chosen, and the cost being accepted in exchange for the benefit gained. Every design decision in the earlier chapters, from choosing eventual consistency over strong consistency to picking fan-out on write over fan-out on read, has a cost side and a benefit side. An interviewer who hears only the benefit side assumes you either do not know the cost or are hiding it. Neither assumption helps you.
The three subchapters here build the full interview skill set. The first covers how to narrate a decision as you make it, so the interviewer never has to guess what you are thinking. The second covers what happens when the interviewer changes the problem mid-design, which they almost always do, and how to adapt without starting over or panicking. The third covers the hardest moment: defending a choice under direct pushback, and knowing the difference between holding your ground and bluffing past a gap in your knowledge.
Narrating Decisions Without Losing the Interviewer
Silent thinking is the single most common failure mode in system design interviews, and it is almost never a sign of weak reasoning. Many candidates think clearly but think privately, working out a comparison between two caching strategies in their head and then announcing only the conclusion. The interviewer sees a candidate who drew a cache, said I'll use a TTL value,
and moved on. From the interviewer's seat, that looks like a guess, even if it was the product of careful reasoning about staleness tolerance. The fix is mechanical: say the comparison out loud, not just the answer.
A useful structure for narrating any decision has four parts, and it works whether the decision is about a load balancing algorithm, a partition key, or a retry budget:
- Name the decision point.
I need to decide how followers catch up on writes.
- State the options.
I could use synchronous replication for strong durability, or asynchronous replication for lower write latency.
- State the cost of each.
Synchronous adds latency to every write because we wait for followers to acknowledge. Asynchronous risks losing recent writes if the leader fails before replicating.
- Make the call and tie it to the requirement.
Since this is a social feed and not a payments ledger, I'll accept asynchronous replication and a small risk of lost writes, in exchange for lower write latency.
Notice that the fourth step references the requirement gathered earlier in the interview, which is exactly why clarifying functional and nonfunctional requirements at the start pays off here. A decision justified by because that's standard
sounds memorized. A decision justified by because this system can tolerate losing a few seconds of writes but can't tolerate slow writes
sounds reasoned. The interviewer is not grading whether you picked synchronous or asynchronous replication. They are grading whether the pick follows from something you established about the problem.
Pacing matters as much as content. A candidate who narrates every micro-decision at the same length drowns the interviewer in detail and runs out of time before reaching the interesting parts of the system. A useful rule is to spend real narration time on decisions that are genuinely close calls or that carry a real cost, such as choosing between strong consistency and eventual consistency for a specific data type, and to move quickly through decisions that are not controversial, such as using a load balancer in front of stateless web servers. Flag the fast ones explicitly: I'll put a standard Layer 7 load balancer here, nothing unusual, so I can spend more time on the harder partitioning question.
This tells the interviewer you know which decisions matter, which is itself a signal of seniority.
Whiteboard or document discipline supports narration instead of fighting it. Label every box with its role, not just its product name, so that when you later say the cache here
the interviewer does not have to scan the whole diagram to find it. Draw data flow with arrows that show direction, and narrate along the arrows rather than jumping between unconnected parts of the diagram. When a decision has a cost that will matter later, write it next to the component as a short note, such as stale up to 30s next to a cache. This turns the diagram itself into a record of trade-offs, which helps both you and the interviewer track the conversation as the system grows more complex.
A common mistake is treating narration as an apology. Candidates sometimes hedge every sentence with I think
or maybe this works but I'm not sure,
which reads as insecurity rather than honesty. The fix is to separate genuine uncertainty, which deserves to be flagged clearly, from routine decisions, which deserve a confident statement followed by the reasoning. Say I'll partition by user ID because most queries filter by user, and this avoids scatter-gather for the common case,
not I guess maybe partitioning by user ID could work?
Confidence in narration comes from grounding each claim in a stated requirement or a piece of math, not from sounding certain about things you are not certain of.
Another mistake is narrating in isolation from the numbers computed earlier. A back-of-the-envelope calculation that estimated peak QPS should resurface when you justify adding a cache or choosing a partitioning scheme. Saying at the estimated read rate, hitting the database directly would saturate it, so I'm adding a cache in front of the user profile table
connects two parts of the interview that many candidates leave disconnected. Interviewers notice when a candidate's later decisions ignore the numbers established earlier, and it reads as a sign that the numbers were décor rather than a genuine input to the design.
Time-boxing yourself helps avoid the opposite failure, where a candidate narrates so thoroughly that the interview runs out before a single component is fully specified. A reasonable allocation for a typical interview might give a few minutes to requirements, a few to estimates, the bulk of the time to the core design with narration woven in, and the remaining time to deep dives and follow-up questions. Watch the clock out loud: I want to make sure we get to the deep dive, so let me move to partitioning now.
This signals time awareness, which many interviewers explicitly value as a proxy for how someone will run a real design review under deadline pressure.
The checklist below captures the habits that keep narration clear without becoming a lecture.
Narration habits for a design interview
- State the decision point before naming the option you picked
- Name at least one alternative you considered and rejected
- State the specific cost of the option you chose, not just its benefit
- Tie the final call back to a requirement or number from earlier in the interview
- Label diagram components by role, and annotate costs like staleness or latency next to them
- Spend more narration time on close calls, less on routine choices
- Watch the clock out loud and flag when you are moving on to protect time for deep dives
Worked example: a candidate designing a URL shortener is asked how to generate short codes. A weak answer is I'll use a hash of the URL.
A strong answer narrates: I have two options, hashing the URL or using a counter with base-62 encoding. Hashing risks collisions and needs a check-and-retry step, adding latency on write. A counter avoids collisions but needs a coordinated ID generator, which becomes a single point of contention at high write volume. Since this system is read-heavy and writes are infrequent, I'll accept the coordination cost of a counter-based approach for the collision-free guarantee.
Both sentences describe a valid design. Only the second demonstrates trade-off narration.
Practicing this skill does not require a live partner every time. Recording yourself narrating a design out loud, even for five minutes on a familiar system, reveals filler words, abrupt jumps between decisions, and places where the reasoning was skipped entirely. Listening back for the four-part structure, naming the decision, stating options, stating costs, making the call, turns an abstract skill into a checklist you can audit against your own voice. Candidates who do this a handful of times before an interview report that the structure becomes automatic, freeing attention for the actual engineering problem instead of the performance of explaining it.
Finally, narration is not a monologue. Pausing after a significant decision to ask does that align with what you're looking for, or should I weigh this differently?
invites the interviewer into the conversation and often surfaces a constraint you missed. This single habit transforms the interview from a test into a collaboration, which is closer to what the job actually looks like, and interviewers consistently rate this kind of engagement higher than a flawless but closed-off monologue.
Handling Follow-Up Questions and Changed Constraints
Every system design interview eventually changes the ground under the candidate's feet. The interviewer who was happy with 10,000 daily active users suddenly asks what if this needs to support 10 million users tomorrow?
or what if this feature now needs to work across two regions?
This is not a trick designed to embarrass the candidate. It is usually a genuine test of whether the design was built on reasoning that can flex, or on a memorized template that only works for the exact scenario it was copied from. A design built from first principles, the way earlier chapters constructed requirements, estimates, and component choices, flexes naturally because every choice is traceable to a number or a requirement that can be updated.
The first reflex to build is to resist re-deriving the entire design from scratch. A changed constraint rarely invalidates everything drawn so far. It usually invalidates one or two specific decisions whose justification depended on the old number. If the original design assumed a certain QPS and justified a single-leader database on that basis, a large jump in scale does not mean throwing away the load balancer or the cache. It means revisiting the database layer specifically, since that was the component whose justification depended most directly on the changed number. Saying this out loud, the load balancer and cache tier don't change much here, but the single-leader database won't hold up, so let's partition it,
shows the interviewer that you understand which parts of a design are coupled to which assumptions.
A practical technique is to keep a mental (or literally written) list of which decisions depended on which numbers as you build the design the first time. This is the same discipline that makes back-of-the-envelope math useful in the first place: an estimate is not just a number, it is a number attached to a decision. When the constraint changes, you scan that list for anything tied to the number that moved. This turns constraint changes from a crisis into a lookup, which is far faster and far calmer than it looks from the outside.
The decision tree below shows a reasonable thought process for handling a scale-up follow-up question specifically, which is the most common type of constraint change interviewers introduce.
Responding to a 1000x scale increase
- Interviewer raises expected load by 1000x
- Identify which layer was sized to the old number
- Load balancer, web tier Note that stateless tiers scale by adding instances
- Database, cache Flag the stateful layer as the real bottleneck
- Database Propose partitioning or re-deriving the partition key
- Cache Re-check whether the cache hit ratio assumption still holds
- Identify which layer was sized to the old number
Other common follow-up patterns deserve their own prepared instincts, not because the answer should be memorized, but because recognizing the pattern quickly frees up thinking time for the substance. A constraint change that introduces a second geographic region usually raises questions about replication topology, whether to move from leader-follower replication to multi-leader replication, and what consistency guarantee the business can tolerate across regions given the added replication lag. A constraint change that introduces a stricter durability requirement, such as a system that previously tolerated losing a few seconds of data now needing a stronger durability target, usually pushes toward synchronous replication or a write-ahead log flushed before acknowledging a write, and the narration should name the added latency this introduces.
A constraint change that adds a new read pattern, such as a feed system that previously showed only chronological posts now needing a ranked feed, often shifts the right choice between fan-out on write and fan-out on read, since a ranking computed at read time behaves differently from one precomputed at write time. Naming the specific mechanism that the new constraint stresses, rather than redesigning everything, is what separates a candidate who understands the system from one who is pattern-matching against a memorized diagram.
It helps to treat a follow-up question as a request for a delta, not a request for a new design. A clean way to frame the answer verbally is: Given this new constraint, here is what stays the same, here is what changes, and here is why.
This three-part framing keeps the interviewer oriented and prevents you from accidentally re-explaining the whole system when only one piece needed revision. It also demonstrates a trait senior engineers need daily: the ability to absorb a changed requirement in a production system without a full rewrite.
Sometimes the interviewer's question reveals that an earlier assumption was simply wrong, not just scaled up. For example, if the candidate assumed reads vastly outnumber writes, and the interviewer reveals that writes are actually just as frequent, this is not a scale change but a workload shape change, and it can invalidate a caching strategy outright. A cache-aside pattern that works well for a read-heavy workload may fight constantly with invalidation under a write-heavy workload. Recognizing the difference between same shape of problem, bigger numbers
and different shape of problem entirely
is a skill that improves with exposure to many practice problems, and it is worth deliberately practicing both kinds of follow-up separately.
A frequent panic response is to defend the original design too hard, treating the follow-up question as an attack rather than new information. This reads poorly to interviewers, who are specifically listening for how a candidate reacts to a changed requirement, since this happens constantly in real engineering work. The better reflex is to treat the new number as a gift: it tells you exactly where to focus the remaining interview time. Saying that's a useful constraint, it tells me the database is now the bottleneck, not the web tier
reframes the follow-up as helpful information rather than a challenge to survive.
Interviewers typically expect a quick recalculation on the spot when a constraint changes, so rehearsing the accompanying arithmetic is essential preparation. If daily active users jump by a large multiplier, peak QPS does not scale proportionally when the new scale also changes usage patterns, such as a product that becomes more global and therefore has a flatter traffic curve across time zones instead of a sharp local peak. Saying this out loud, and adjusting the peak-to-average ratio used in the original estimate, shows that the back-of-the-envelope calculation technique is a living tool, not a one-time ritual performed only at the start of the interview.
The goal across all of this is to treat every follow-up as additional information about what the interviewer cares about, since interviewers generally push on the part of the design they think is weakest or most interesting. A constraint change aimed at the database layer is a strong hint that the database decisions deserve more depth. A constraint change aimed at failure scenarios is a hint to bring in timeout and retry reasoning sooner. Listening for what the question is really testing, beneath its literal wording, turns the follow-up phase from a defensive scramble into the most informative part of the interview.
Defending a Design Under Pushback and Admitting Limits
Pushback in an interview sounds like direct disagreement: Why wouldn't you just use a single strongly consistent database for everything?
or Isn't that cache going to serve stale data and cause a bug?
The instinct many candidates have is to treat this as a signal that they made a mistake, and to immediately abandon the original choice in favor of whatever the interviewer seems to be hinting at. This instinct is usually wrong. Interviewers push on strong designs just as often as weak ones, specifically to see whether the candidate can hold a well-reasoned position, update it when genuinely warranted, and tell the difference between the two in real time.
The first response to pushback should never be to immediately concede or to immediately double down. Both are reflexive rather than reasoned. The useful middle path is to restate the trade-off that justified the original choice, and ask whether the pushback is pointing at a new piece of information or just inviting you to re-justify the existing one. For example: I chose eventual consistency here because the requirement was that likes on a post can lag by a few seconds without harming the user experience. If that requirement is actually stricter than I assumed, I'd reconsider. Is that assumption wrong?
This response does three things at once: it shows you remember why you made the choice, it invites the interviewer to supply missing information rather than guessing at their intent, and it signals that you would genuinely change the design if the underlying requirement changed.
A table of planned responses to the kinds of pushback interviewers commonly raise makes the muscle memory concrete ahead of time, though the specific requirement cited will depend on the actual interview.
| Pushback heard | What it is usually testing | How to respond |
|---|---|---|
| Why not just use strong consistency everywhere? | Whether you understand the latency and availability cost of strong consistency | Name the specific latency or availability cost, and tie your choice to which parts of the system can tolerate staleness |
| That single leader looks like a bottleneck. | Whether you can identify when a component needs partitioning | Agree if true, and propose a concrete partition key, or explain why write volume doesn't justify it yet |
| What happens if that cache goes down? | Whether you've reasoned about graceful degradation | Describe the fallback path to the database and the latency or load cost of that fallback |
| Isn't that retry going to make the outage worse? | Whether you understand retry storms and backoff | Name exponential backoff, jitter, and a bounded retry budget as the mitigation |
Admitting uncertainty is the skill most candidates avoid out of fear it will look like a weakness, when in practice the opposite is true. An interviewer who asks a detailed question about a system you have genuinely never studied, such as the internals of a specific consensus protocol, is not expecting a flawless answer. A fabricated, confident-sounding answer that is wrong is far more damaging than a clear admission paired with a reasoned guess. The useful phrase is something like: I haven't worked directly with the internals of that protocol, but based on what I know about quorums and leader election generally, here's how I'd reason about it.
This signals honesty without surrendering the conversation, and it lets you demonstrate transferable reasoning even in an area of partial knowledge.
Bluffing is identifiable to an experienced interviewer almost every time, because a bluffed answer tends to use the right vocabulary without the right structure. A candidate who does not actually understand quorum-based replication might say we'd use a quorum to make sure it's consistent,
but cannot answer a follow-up about what happens when a quorum cannot be reached, because the follow-up requires a model of the mechanism, not just its name. Interviewers often ask exactly this kind of second-level question specifically to test whether the first answer was understood or recited. Building genuine understanding of the mechanisms in earlier chapters, rather than memorizing their names, is the real defense against this failure mode.
Admitting a limit does not mean giving up on the question. A strong pattern is admit, then reason, then offer a next step: I don't know the exact algorithm Cassandra uses for hinted handoff, but I know the general goal is to let a write succeed even when a replica is temporarily unreachable, and reconcile it later. I'd want to check the specific mechanism before committing to it in a real design.
This response is honest about the gap, demonstrates the surrounding conceptual understanding, and signals the professional habit of verifying details before shipping them, which is itself a trait interviewers value.
A different kind of pushback challenges not your knowledge but your priorities, such as why are you spending so much time on this corner of the system when the bottleneck seems to be elsewhere?
This is a legitimate critique of time allocation, and the right response is to recognize it rather than defend the time already spent. Saying that's fair, let me move to the bottleneck you're pointing at
and actually redirecting is far stronger than continuing to justify the original path out of a reluctance to appear inconsistent. Flexibility under this kind of pushback is itself part of what is being evaluated.
Key takeaway: The difference between defending and bluffing
Defending a design means restating the requirement or number that justified it, and updating the decision only when that underlying fact changes. Bluffing means producing confident language without a mechanism behind it. An interviewer tests for the difference by asking a second-level follow-up, so the real defense is understanding the mechanism well enough to survive one more question past the first answer.
It is also useful to rehearse the specific vocabulary of limits, since a vague I'm not sure
lands worse than a precise one. Compare I'm not sure about that
to I know the general trade-off between availability and consistency from the CAP theorem, but I haven't worked with the specific tuning parameters of this particular database, so I'd want to verify the default consistency level before relying on it.
The second version narrows the gap to something specific and shows the surrounding knowledge is solid even where one detail is missing. Practicing this phrasing ahead of time, for the areas you already know are weaker, removes the panic of improvising it under pressure.
Finally, pushback sometimes exposes a genuine flaw rather than an area to defend or admit ignorance about, and recognizing that moment matters as much as the other two. If an interviewer points out that a chosen partition key would create a severe hotspot under a realistic access pattern you had not considered, the correct response is a clean concession followed immediately by a better proposal: You're right, that key would hotspot around popular accounts. I'd add a secondary dimension to the key, like a hash suffix, to spread that load.
A candidate who treats every piece of pushback as something to be defended, even when it is correct, reads as rigid rather than rigorous. The interview is not measuring whether the first design was perfect. It is measuring whether the reasoning stays honest and adaptive all the way through, which is the exact behavior a senior engineer needs in a real design review where the stakes are higher than a whiteboard.
Conclusion
Picture a system design interview that opens with a one-line prompt: design a notification system.
No mention of scale, no mention of channels, no mention of what happens when a push provider goes down. A candidate who has memorized a notification architecture from a blog post will draw boxes immediately: a queue, a worker pool, a database, maybe a fan-out layer. A candidate who has internalized the reasoning in this book will instead ask how many notifications per second, what happens when a user has no device registered, and whether a duplicate notification is a minor annoyance or a real problem. The second candidate is not smarter. They simply have a different starting point, and that starting point is the real subject of everything covered so far.
The ten chapters before this one were not really about load balancers, caches, replicas, queues, or circuit breakers as isolated topics. Each of those building blocks was a vehicle for a smaller set of underlying questions: what does this system actually need to guarantee, what does it cost in money, latency, or complexity to guarantee it, and what happens the moment that guarantee breaks. A functional requirement and a nonfunctional requirement set the destination. A back-of-the-envelope calculation tells you whether you are solving a problem of scale or a problem of correctness. Everything from consistent hashing to quorum reads is a specific, named answer to a trade-off that was already visible in the requirements, if you knew to look for it.
This is why the book insisted, chapter after chapter, on naming the cost of every choice rather than presenting components as free upgrades. A cache is not simply faster reads.
It is faster reads purchased with staleness, with an invalidation strategy you now owe an explanation for, and with a new failure mode when the cache itself goes down. Leader-follower replication is not simply more available.
It is availability and read scale purchased with replication lag and a harder story about what a follower should return during a network partition. Nothing in distributed systems is a strict improvement. Everything is a trade, and the engineers who defend their designs well are the ones who can name the other side of the trade without being asked twice.
None of this is abstract loyalty to nuance for its own sake. It has a direct, practical payoff in the room where it matters most: an interview, a design review, or a production incident review where someone asks why the system behaved the way it did under load. An engineer who can say we chose eventual consistency here because the business told us a five-second-old follower count is acceptable, but we chose strong consistency on the ledger because a wrong balance is not acceptable at any staleness
has demonstrated judgment that no memorized diagram can fake. That sentence is the entire book compressed into two clauses, and it is worth returning to every time a design starts to feel like a checklist instead of a decision.
Carrying These Principles Into Your Next Design
Start every future design, whether in an interview or at a whiteboard with real teammates, by refusing to draw anything until the requirements and the numbers exist on the page. This is not a ritual to perform for an interviewer. It is the only reliable way to know whether you are building for ten thousand users or ten million, whether a write happens once a minute or ten thousand times a second, and whether the business can tolerate a stale read or needs every read to reflect the latest write. The functional requirement versus nonfunctional requirement split from the opening chapter is the cheapest investment you will ever make in a design, because every later component choice either satisfies those requirements or quietly fails to.
Treat the back-of-the-envelope calculation as a filter, not decoration. A system that needs to handle 200 QPS does not need the same architecture as one that needs 200,000 QPS, and the gap between those two numbers often eliminates entire categories of solution before you draw a single box. If storage estimates show the dataset fits comfortably on one well-provisioned machine for the next two years, an elaborate partitioning scheme is not senior-level thinking, it is over-engineering dressed up as rigor. The discipline of estimating first means your architecture is a response to a number, not a reflex copied from the last system you happened to read about.
When you reach the part of a design that touches traffic distribution and data placement, resist the urge to add a load balancer, a cache, or a CDN simply because they appear in most reference architectures. Ask what specific problem each one solves in this design. A cache earns its place when read traffic to a given piece of data vastly outweighs writes to it and when the business can tolerate the staleness window you are about to introduce. A CDN earns its place when content is large, relatively static, and consumed by users spread across geography. If you cannot state the specific problem a component solves in this particular system, leave it off the diagram until you can.
Apply the same discipline to replication and partitioning, and keep their two separate purposes straight. Reach for leader-follower replication when the problem is read capacity or availability during a single node's failure. Reach for partitioning when the problem is write throughput or data volume that no single machine can hold. Conflating the two is one of the most common mistakes under interview pressure, and it shows up as candidates partitioning a dataset that would have fit on one machine for years, simply because partitioning sounds more advanced. Advanced is not the goal. Matching the mechanism to the actual bottleneck is the goal.
Use the table below as a quick gut check whenever you are deciding how much consistency a given piece of data actually needs, since that decision drives so much of what follows it.
| Data Type | Consistency Need | Typical Choice |
|---|---|---|
| Account balance in a payments ledger | Must never be wrong | Strong consistency, quorum writes |
| Social media like count | Can lag by seconds | Eventual consistency, cache-aside |
| Inventory count at checkout | Must not oversell | Strong consistency on the decrement path |
| User's own recent post in a feed | Should appear to them immediately | Read-your-writes guarantee |
| Follower count shown publicly | Can lag by minutes | Eventual consistency, periodic recompute |
Once you have chosen a consistency model, carry that choice through to every retry and queue decision downstream, because those two layers are where a sound consistency story quietly breaks in production. A retry against a non-idempotent payment endpoint can turn one eventual-consistency tolerance into a double charge, which is a correctness failure dressed up as a reliability feature. Before adding a retry anywhere, ask whether the operation is safe to repeat, and if it is not, add an idempotency key before you add the retry, never after. This single habit prevents a large share of the self-inflicted outages that distributed systems suffer, where the system did not fail so much as the client's well-meaning recovery logic amplified a small failure into a large one.
Keep exponential backoff and jitter attached to every retry you design, and keep a bounded retry budget attached to every client, not just a single request. A client that retries three times with backoff looks safe in isolation, but ten thousand clients retrying simultaneously after a brief outage can recreate the exact overload condition that caused the outage in the first place, a pattern known as a retry storm. State this cost out loud whenever you propose a retry strategy, because naming it is what separates a design that sounds safe from a design that actually is safe.
When a design calls for a queue, remember that a queue does not remove the hard problems in a system, it relocates them. Decoupling a producer from a consumer buys you the ability to absorb bursts and to let consumers fail independently of producers, which is genuinely valuable. But it hands you new obligations: deciding what happens to a message that keeps failing (a dead-letter queue), deciding whether at-least-once delivery or something closer to exactly-once delivery is required, and deciding how a backlog is monitored and drained when a consumer falls behind. A queue without a documented answer to all three of those is an unfinished design, not a decoupled one.
Before finalizing any design, walk through the checklist below as a final pass, since it captures the habits most likely to separate a senior-level answer from an intermediate one.
Final Pass Before You Present a Design
- Stated the functional and nonfunctional requirements out loud before drawing anything
- Ran a back-of-the-envelope calculation to justify scale-related choices
- Named the specific problem each component (cache, load balancer, CDN) solves
- Distinguished whether replication or partitioning is the actual need
- Chose a consistency model per data type, not one model for the whole system
- Checked that every retry is paired with idempotency, backoff, and a bounded budget
- Described what the user sees when a dependency is slow or down
- Identified at least one rejected alternative and why it was rejected
Treat failure as the condition you are designing for, not an edge case you are designing around. Every dependency in a real system will eventually be slow, unavailable, or returning wrong data, and the question that matters is not whether this happens but what the rest of the system does when it does. A timeout without a plan for what happens after it fires is incomplete. A circuit breaker that trips without a fallback response leaves a user staring at a blank screen instead of a degraded but functional one. Graceful degradation means deciding, in advance, which features are allowed to disappear under stress and which ones must survive no matter what, and that decision belongs in the requirements conversation, not as an afterthought during an outage.
Resist the temptation to treat observability as operational detail that belongs to a separate team. A design that cannot be measured cannot be defended, and an engineer who proposes a circuit breaker without proposing the metric that trips it has only half a design. When you state a target such as an SLO or a durability target, immediately name how you would know, in production, whether the system is meeting it. This habit, carried from the early chapters on requirements through to the chapters on failure, is what makes a design feel complete rather than merely plausible.
In an interview specifically, remember that the strongest signal you give is not the final diagram but the sequence of decisions that produced it. State your assumptions before making a choice, not after. When the interviewer changes a constraint, show explicitly which earlier decision that change invalidates, rather than quietly redrawing the diagram as if nothing happened. This is trade-off narration in practice, and it is the single habit most responsible for separating a candidate who passes a senior-level interview from one who does not, more than any specific technology choice.
Consider a realistic example: midway through designing a ride-sharing dispatch system, an interviewer adds the constraint that the system must now work across three continents with regional outages tolerated independently. A weak answer re-draws the whole system from scratch. A strong answer says something like this.
This changes our replication strategy from a single leader to a multi-leader setup per region, which means we now accept the possibility of conflicting writes across regions and need a conflict resolution rule. It also means our earlier assumption of strong consistency on driver location updates has to loosen to eventual consistency across regions, though we can keep it strong within a region. I would confirm with the business whether a driver briefly appearing in two regions' views is acceptable for the few seconds it takes to reconcile.
That response does three things worth copying into your own practice: it names exactly which earlier decision the new constraint breaks, it states the new cost in plain terms, and it checks that cost against what the business can tolerate instead of assuming an answer. None of it requires exotic vocabulary. It requires the habit of treating every design decision as provisional, attached to a stated assumption, and ready to be revisited the moment that assumption changes. That habit, more than any single pattern covered in this book, is what will let you handle a system design question you have never seen before, built from building blocks you now understand well enough to recombine on demand.
The decision tree below summarizes a compact version of the questioning sequence worth running at the start of nearly any new design prompt, before a single box goes on the page.
A Starting Sequence for Any New Design Prompt
- Have requirements and scale been stated?
- No Ask for functional, nonfunctional requirements, and rough scale
- Run a back-of-the-envelope calculation
- Is the bottleneck reads, writes, or storage?
- Reads Consider caching, CDN, or read replicas
- Choose a consistency model per data type
- Writes Consider partitioning or write-optimized storage
- Choose a consistency model per data type
- Storage Consider partitioning or tiered storage
- Choose a consistency model per data type
- Reads Consider caching, CDN, or read replicas
- Is the bottleneck reads, writes, or storage?
- Run a back-of-the-envelope calculation
- Yes Run a back-of-the-envelope calculation
- Is the bottleneck reads, writes, or storage?
- Reads Consider caching, CDN, or read replicas
- Choose a consistency model per data type
- Writes Consider partitioning or write-optimized storage
- Choose a consistency model per data type
- Storage Consider partitioning or tiered storage
- Choose a consistency model per data type
- Reads Consider caching, CDN, or read replicas
- Is the bottleneck reads, writes, or storage?
- No Ask for functional, nonfunctional requirements, and rough scale
None of the mechanisms in this book are owed loyalty for their own sake. A circuit breaker, a write-ahead log, a bulkhead, a consistent-hashing ring, all of them exist because someone, somewhere, faced a specific failure and built a specific answer to it. The landmark papers behind many of these ideas are worth reading directly once the concepts feel familiar, because they show the reasoning in its original, unsimplified form rather than the tidied-up version that tends to circulate in interview preparation material.[1] Reading the original argument behind the CAP theorem, for instance, reveals nuance that a slide deck summary usually flattens away.
The same is true of the paper that formalized how eventual consistency could be reasoned about rigorously rather than treated as a vague promise.[2] Going back to primary sources like these is not required to pass an interview, but it builds the kind of depth that lets you answer a follow-up question you were not expecting, because you understand the reasoning well enough to extend it rather than recall it.
Keep in mind that none of the specific numbers, patterns, or defaults in this book are permanent truths about distributed systems. They are the current best answers given current hardware, current network costs, and current failure rates, and all three of those have changed before and will change again. What does not change is the discipline underneath them: state the requirement, estimate the scale, name the trade-off, and say out loud what breaks and what the user sees when it does. That discipline is what you are actually carrying forward, not a fixed catalog of components.
The next time a vague prompt lands in front of you, whether in an interview room or in a real design document at work, the instinct to reach for a familiar diagram will still be there. Let it pass. Ask what the system needs to guarantee, estimate what that guarantee will cost at the scale in question, and build outward from there, one named trade-off at a time. That is the entire method this book has been teaching, and it travels to any system you have not yet seen, which is the only kind of system a real senior engineer is ever actually asked to design.
Further Reading and Resources
The ideas in this book rest on a small set of papers and books that shaped how the industry thinks about distributed systems. None of them require a theoretical computer science background to follow, and most were written by engineers who had just shipped something large and wanted to explain why it worked the way it did. Reading even two or three of these in full will deepen every chapter you have just read, because this book compresses decades of hard-won lessons into a few hundred pages. The sources below are grouped by the problem they address, not by publication date, so you can go straight to the one that matches whatever you are currently stuck on.
On replication and consistency, the paper that most shaped modern thinking is Google's description of its globally distributed database, which explains how synchronized clocks allow strong consistency across continents.[1] For a broader map of the consistency spectrum discussed in the chapter on consistency and availability trade-offs, Eric Brewer's own retrospective on the CAP theorem is more careful and more hedged than most summaries of it, and corrects several popular misreadings of his original conjecture.[2] Werner Vogels' essay on eventual consistency, written while Amazon was operating Dynamo at scale, remains one of the clearest plain-language explanations of why a system might choose to be wrong briefly on purpose.[3]
For partitioning and hotspots, the original Dynamo paper is still the clearest account of consistent hashing in production, including how Amazon handled uneven load across nodes.[4] Google's Bigtable paper, published a year earlier, shows the partitioning and compaction ideas that later influenced HBase and Cassandra, and is worth reading even if you never touch those systems directly.[5] If partition keys and rebalancing from the replication and partitioning chapter felt abstract, these two papers ground the same ideas in real engineering decisions made under real constraints.
On queues, retries, and idempotency, there is less canonical literature and more operational writing, because this territory was mostly worked out in postmortems rather than papers. Amazon's internal engineering culture of writing public postmortems after major outages has produced some of the most honest accounts available of what happens when retries are not bounded.[6] Reading a handful of these postmortems from any major cloud provider, even informally, teaches the exponential backoff and jitter lessons from the queues chapter faster than any textbook, because the stakes and the timeline are both concrete and real.
For failure handling and graceful degradation, Michael Nygard's book on production-grade software introduced the circuit breaker pattern to a wide engineering audience and remains the clearest treatment of bulkheads and timeouts in practical terms.[7] Netflix's engineering blog from the era when it built and later retired its Hystrix library documents, in granular detail, what it actually took to run circuit breakers across hundreds of services at once. That history matters because it shows graceful degradation is not a single pattern you add once, but a discipline that has to be maintained as a system grows.
Two broader books deserve a place on the same shelf as the papers above. Martin Kleppmann's survey of data-intensive systems ties together replication, partitioning, and consistency into one coherent framework and is the closest thing this field has to a standard reference.[8] Google's site reliability engineering book, written collectively by its production engineering teams, is the best available source on error budgets and observability as organizational practices rather than just technical ones.[9] Both books reward slow reading over a single pass through an interview preparation cycle.
Beyond books and papers, three categories of ongoing resources are worth building into a regular habit rather than a one-time reading list.
- Engineering blogs from companies operating at scale. Posts from large tech companies' engineering teams describe real architecture decisions, including the ones that did not work, far more candidly than most conference talks.
- Public postmortems and incident reports. Major cloud providers and large platforms publish detailed incident reports after significant outages. These documents show failure handling and timeout reasoning applied under real pressure, not in a hypothetical.
- Conference talks from USENIX, re:Invent, and QCon. Recorded talks from engineers who built the systems described in this book often explain the reasoning behind a design choice more informally, and more usefully, than the polished paper that followed it.
None of these sources need to be read cover to cover before an interview. A more realistic plan is to pick one paper or chapter tied to whatever topic felt weakest while working through this book, read it slowly once, and then try to re-explain its central idea out loud without notes. That single exercise, repeated across a handful of sources rather than rushed across all of them, builds the kind of fluency that no amount of passive reading produces on its own.
Glossary
The terms below carry a specific, narrow meaning throughout this book, even when everyday usage is looser. Treat this list as a quick lookup rather than a tutorial. Each entry gives the working definition used across earlier chapters, so you can refresh your memory before an interview without rereading full sections. Where two terms are often confused, both appear here so you can compare them directly.
Functional requirement describes what a system must do, such as letting a user post a comment or retrieve an order history. Nonfunctional requirement describes how well it must do it, covering latency, availability, durability, and scale. SLO (service level objective) is a measurable target, such as 99.9 percent of requests completing under 200 milliseconds. Durability target states how certain you must be that committed data survives failures, independent of how fast it can be read back. Back-of-the-envelope calculation is a rough estimate of traffic, storage, or bandwidth built from a few known numbers, used to decide which constraints actually matter before picking components.
QPS (queries per second) measures request rate against a service or database. Latency numbers every engineer should know refers to the rough orders of magnitude for memory access, disk seeks, and network round trips, used to sanity-check design decisions without measuring anything.[1] These numbers matter less for their exact values than for the relative gaps between them, since a cache hit that beats a cross-region call by three orders of magnitude tells you where to focus optimization effort.
Consistent hashing maps both servers and keys onto a ring so that adding or removing a server only reshuffles a small fraction of keys, rather than all of them.[2] A virtual node is a technique where one physical server is represented by many points on that ring, smoothing out load that would otherwise land unevenly across a small number of real machines.
Layer 4 load balancing routes traffic based on IP address and port, without inspecting the content of the request, making it fast but blind to application-level details. Layer 7 load balancing reads the actual HTTP request, allowing routing decisions based on path, header, or cookie, at the cost of more processing per request. A health check is a periodic probe a load balancer sends to each backend to decide whether it should keep receiving traffic, pulling a failing node out of rotation before users notice.
Cache-aside pattern has the application check the cache first, fall back to the database on a miss, and write the result back into the cache. Write-through pattern writes to the cache and the database together on every update, keeping them in sync at the cost of extra write latency. Write-back pattern writes to the cache immediately and flushes to the database later, which is faster but risks data loss if the cache fails before the flush. TTL (time to live) is the duration a cached value is considered valid before it expires automatically. CDN (content delivery network) is a geographically distributed set of caching servers that serve content from a location close to the requesting user. Thundering herd describes many clients simultaneously requesting the same expired cache entry, overwhelming the origin all at once.
Staleness is the gap between what a reader sees and what is currently true, an unavoidable cost of any caching or replication strategy that favors speed over freshness. Leader-follower replication designates one node to accept writes and propagates changes to read-only followers. Multi-leader replication allows writes to be accepted at more than one node, trading simplicity for the possibility of conflicting updates that must later be reconciled.
Partition key is the field used to decide which shard a given row or record lives on. A hotspot is a partition that receives disproportionate traffic because its key was chosen poorly, such as sharding by date when all writes land on today's partition. Scatter-gather is a query pattern that fans a request out to every partition and merges the results, useful but costly compared to a query that can be routed to a single shard.
CAP theorem states that during a network partition, a distributed system must choose between consistency and availability, since it cannot guarantee both.[3] Eventual consistency guarantees that replicas converge to the same value given enough time without new writes, but offers no promise about what a read returns in the meantime. Strong consistency guarantees that every read reflects the most recent committed write, typically at the cost of higher latency or reduced availability during a partition.
Quorum is a rule requiring a minimum number of nodes to acknowledge a read or write before it counts as successful, used to balance consistency against availability. A lease is a time-bound grant of exclusive rights, such as leadership, that expires automatically if not renewed, protecting against a stuck or crashed holder blocking the system forever. PACELC extends CAP by noting that even without a partition, a system must still choose between latency and consistency during normal operation.[4]
Idempotency key is a unique identifier attached to a request so that processing it more than once has the same effect as processing it exactly once, which is essential for safe retries. Exponential backoff increases the wait time between retry attempts geometrically, preventing a failing service from being hit with retries at the same rate that caused the failure. Jitter adds randomness to that backoff delay so that many clients retrying at once do not all collide on the same retry moment. A dead-letter queue holds messages that repeatedly fail processing, so a single poison message cannot block the rest of the queue indefinitely.
At-least-once delivery guarantees a message is delivered one or more times, which can produce duplicates that the consumer must handle. Exactly-once delivery is the stronger (and harder to achieve) guarantee that a message is processed a single time, usually approximated in practice by combining at-least-once delivery with idempotency rather than achieved outright.
Circuit breaker is a pattern that stops sending requests to a dependency after it starts failing, giving it room to recover instead of piling on load. Bulkhead isolates resources, such as thread pools or connection pools, so that one overloaded dependency cannot exhaust capacity needed by unrelated parts of the system. Graceful degradation means serving a reduced but still useful response, such as showing cached recommendations instead of a personalized feed, rather than failing the whole request. Timeout is a maximum wait time for a response, after which the caller gives up rather than blocking indefinitely.
Observability is the practice of instrumenting a system with logs, metrics, and traces so its internal state can be inferred from its external outputs during an incident. Distributed tracing follows a single request as it moves across multiple services, showing where time is spent and where failures occur. Error budget is the amount of unreliability an SLO permits over a given window, used to decide whether a team should ship new features or focus on stability.
Fan-out on write pushes new content to every follower's feed at the moment it is created, making reads fast but writes expensive for accounts with many followers. Fan-out on read assembles a feed at request time by pulling from each followed account, making writes cheap but reads slower. Write-ahead log is a durability mechanism where every change is recorded sequentially to disk before being applied, so the system can replay it after a crash.[5] Trade-off narration is the practice, central to a strong interview answer, of stating the alternatives considered, the one chosen, and the specific cost accepted in return.
Notes
Introduction
Chapter 1: Clarifying Requirements and Scope
- 1. Google, "Site Reliability Engineering," O'Reilly Media, 2016
Chapter 2: Estimates and Back-of-the-Envelope Math
- 1. Jeff Dean, "Designs, Lessons and Advice from Building Large Distributed Systems," LADIS Conference, 2009
Chapter 3: Load Balancing and Traffic Distribution
Chapter 4: Caching Strategies and Content Delivery
Chapter 5: Database Replication and Partitioning
Chapter 6: Consistency, Availability, and Trade-Offs
- 1. Eric Brewer, "Towards Robust Distributed Systems," Symposium on Principles of Distributed Computing, 2000
- 2. Seth Gilbert and Nancy Lynch, "Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services," ACM SIGACT News, 2002
- 3. Maurice Herlihy and Jeannette Wing, "Linearizability: A Correctness Condition for Concurrent Objects," ACM Transactions on Programming Languages and Systems, 1990
- 4. Mustaque Ahamad and others, "Causal Memory: Definitions, Implementation, and Programming," Distributed Computing, 1995
- 5. Leslie Lamport, "The Part-Time Parliament," ACM Transactions on Computer Systems, 1998
- 6. Diego Ongaro and John Ousterhout, "In Search of an Understandable Consensus Algorithm," USENIX Annual Technical Conference, 2014
- 2. Seth Gilbert and Nancy Lynch, "Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services," ACM SIGACT News, 2002
- 7. Giuseppe DeCandia and others, "Dynamo: Amazon's Highly Available Key-Value Store," Symposium on Operating Systems Principles, 2007
- 8. Daniel Abadi, "Consistency Tradeoffs in Modern Distributed Database System Design," IEEE Computer, 2012
Chapter 7: Queues, Retries, and Idempotency
Chapter 8: Reasoning About Failure and Degradation
Conclusion
Further Reading and Resources
- 1. James C. Corbett and others, Spanner: Google's Globally Distributed Database, USENIX Symposium on Operating Systems Design and Implementation, 2012
- 2. Eric Brewer, "CAP Twelve Years Later: How the Rules Have Changed," Computer, 2012
- 3. Werner Vogels, "Eventually Consistent," Communications of the ACM, 2009
- 4. Giuseppe DeCandia and others, "Dynamo: Amazon's Highly Available Key-value Store," ACM Symposium on Operating Systems Principles, 2007
- 5. Fay Chang and others, "Bigtable: A Distributed Storage System for Structured Data," USENIX Symposium on Operating Systems Design and Implementation, 2006
- 6. Amazon Web Services, "Summary of the Amazon DynamoDB Service Disruption," Amazon Web Services, 2015
- 7. Michael Nygard, "Release It," Pragmatic Bookshelf, 2007
- 8. Martin Kleppmann, "Designing Data-Intensive Applications," O'Reilly Media, 2017
- 9. Betsy Beyer and others, "Site Reliability Engineering," O'Reilly Media, 2016
Glossary
- 1. Jeff Dean, "Numbers Everyone Should Know," Stanford CS 295 course materials, 2009
- 2. David Karger and others, "Consistent Hashing and Random Trees," Symposium on Theory of Computing, 1997
- 3. Eric Brewer, "Towards Robust Distributed Systems," ACM Symposium on Principles of Distributed Computing, 2000
- 4. Daniel Abadi, "Consistency Tradeoffs in Modern Distributed Database System Design," Computer, 2012
- 5. Jim Gray, "Notes on Data Base Operating Systems," Operating Systems: An Advanced Course, 1978
Use this book
- Read the whole book here for free. No account needed.
- Link to the book or any chapter, and share the links anywhere.
- Share or host the complete PDF or EPUB file for free, including with a class or a library. Keep it unchanged, with the credit inside.
- Quote up to 500 words with credit and a link back to the book.
- Do not post the text on other websites, sell it, change it, translate it, or present it as your own.
Suggested credit: From "Designing Under Pressure", a free book by TailoredRead: https://tailoredread.com/book/designing-under-pressure-first-principles-systems-b325331883de. See the terms of service for the full rules.
Want a book written for you?
Tell us your goals and background, and get a complete book on this subject or any other, tailored to you.