Fast Enough · Nº 31

Fast Enough

A field guide to making systems fast — honestly

Every slow page is slow for the same reason: the answer lives too far from the question. This guide teaches you the real latency numbers, the honest way to measure (the tail, not the average), how to find the one bottleneck that matters instead of guessing — and how to wield caching, the highest-leverage speedup in software, without being destroyed by its one unforgiving tax: invalidation.

Module 01 Speed is a location problem

Ask an engineer why a page is slow and you will usually get an answer about code: the algorithm is inefficient, the language is slow, the server is underpowered. Occasionally that is true. Far more often the page is slow because the data it needs lives somewhere far away and had to be fetched — sometimes dozens of times — and no amount of clever code shortens a round trip. Performance work, done well, is mostly geography.

This module installs the frame the rest of the course instantiates. You will learn the five canonical rungs of the latency hierarchy and their real numbers, convert them to a human scale where the gaps stop being abstract, and cost out a real request — the wine-cellar product's value my cellar page — hop by hop. By the end you will be able to look at a slow endpoint and say, before profiling anything, where the time is likely to be. Then module 2 will teach you to never trust that instinct without evidence.

Every performance question is a distance question

Start with an uncomfortable observation: modern CPUs execute on the order of a billion instructions per second per core, and almost no web request is limited by instructions. The request spends its life waiting — for a value to arrive from memory, for a page to come off an SSD, for a database on the next rack to answer, for an API in another region to reply. The dominant term in nearly every latency budget you will ever debug is distance: how far the answer had to travel to reach the question.

This is not a metaphor. Signal propagation, serialization, queueing at each hop, and the software layers that mediate every boundary all scale with how many boundaries you cross and how far apart they are. A function call and a database query look identical in your source code — price = lookup(wine) either way — and differ by five orders of magnitude in cost. Abstractions are designed to make that invisible. Performance work is the discipline of making it visible again.

The load-bearing idea

Nearly every performance win ever shipped is the same move: shorten the distance between a question and its answer. Caching, indexes, batching, replicas, CDNs, materialized views, colocation — all of them are that one move wearing different clothes. Learn the distances and the rest of this course is variations on a theme.

The corollary is what makes the frame useful in a design review. When someone proposes a fix, ask what distance it shortens. A faster serializer shortens no distance. A bigger instance shortens no distance. Moving a lookup out of a loop removes forty round trips, and that is a distance answer — which is why it will beat both.

The hierarchy and the canonical numbers

Five rungs cover essentially every cost you will meet in application work. Memorize them; practitioners who know these cold reason about designs in seconds that otherwise take an afternoon of benchmarking.

The latency hierarchy: five rungs from CPU cache to cross-region round trip, each shown with its real duration and its human-time equivalent at a one-billion-times scaleRungReal timeRelative bar (log scale)Human timeL1 CPU cache1 ns1 secondMain memory (RAM)100 ns~1.7 minutesSSD random read100 µs~1 daySame-datacenter round trip500 µs~6 daysCross-region round trip100 ms~3 yearsthe chasm — everything below here leaves the machineBars are logarithmic: rungs span roughly a few× to 1000×, step to step.Human time = real time × 1,000,000,000. One nanosecond becomes one second.
Figure 1.1 — The latency hierarchy, scaled to human time. Five rungs spanning eight orders of magnitude: L1 cache 1 ns, RAM 100 ns, SSD random read 100 µs, same-datacenter round trip 500 µs, cross-region round trip 100 ms. Multiplied by one billion, they become 1 second, 1.7 minutes, a day, six days, and three years. Every later figure in this guide cites these numbers.

Three properties of this ladder do most of the analytical work. First, the gaps are multiplicative, not additive: they are large but uneven — commonly 100× to 1000×, though the SSD-to-same-datacenter step is only a few× — so mixing rungs in a single budget usually means the lowest rung present dominates. Second, the biggest single jump is the one marked in the figure — leaving the machine. Inside the box you are trading nanoseconds and microseconds; the moment a request crosses to another host you are in a different unit of measurement. Third, the bottom rung is bounded by physics, not engineering. A round trip from Virginia to Frankfurt is about 4,000 miles each way as the crow flies (real fiber routes are longer); light in fiber covers roughly 125 miles per millisecond, so 100 ms is not a vendor's inefficiency you can escalate to. No one is optimizing that away.

Note

These are order-of-magnitude figures, and that is the point. Your SSD might do 80 µs and your same-region hop 300 µs. Nothing in this course's reasoning changes, because the conclusions turn on which rung something is on, not on its precise value. Distrust anyone who needs three significant figures to decide where to put a cache.

Scaled to human time

The numbers above are correct and, for most people, useless — human intuition does not distinguish a nanosecond from a microsecond, so both read as instant. The fix is a fixed multiplier. Multiply every duration by 10⁹, so that one nanosecond becomes one second, and the ladder becomes something you can feel in your body.

An L1 cache hit is one second — a beat. A memory read is 1.7 minutes, long enough to make coffee. An SSD read is a full day. A round trip to the database on the next rack is six days — a working week gone, to fetch one number. And a cross-region API call is three years. Not three years of work; three years of waiting, doing nothing else, for one answer.

Worked example — the value-my-cellar request

The wine-cellar product lets a collector see what their cellar is worth. The page loads a 40-bottle cellar, then, for each bottle, looks up its current market price from the price table. That is 1 query for the cellar plus 40 for the prices: 41 same-datacenter round trips, issued one after another. In real time that is 41 × 500 µs ≈ 20 ms of pure travel, plus per-query database work — measured, the block takes about 2.1 seconds. In human time, it is 35 weeks of waiting in a queue, six days at a time, for forty-one numbers that could have arrived together in a single six-day trip.

Swimlane sequence of the value-my-cellar page load showing browser, application server, database, and price API hops, with the forty-one repeated database round trips drawn as a loopBrowserApp serverDatabasePrice APIGET /cellar/value · internet hopload cellar · 1 trip · ~6 days×41price for bottle none row · ~6 days eachprice feed refresh · cross-region · ~3 yearsrendered pageHuman time at ×10⁹: same-DC round trip ≈ 6 days; the 41-trip loop ≈ 35 weeks; the cross-region feed call ≈ 3 years.
Figure 1.2 — One page load, counted in hops. The value-my-cellar request makes one same-datacenter trip to load the cellar, then repeats a price lookup once per bottle — 41 trips in total, roughly 35 weeks of human time — before rendering. The dashed cross-region call to the external price feed is a single hop costing three human years on its own. Counting hops, not lines of code, tells you where the time went.

Notice what the human scale makes obvious that the millisecond scale hides. In real numbers, 41 sequential same-datacenter trips is "20 milliseconds of network," which sounds like nothing and gets waved through code review. At human scale it is nearly a year of standing in line — and the fix (fetch all forty-one rows in one trip) collapses it to a week. That is a 40× reduction in the dominant term, available for one line of code, and it is invisible unless you are counting hops.

The two ways performance work goes wrong

Two failure modes account for most wasted performance effort, and both are attractive precisely because they feel like decisive action.

The first is optimizing before measuring. An engineer forms a hypothesis from reading the code — this JSON serialization looks expensive — and rewrites it. The rewrite is real work, often clever work, and it changes the p99 by nothing at all, because serialization was 3% of the budget and the other 90% was sitting in Figure 1.2's loop. The wine-cellar team lived this: a two-week effort to replace the templating engine shipped, benchmarked beautifully in isolation, and moved the page's p99 from 4.2 s to 4.1 s. The work was not wrong; it was aimed at the wrong component, and no amount of quality in the execution can fix an aiming error.

The second is caching without an invalidation plan. A cache is the highest-leverage tool in this guide, which is exactly why it gets deployed reflexively — SETEX price:{id} 3600, latency drops, ticket closed. Six weeks later a customer disputes a valuation because the page showed a price that changed last Tuesday, and nobody can say why, because nobody wrote down what invalidates the entry or how stale it is allowed to be. The performance win was real. It was paid for with a correctness liability that was never priced.

When it misleads

Both anti-patterns produce local evidence of success. The rewritten serializer benchmarks 8× faster; the cache shows a 60% hit rate on a dashboard. Local wins are not the metric. The only metric that counts is the one your user experiences end to end — which module 2 will teach you to measure honestly, and which is why measurement comes before every fix in this course.

The method that replaces both runs in five steps, and the remaining modules are its expansion: measure the tail (module 2) → locate the bottleneck (module 2) → shorten the distance (modules 3, 5, 6) → state the staleness you bought (module 4) → verify at the tail, with module 7 covering the case where distance is not the problem at all and capacity is. Every step is falsifiable. That is the whole point: performance stops being a matter of opinion the moment you commit to numbers before and after.

Cross-reference

The discipline here will feel familiar from adversarial legal work: you do not argue from the theory of the case you find most elegant, you argue from the record. A profile is the record. An intuition about the slow serializer is a theory without one, and in performance work as in litigation, the theory that survives contact with evidence is rarely the one you started with.

Module 02 Measure the tail, then find the bottleneck

Module 1 gave you a map of where time can go. This module gives you the instruments, and it comes before every fix in this course for a reason: the two most expensive mistakes in performance work are both measurement failures wearing engineering clothes. Reporting an average and declaring victory is one. Guessing at the bottleneck and optimizing the guess is the other.

You will learn to separate throughput from latency, to read a distribution instead of a mean, to compute how badly fan-out amplifies a rare slow call, and to use a profile the way a prosecutor uses evidence — to convict a specific component rather than to confirm a hunch. The wine-cellar page runs through the whole procedure: the dashboard says 180 ms, the users say the page is broken, and both are telling the truth.

Throughput is not latency

Two numbers get called "performance" and they measure different things. Throughput is how much work a system completes per unit time — requests per second, rows per minute. Latency is how long one unit of work takes from question to answer. They are the width and the length of the pipe, and a pipe can be very wide and very long at the same time.

The wine-cellar dashboard during the incident showed a healthy 340 requests per second with no errors, and the team spent two days believing the system was fine. It was fine, in the sense the dashboard meant: it was accepting and completing everything asked of it. It was also taking 4.2 seconds to answer a collector who wanted to know what their wine was worth. Throughput was never the complaint.

Two pipes carrying identical volume: a wide short pipe and a narrow long pipe, illustrating that equal throughput can accompany very different latencySystem A — wide and short340 req/s · 40 ms per requestSystem B — narrow and long340 req/s · 4,200 ms per requestSame throughput. The dashboard cannot tell them apart.The user rides the length, not the width.Concurrency is what reconciles them: B sustains the same rate by having ~1,400 requests in flight at once.
Figure 2.1 — Width vs. length. Two systems moving identical volume per second, one answering in 40 ms and one in 4.2 s. Throughput dashboards report them as equivalent; users experience only the length. The reconciliation is concurrency — the slow system keeps far more requests in flight to sustain the same rate, which is also why it is closer to the cliff of module 7.
Note

The relationship is not mystical: for a stable system, average concurrency ≈ throughput × average latency. That is why the slow system in Figure 2.1 must hold roughly 1,400 requests in flight to sustain 340/s at 4.2 s each, and why fixing latency usually reduces resource pressure as a side effect — fewer things waiting means fewer connections, threads, and buffers occupied.

Why averages lie

Latency distributions are not bell curves. They are right-skewed: a dense cluster of fast responses and a long thin tail stretching to the right, produced by retries, cache misses, garbage-collection pauses, contended locks, and unlucky queueing. The arithmetic mean of such a distribution sits well to the left of the pain — it is dragged toward the dense cluster where most requests live, and the tail is too thin to move it much.

The wine-cellar numbers make this concrete. Mean response time: 180 ms. p99: 4.2 s. Both are correct measurements of the same traffic. The mean was on the dashboard the team looked at every morning; the p99 was the reason support tickets used the word "broken."

A right-skewed latency histogram with the arithmetic mean marked well left of the long tail, and p50, p95, and p99 marked as vertical rules far to its right0response time →4.5 scountp50 ≈ 95 msmean = 180 msp95 ≈ 1.6 sp99 = 4.2 sThe mean sits inside the comfortable cluster. Everything a user complains about is to the right of it.
Figure 2.2 — The percentile distribution. The wine-cellar page's real distribution: a dense cluster of fast responses, a mean of 180 ms sitting just right of the median, and a long tail reaching 4.2 s at p99 (marked in danger red). The mean is not wrong — it is unrepresentative, because a thin tail cannot pull it far, and a thin tail is exactly what ruins an experience.

Read the percentiles precisely, because the loose reading causes real errors. p99 is the value below which 99% of requests complete — it is not "the worst case" and it is not an average of the slow ones. One percent of requests are worse than p99, and some of them are far worse. When you fix a tail, you move a percentile; you do not eliminate a maximum.

Cross-reference — Guide Nº 10

The general statistical argument — why summary statistics mislead, when a mean is the wrong estimator, and how to reason about distributions rather than points — belongs to the experimentation and statistics guide. Everything here is that argument applied to one specific distribution shape you will meet constantly. If percentiles feel unfamiliar as statistics rather than as dashboard widgets, read Nº 10 alongside this module.

The tail is the experience

The obvious objection to caring about p99 is arithmetic: it is one request in a hundred. Two amplifiers make that reasoning wrong.

The first is fan-out amplification. A modern page rarely makes one backend call. The value-my-cellar view makes about twenty — cellar, prices, valuation, user profile, feature flags, recommendations, and so on — and the page is not done until the slowest one returns. If each call independently has a 1% chance of landing in its own tail, the chance the page avoids all twenty is 0.99²⁰ ≈ 0.82, so roughly 18% of page loads wait on at least one tail outlier. A one-in-a-hundred backend event became a one-in-six user event, purely through composition.

Curve showing the probability of encountering at least one p99 outlier rising with the number of backend calls per page, with twenty calls annotated at eighteen percentbackend calls per page (n)1102035500%20%40%value-my-cellar page: n = 201 − 0.99²⁰ ≈ 18%P(at least one p99 outlier) = 1 − 0.99ⁿ
Figure 2.3 — Fan-out amplification. As a page's backend call count rises, the probability that at least one call lands in its own slow 1% climbs steeply: 10% at n = 10, about 18% at n = 20, nearly 40% at n = 50. A "rare" backend tail becomes a common user experience through composition alone, which is why microservice fan-out makes tail discipline non-optional.

The second amplifier is usage skew, and it is the one that turns a technical problem into a commercial one. Requests are not distributed evenly across users. The collector with 40 bottles loads the valuation page occasionally; the collector with 900 bottles loads it several times a day, triggers the heaviest queries, and is on your most expensive plan. If a user issues 200 requests in a session, their chance of meeting at least one p99 outlier is 1 − 0.99²⁰⁰ ≈ 87%. Your tail is not sampled uniformly by your user base — it is sampled disproportionately by the users you can least afford to lose.

The load-bearing idea

The tail is not an edge case; it is the experience, because fan-out and usage skew both convert a rare per-call event into a frequent per-user one. Optimize for p99 (and watch p99.9 for the pathological cases) and the mean takes care of itself. Optimize for the mean and you will systematically improve the requests nobody was complaining about.

Profile, don't guess

Once you know the page is slow at the tail, the next question is where, and the answer must come from a measurement, not from reading the code. Intuition nominates suspects; a profile convicts. The distinction matters because intuition is systematically biased toward code that looks expensive — nested loops, big string operations, regular expressions — while actual budgets are usually consumed by things that look like nothing at all, like a property access that lazily triggers a query.

Two forms of evidence cover most application work. A profile attributes time to code paths, telling you which functions or spans own the budget. A distributed trace or query log attributes time to hops, which per module 1 is usually where it went. Use both; the query log frequently settles the case on its own by revealing a statement count nobody expected.

Then apply the constraint that saves you from wasted sprints. Amdahl intuition: speeding up a component buys you at most that component's share of total time, no matter how good the optimization is. A component that is 5% of the budget caps out at a 5% improvement even if you make it infinitely fast. So the profile's real job is not to find something slow — plenty of things are slow — but to find the component that is the budget.

Stacked horizontal bars comparing a request time budget before optimization, after a tenfold speedup of a seven percent component, and after a twofold speedup of a sixty percent componentBaseline — 4,200 ms totalN+1 queries · 50%aggregation · 43%rest 7%Fix A — make the 7% component 10× faster3,935 ms — 6.3% betterFix B — make the 50% component 10× faster (batch the queries)2,300 ms — 45% betterAmdahl's ceiling: any fix is capped by its component's share. A perfect fix to a 7% component is a 7% win.
Figure 2.4 — Amdahl's ceiling. The same request budget under two interventions. A tenfold speedup of a 7% component returns 6.3%; a tenfold speedup of the 50% component returns 45%. The quality of the optimization is irrelevant next to the size of the thing optimized — which is why the profile comes before the plan.
Worked example — the value-my-cellar profile

Query log and trace for one p99 request, 40-bottle cellar: 41 sequential queries ≈ 2,100 ms (one for the cellar, one per bottle for price); valuation aggregation ≈ 1,800 ms (a single query scanning the price-history table); template rendering 90 ms; serialization 40 ms; everything else under 100 ms combined. Two components own 93% of the budget. The templating rewrite the team had shipped the previous sprint was aimed at the 90 ms — Amdahl capped it at 2%, and it delivered exactly that.

The archetypal hidden cost: N+1

The N+1 query is the most common serious performance bug in application code, and it is worth studying as a specimen because its mechanism generalizes. The ORM loads the cellar in one query. Then the rendering loop touches bottle.price, which the ORM has helpfully declared a lazily loaded relation, and each touch issues its own round trip. One query for the list, N for the items: 41 trips for 40 bottles.

What makes it archetypal is that the code is good. There is no nested loop, no obvious waste, nothing a reviewer would flag. The cost is not in the code that is written; it is in the boundary the code silently crosses, once per row. Module 1's lesson lands here: the hierarchy's costs hide behind abstractions designed to make a six-day round trip look like a property access.

Side-by-side waterfalls: forty-one sequential query round trips forming a long staircase, versus a single joined query returning in one tripN+1 — 41 sequential trips ≈ 2,100 msSELECT cellarSELECT price WHERE wine_id = ? (once per bottle)⋮ 30 more, each waiting on the lasttotal ≈ 2,100 ms · ~35 weeks human timeBatched — 2 trips ≈ 55 msSELECT cellarSELECT pricesWHERE wine_id IN (...)total ≈ 55 ms · ~1.5 weeks human timeSame data, same database, same rows returned. The only change is the number of boundary crossings.
Figure 2.5 — The N+1 waterfall. Left: 41 queries issued one after another, each waiting on the previous, forming the staircase signature you can recognize in any trace viewer. Right: the same data fetched with a join or a batched IN query — two trips, roughly 55 ms. The rows returned are identical; only the number of round trips changed.

The fix is a join or a batch fetch — IN (...), or the ORM's eager-loading directive — and the deeper practice is to make query counts visible: log statements per request in development, and alert when a request issues more than a threshold. Guide Nº 06 covers the SQL mechanics and the ORM directives; what belongs here is the recognition skill. The signature is unmistakable once you have seen it: many fast queries rather than one slow one. A team that has not learned that signature reliably misdiagnoses N+1 as "the database is slow" and buys a bigger database, which changes nothing, because each individual query was already fast.

When it misleads

N+1 is invisible at development scale. Your local cellar has three bottles, the loop makes four queries, the page renders in 30 ms and nobody notices. Production has collectors with 900 bottles. Any performance property that scales with a data dimension your development environment does not have is effectively untested — which is a good argument for seeding development data at realistic magnitudes.

Module 03 The universal lever

You have a map of distances and a method for finding which one is eating your budget. Now the lever. Caching is the highest-leverage speedup in software, not because it is clever — it is the least clever technique in this course — but because it is module 1's single move in its purest form: keep the answer closer than its source.

This module defines a cache precisely, walks the hit and miss paths so you know what a miss actually costs, and teaches the one piece of arithmetic that decides whether a cache is worth having. It ends by naming the two ways teams get caching wrong on entry, before module 4 takes on the harder problem of keeping cached answers honest.

The answer, kept closer

A cache is a copy of an answer stored nearer to the question than the answer's source of truth. That is the entire definition, and it is worth being pedantic about each part. It is a copy — the source still exists and can still change, which is where all the trouble in module 4 comes from. It stores an answer, not data as such: a rendered page, a query result, a computed valuation, a fetched price. And nearer means nearer on module 1's hierarchy — fewer rungs down, fewer boundaries crossed.

Once you hold that definition, you start seeing it everywhere, because it is the same idea implemented at every scale of the machine. The CPU's L1 cache keeps recently used memory a nanosecond away instead of a hundred. The operating system's page cache keeps file contents in RAM instead of on the SSD. A database's buffer pool keeps hot pages in memory instead of re-reading them. An application cache keeps a computed result in the process instead of recomputing it. A CDN keeps a response in a city near your user instead of a continent away. Different vendors, different words, one idea repeated across eight orders of magnitude.

The load-bearing idea

Caching is the highest-leverage speedup available because it does not make anything faster. It changes where the answer lives, and per module 1, location is what dominates the budget. This is also why it works when nothing else does: you cannot make a cross-region round trip faster, but you can arrange not to make it.

Cross-reference — your own domain

You already run caches professionally. A memo summarizing the controlling authority on a recurring question is a cache: the answer, kept nearer than the source, so you do not re-read the statute each time. And you already know its failure mode — the memo that was right in 2019 and is quietly wrong now because the regulation moved and nobody re-derived it. That is a stale cache entry with no invalidation rule, and module 4 is about exactly this problem in machines.

Hit, miss, and the anatomy of a lookup

The dominant pattern is cache-aside, and it is worth walking precisely because the miss path contains a fact people forget. Check the cache. On a hit, return the cached answer and you are done. On a miss, go to the source, get the answer, write it into the cache, and return it.

Look at what a miss cost you. You paid the cache lookup and the full source fetch and the cache write. A miss is strictly more expensive than not having a cache at all. It is usually only slightly more — a millisecond against eighty — but the sign matters: a cache is not free insurance that sometimes pays off. It is a bet that most lookups will hit, and if they do not, you have added latency, memory, an operational dependency, and a correctness liability in exchange for nothing.

Flowchart of the cache-aside pattern showing a short accented hit path returning immediately and a longer miss path that travels to the source, populates the cache, and returnsRequestCache lookupprice:{wine_id}HIT — 1 ms · return immediatelyAnswerMISSSource of truthprice feed / database — 80 mspopulate cachereturnMiss cost = lookup + source fetch + cache write ≈ 81 ms — strictly more than having no cache at all.A cache is a bet that hits dominate. If they do not, you paid for the bet and lost it.
Figure 3.1 — Cache-aside, hit and miss. The hit path is one short hop and returns immediately at ~1 ms. The miss path pays the lookup, the full 80 ms source fetch, and the cache write before returning — slightly more than the uncached path. Because misses cost more than no cache, the hit rate is not a nice-to-have metric; it is the thing that decides whether the cache was a good idea.
Note

Cache-aside is not the only pattern — read-through, write-through, and write-behind put the cache inline with the source and shift who owns population — but cache-aside is the default in application code because it keeps the cache optional: if the cache is down, every request is a miss and the system is slow rather than broken. That degradation property is worth more than the modest elegance the inline patterns buy.

Hit rate is the number

One formula prices a cache. With hit rate h, hit cost t_hit, and miss cost t_miss, the effective latency is h·t_hit + (1−h)·t_miss.

Run it on the wine-cellar price feed, where a cached price is 1 ms away and the source is 80 ms. At h = 0.50: 0.5(1) + 0.5(80) = 40.5 ms. At h = 0.90: 0.9(1) + 0.1(80) = 8.9 ms. At h = 0.99: 0.99(1) + 0.01(80) = 1.8 ms.

Now notice the shape those numbers make, because it defeats most people's intuition. Going from 50% to 90% — forty percentage points — saves 31.6 ms. Going from 90% to 99% — nine percentage points, less than a quarter as much movement — saves 7.1 ms, and more importantly cuts what remains by a factor of five. The reason is that effective latency at high hit rates is almost entirely made of misses, so what matters is not the hit rate but the miss rate, and miss rate is what moves multiplicatively. 90% → 95% halves the misses. 90% → 99% removes nine-tenths of them. 99% → 99.9% removes nine-tenths again.

Curve of effective latency against cache hit rate for a one millisecond hit and eighty millisecond miss, showing a steep final descent between ninety and one hundred percenthit rate h →0%50%90%100%80 ms40 ms050% → 40.5 ms90% → 8.9 ms99% → 1.8 mseffective latency = h·1 ms + (1−h)·80 ms90 → 95%: half the misses removed90 → 99%: nine-tenths of the misses removed
Figure 3.2 — What hit rate buys. Effective latency against hit rate for the wine-cellar price feed (1 ms hit, 80 ms miss). The line is straight in hit rate but the meaningful gains cluster at the top, because at high hit rates the remaining latency is made almost entirely of misses — so think in miss rate: 90 → 99% removes nine-tenths of what is left, while 90 → 95% removes half.

Two practical consequences. First, when you estimate a cache's value, estimate the reuse rate of the data — how many times an answer is read between changes — because that is what sets the achievable hit rate. The wine-cellar price for a given wine is read roughly 200 times between feed updates, which supports a hit rate well above 99%. Second, resist reporting hit rate as a single global number; it averages across keys with wildly different reuse and hides the ones dragging the tail down.

The tax you sign up for

Everything above is the case for caching, and it is strong. Now the honest accounting, because a cache is not free and two entry mistakes are so common they deserve names.

The first is the cargo-cult cache: an in-memory store added because caching is what serious systems have, with no measurement of reuse beforehand. The wine-cellar team's first instinct in the incident was exactly this — they put Redis in front of the cellar-contents query, deployed it, and measured a 12% hit rate. The reason is obvious in hindsight: cellar contents are per-user and change whenever the collector adds a bottle, so there was almost no reuse to capture. Effective latency went from 45 ms to 0.12(1) + 0.88(46) ≈ 40.6 ms — a 10% improvement bought with a new production dependency, a new failure mode, and a new source of stale data. Not a catastrophe. Just a bad trade, made without the arithmetic that would have predicted it.

The second is indiscriminate caching: caching everything, on the theory that more caching is more speed. It fails on two fronts simultaneously. Memory is finite, so caching low-value entries evicts high-value ones and your hit rate on the keys that mattered falls. And every cached item is one more copy that can be wrong, so correctness surface grows linearly with the number of things you cache while the latency benefit concentrates in a few hot keys. The rule of thumb worth internalizing: cache the few things that are read many times, not the many things that are read once.

When it misleads

Hit rate is a seductive dashboard metric because it always looks like progress. A 95% hit rate on a key that is read twice an hour saves nothing; a 70% hit rate on a key read a thousand times a second is transformative. Weight hit rate by traffic and by the miss cost it avoids, or you will find yourself proudly optimizing a cache that no meaningful number of requests touches.

What you will notice about both anti-patterns is that neither is fixed by knowing more about caching. Both are fixed by module 2: measure the reuse, price the fix, then act. A cache is a fix like any other, and it earns its place with a number.

Module 04 The hard half: invalidation

Module 3 sold you the lever. This module presents the bill. The moment you keep a second copy of an answer, you own a consistency problem, and it does not go away because you are not thinking about it — it simply becomes a problem your users discover on your behalf.

The framing that makes this tractable is to stop treating staleness as a failure and start treating it as a budget. Every cache has a worst-case staleness. The only question is whether that number was chosen deliberately and stated out loud, or whether it is an emergent property of code nobody has read since it shipped. You will learn the two mechanisms for controlling it, when to combine them, how the cache stampede works mechanically, and how to write an invalidation rule you could defend in an incident review.

Two copies, one truth

Before the cache, there was one answer and it was current by construction. After the cache, there are two, and the copy diverges from the source the instant the source changes. Staleness — the gap between the cached copy and current truth — is not a risk that might materialize. It is a schedule. Given a cache and a source that changes, staleness will occur; your design decides only how much and for how long.

This reframing is the whole module. "Is the cache correct?" is a question with no useful answer. "What is this cache's worst-case staleness, and is that acceptable for this data?" is answerable, testable, and reviewable. A price cache with a one-hour bound is fine for a browsing page and unacceptable for a checkout total. Same mechanism, same code, different answer, because the tolerance belongs to the data and the use, not to the cache.

Cross-reference — the disclosure frame

You already have a rigorous instinct for this from legal practice. A memo stating a conclusion without stating what it relies on and as of when is not merely unhelpful; it is misleading in a way that can create liability. The wine-cellar valuation page displaying yesterday's prices as a current valuation, with no as-of qualifier, is the same defect in software: a collector who lists a bottle at a price your page implied was current has been given a stale representation as a fresh one. "Prices as of 09:00 UTC" costs one line of UI and converts a misrepresentation risk into a disclosed limitation. Staleness you disclose is a product decision. Staleness you conceal is a claim you cannot support.

The load-bearing idea

Every cache has a worst-case staleness. The choice is never whether to have one — it is whether the number was chosen and stated, or discovered by a customer. Design reviews should ask for the number the way they ask for an error budget.

TTL: staleness by appointment

A TTL (time-to-live) attaches an expiry to a cache entry: after N seconds, the entry is discarded and the next read goes to the source. Its great virtue is that it requires no cooperation from anything else in your system. Write paths do not need to know the cache exists. Bulk imports, admin consoles, database migrations, and the script someone runs manually on a Tuesday all work correctly, because the entry expires on its own schedule regardless of how the source changed.

Its great vice is that the staleness bound is exactly the TTL and not one second less. A 300-second TTL means that for up to five minutes after a change, every reader sees the old answer, and there is nothing you can do about it from the write side. TTL does not detect change; it forgets on a timer.

Choose N from the data's tolerance, not from habit. The value 300 appears in an enormous amount of production code for no better reason than that it appeared in a tutorial. Ask instead: how fast does this data actually move, and how much staleness does the reader's decision tolerate? Wine market prices update once daily, so a one-hour TTL is generous rather than risky. Feature flags need to take effect in seconds, so their TTL is measured in seconds — or they use explicit invalidation. An exchange rate on a payment page might tolerate 30 seconds. Each of those is a defensible number with a sentence of reasoning behind it, which is more than "300" ever has.

And remember module 3's arithmetic prices the choice. Shortening a TTL raises the miss rate, which raises effective latency and load on the source. If a key is read 200 times an hour and you cut its TTL from one hour to one minute, you have gone from one miss per hour to sixty, multiplying source load by 60× for a freshness improvement that may buy nothing the reader can perceive. TTL selection is a latency/freshness trade with real numbers on both sides, not a safety dial you turn down when nervous.

Explicit invalidation: precision and its failure modes

Explicit invalidation deletes or updates the cached copy at the moment the source changes. Its staleness bound is approximately zero — bounded only by propagation delay — which makes it the right mechanism whenever freshness genuinely matters.

It buys that precision with an obligation that is easy to underestimate: every path that can modify the source must fire the invalidation. Not most paths. Every path. The API handler you wrote does. The nightly bulk import someone added last quarter does not. The admin console's manual override does not. The data-fix script run during an incident certainly does not. And the failure is silent — no error, no alert, just one entry serving an answer that stopped being true, indefinitely, until something else happens to displace it.

This is the origin of "stale data no one can explain," a category of bug notable for how much time it consumes. Nobody can explain it because the invalidation code is present and correct and is simply never reached by the write path that actually changed the data.

The robust design is not to choose. It is belt and suspenders: explicit invalidation for freshness, plus a generous TTL as a backstop. The explicit path handles the normal case in milliseconds; the TTL bounds the damage of every write path you forgot, every deploy that dropped an event, every network partition that ate a message. Your worst-case staleness becomes the TTL, your typical staleness becomes near-zero, and the failure mode degrades from "wrong forever" to "wrong for at most an hour." That is a very large difference in an incident review.

Timeline of one cached value showing it written and served, then going stale when the source changes, with three branching outcomes: TTL expiry, explicit invalidation, and a stampede of simultaneous missesOne cached value: price:chateau-margaux-2016timecachedhits — answer is currentsource changeshits — answer is now staleA · TTL expiryTTL fires · one miss · refillstaleness bounded by the TTL — here ~200 s of wrong answers, then correctB · Explicit invalidationdelete fires at the write · one miss · refillstaleness ≈ 0 — provided every write path fires it; the forgotten path serves stale foreverC · Stampedeexpiry under load → 800 simultaneous missesthe source absorbs every miss at once — the cache's failure mode, not the source's
Figure 4.1 — One cached value, three fates. The same entry is written, served correctly, then goes stale when the source changes. It can end three ways: TTL expiry bounds the staleness at N and pays one miss; explicit invalidation drops staleness to near-zero but only on write paths that fire it; or the entry expires under load and every concurrent reader misses at once, converting a cache expiry into a load spike on the source.
DimensionTTLExplicit invalidation
Worst-case stalenessExactly the TTLNear zero — on paths that fire it; unbounded on paths that do not
Implementation burdenOne parameter at write timeEvery write path must know about the cache and its key
Characteristic failurePredictably stale for N; synchronized expiry causes stampedesThe forgotten write path — silently stale forever, with no signal
FitsSlow-moving or tolerant data; anything with many uncontrolled writersData with a small number of well-known write paths and low staleness tolerance
Best practiceBoth: explicit invalidation for freshness, generous TTL as the backstop that bounds every miss you did not anticipate

The stampede

Caching does not only mitigate failure modes; it introduces one. The cache stampede — also called a dogpile or thundering herd — happens when a hot entry expires while under concurrent load. Every request that arrives in the window between the expiry and the first refill finds a miss, and every one of them independently goes to the source. The cache was absorbing that traffic a millisecond ago. Now the source receives all of it at once.

Worked example — the shared-cellar incident

The wine-cellar product lets collectors publish a read-only view of a cellar. One collector's shared cellar was featured in a newsletter, and traffic to that single URL went from a trickle to roughly 800 concurrent requests. Its rendered summary was cached with a 10-minute TTL, and the cache was doing its job — one recomputation every ten minutes, everything else a hit at 1 ms.

Then the TTL fired. In the ~1.8 s it took the first request to recompute the valuation aggregation, roughly 800 more requests arrived, missed, and each started its own recomputation. Postgres received 800 concurrent instances of the heaviest query in the product. Connection pool exhausted, queue depth climbed, every other endpoint's latency followed, and the site was effectively down for four minutes — at which point the recomputations completed, one of them populated the cache, and everything recovered instantly, leaving a graph that looked like a lightning strike and no obvious cause.

Note what did not happen: nothing changed about traffic volume, the database, or the query. The trigger was a cache entry expiring. Under the same load with no cache at all, the database would have been overwhelmed continuously and the team would have known why on day one. The cache's success is what made the failure sudden and confusing.

Three mitigations, and mature systems use all three because they address different parts of the mechanism.

Jittered TTLs. Set the expiry to N ± a random spread — say 600 s ± 60 s — instead of exactly N. This does nothing for a single hot key, but it is essential at scale: when a thousand keys are populated together during a deploy or a cache flush, an identical TTL makes them all expire in the same second, producing a synchronized stampede across the entire key space. Jitter spreads that over a minute.

Single-flight. When a key misses, the first request acquires a per-key lock and recomputes; concurrent requests for the same key wait for that result instead of starting their own. Source load on a miss becomes exactly one query regardless of concurrency. This is the direct fix for the single-hot-key case: 800 requests, one recomputation.

Stale-while-revalidate. Keep the expired value and serve it while a background refresh runs. Users never pay the miss at all — the entry becomes stale-but-served for the refresh duration, then updates. It is the best user experience of the three and it explicitly extends the staleness bound to include the refresh window, which is a trade you must make deliberately and state.

Two sequence panels: on the left, many clients missing simultaneously send identical queries to the database; on the right, single-flight allows one recompute while the others coalesce onto its resultWithout single-flightclient 1client 2client 3⋮ ×800client 800cacheEXPIREDPostgres×800800 identical 1.8 s aggregations · pool exhausted · 4 minutes of downtimeWith single-flightclient 1client 2⋮ ×799 waitclient 800per-key lockone leaderDB ×1799 coalesce onto the leader's resultone 1.8 s query · everyone answered · no incidentStale-while-revalidate goes further: the 800 are served the expired value immediately while the leader refreshes inthe background — nobody waits 1.8 s, at the cost of extending the staleness bound by the refresh duration.Jittered TTLs address the other case: many keys expiring in the same second after a deploy or flush.
Figure 4.2 — The stampede, and single-flight. Left: a hot key expires under load and 800 concurrent readers each launch the same 1.8 s aggregation, exhausting the connection pool. Right: single-flight elects one leader to recompute while the rest coalesce onto its result — one query, everyone served. Stale-while-revalidate improves it further by serving the expired value during the refresh; jittered TTLs prevent the many-keys-expiring-together variant.
When it misleads

The stampede is routinely misdiagnosed as a capacity problem, because the symptom is a database under overwhelming load and the obvious remedy is a bigger database. Read the signature instead: the spike is instantaneous, the queries are identical, request volume did not change, and the whole thing resolves the moment one query completes. That is a synchronized-expiry pattern, and provisioning your way out of it means buying capacity for 800 copies of a query that should have run once.

Module 05 Caching, layer by layer

You know what a cache is and what it costs to keep honest. The remaining question is where to put it, and it turns out you rarely have one cache — you have five, arranged as a descent, and each is a different rung of module 1's hierarchy.

This module traces one request through all of them, gives each layer's characteristic invalidation mechanism and its blast radius when it serves the wrong thing, and then assembles the wine-cellar fix end to end with the measured numbers. The blast-radius framing matters more than it may first appear: the same mistake at different layers ranges from "one user sees a stale number for a minute" to "every user sees another user's private data," and the second is not a performance incident at all.

One request, five caches

Consider a single GET for a cellar summary and follow it down. The browser may answer it from its own HTTP cache with no network at all — zero on the hierarchy, the fastest possible outcome because the request never happens. Failing that, it reaches a CDN edge node in the user's city: one short network hop instead of a continental one. Failing that, it arrives at your application, which may answer from an in-process cache — a memory read. Failing that, the application asks a shared in-memory store: one same-datacenter round trip. Failing that, the database, which may still answer from its buffer pool or a precomputed view rather than scanning. And failing all of it, the full cost is paid: disk, computation, and the whole distance back.

Each layer is a rung, each hit short-circuits everything below it, and a full miss pays the entire depth. That structure is why placement is a real decision rather than a detail — moving a cache one layer up can save more than any amount of tuning within a layer.

A vertical stack of five cache layers from browser down to database, with a hit arrow short-circuiting back up at each level annotated with latency saved, and a dashed full-miss path running the entire depthRequest1 · Browser cacheCache-Control · per user · no network at allhit → 0 mssaves everything2 · CDN edgeshared across users · one short hophit → ~20 mssaves origin trip3 · Application (in-process)per replica · a memory read · incoherenthit → ~0.1 mssaves all below4 · Shared in-memory storeone same-DC hop · one shared truthhit → ~1 mssaves DB work5 · Database — buffer pool, materialized viewsaves the source's work, not the distancefull missfull miss pays the whole depth: ~1,800 ms for the uncached valuationEach hit short-circuits every layer below it. Placement decides how much distance a hit removes.
Figure 5.1 — The descent. One request falling through five cache layers. A browser hit costs nothing because the request never leaves the machine; each lower hit saves progressively less; a full miss pays the entire depth — about 1,800 ms for the wine-cellar valuation. The higher the layer that answers, the more distance a hit removes, which is why placement dominates tuning.
Note

The layers differ on a second axis besides distance: audience. Browser caches serve one user. CDN caches serve everyone in a region. In-process caches serve one replica. Shared stores serve every replica. That axis decides what may safely live where, and it is the subject of the next two sections.

The browser and the edge

The two outermost layers are the highest-leverage and the most dangerous, for the same reason: they are furthest from your code and closest to your users.

The browser cache is caching infrastructure you already ship in every response, whether you have thought about it or not. Cache-Control instructs every cache between your server and your user what may be stored, by whom, and for how long. A CDN then multiplies that decision across users: one origin fetch serves thousands of readers from an edge node near each of them.

Two properties define working at these layers. First, invalidation is blunt. You cannot reach into a user's browser and delete an entry; once you have served max-age=86400, that answer is out of your control for a day. CDNs offer purge APIs, but purges are eventually consistent across a global fleet and are an operational action rather than a code path. The technique that actually works is invalidation by renaming: give the content a URL derived from its contents (app.7f3c9d.js), cache it effectively forever, and ship a new URL when the content changes. There is nothing to invalidate because the old answer is still correct for the old URL.

Second, the blast radius is maximal. A wrong entry in a shared edge cache is served to every user who requests that URL. This is where a performance decision becomes a security decision: Cache-Control: public on an authenticated page tells the CDN it may store that response and serve it to anyone requesting the same URL — which means user A's cellar contents can be served to user B. The correct discipline is that anything varying by identity is marked private (browser may store it, shared caches may not), or is cached with the identity in the key, or is not cached at those layers at all.

When it misleads

The failure is invisible in every environment where you would notice it. In development there is no CDN. In staging there is one user. A cross-user leak through a shared cache appears only under real concurrency with real distinct users, and it manifests as a support ticket saying "I saw someone else's data," which is a breach notification path, not a bug queue. Treat cacheability headers on authenticated routes as security-relevant configuration and review them accordingly.

Cross-reference — Guides Nº 14 and Nº 21

The semantics of Cache-Control, ETag, revalidation, and Vary belong to Guide Nº 14 (HTTP), and the browser's own storage behavior to Guide Nº 21. This module does not re-derive them; it uses them to make placement decisions. If you are unsure what no-cache means as distinct from no-store — they are quite different, and the confusion is common — read Nº 14 before writing headers for an authenticated route.

The application tier

Inside your own process, two options differ in a way that matters more than their similar-looking APIs suggest.

In-process memoization — a dictionary in memory, an LRU decorator — is the fastest cache that exists, because a hit is a memory read on module 1's second rung. It has no network cost, no serialization, no external dependency. Its defining property is that it is per replica: if you run eight application instances, you have eight independent caches that know nothing about each other. Invalidating means invalidating eight copies, which usually means you cannot invalidate at all and must rely on TTL.

That incoherence is fine for some data and user-visible for others. Configuration, feature-flag definitions, compiled templates, currency-code tables, and anything else that changes rarely and identically for everyone are ideal: an inconsistency window of seconds across replicas harms nothing. But cache a user-visible value in-process and the user's requests, load-balanced across replicas, will hit different copies with different ages — producing flapping: refresh the page and the total goes up, refresh again and it goes back down. This is one of the most confusing bug reports a support team can receive, because it is not reproducible from any single instance.

The shared in-memory store — Redis, Memcached, or equivalent — costs one same-datacenter round trip (~1 ms rather than ~0.1 ms) and buys one shared truth. All replicas see the same entry, invalidation is a single delete, and the cache survives a deploy that restarts every application process. For anything user-visible, that coherence is worth the extra hop, and the arithmetic from module 3 says so plainly: against an 80 ms source, the difference between a 0.1 ms and a 1 ms hit is invisible.

Worked example — choosing per layer

The wine-cellar app runs six replicas. Its feature-flag map lives in-process with a 30-second TTL: changes take up to 30 seconds to reach all six, which is acceptable and costs zero hops. Its price entries live in the shared store: a price must be identical across replicas, must survive deploys, and must be deletable in one operation when the feed updates. The first version of the system had prices in-process, and the flapping-valuation bug reports were what prompted the move.

The database's own answers

The bottom layer caches too, in two quite different ways worth separating.

The buffer pool caches pages of data in RAM so repeated reads do not touch the SSD. It is transparent, automatic, and requires nothing from you except enough memory. It also, per module 3's fourth quiz, saves the database's I/O and not your distance to the database — a buffer-pool hit still costs you a full round trip plus connection acquisition, parsing, planning, and serialization.

A materialized view is different in kind. It stores a query's answer as a table, so a query that scans a million price-history rows becomes a lookup of one precomputed row. That is unambiguously a cache by module 3's definition — a copy of an answer kept closer than its source — and it is therefore subject to everything module 4 taught. It has a refresh rule, which is its invalidation policy. It has a worst-case staleness, which is the interval between refreshes. It can serve wrong answers if its refresh is missed or fails silently.

The distinction that makes it valuable: this is the layer that fixes expensive computation, whereas the layers above fix distance. The wine-cellar valuation aggregation was 1.8 s of genuine database work — scanning price history, joining, summing — and no amount of caching further up would have made that computation cheap for the first requester of every distinct cellar. Precomputing it collapses the work itself.

Cross-reference — Guide Nº 20

Materialized views, refresh strategies (full vs. incremental, concurrent refresh), and their place in an analytics stack belong to Guide Nº 20. What belongs here is the reclassification: a materialized view is a cache, so before you add one, state its refresh trigger and its worst-case staleness exactly as module 4 requires. Teams that would never ship a Redis entry without a TTL routinely ship materialized views with no stated refresh policy, because "it's a database object" makes it feel like part of the schema rather than a second copy of an answer.

Placing the wine-cellar fixes

The running example, assembled. Recall the diagnosis from module 2: p99 of 4.2 s, composed of ~2.1 s of N+1 price lookups and ~1.8 s of valuation aggregation. Three interventions, each at the layer that matches what it fixes, each with its invalidation rule stated at the moment it is introduced.

Fix 1 — batch the query. Before any cache, eliminate the N+1: one IN (...) fetch instead of 40 sequential round trips. This is not caching at all and it comes first because it removes work rather than relocating it. 2,100 ms → 55 ms.

Fix 2 — price feed to the shared in-memory store. Prices are shared across users with roughly 200:1 reuse, making them the ideal caching candidate from module 3's arithmetic. Invalidation rule: the price-feed ingest job deletes each changed price:{wine_id} at commit; a 1-hour TTL backstops any missed delete; single-flight protects against stampede on hot wines. Worst-case staleness 1 hour, surfaced as "prices as of 09:00 UTC." 55 ms → 4 ms.

Fix 3 — aggregation to a materialized view. The 1.8 s is computation, so it moves to the layer that precomputes. Invalidation rule: refreshed when the price feed lands and on any cellar mutation for that cellar; worst-case staleness equals the gap to the next refresh, which the same as-of timestamp discloses. 1,800 ms → 12 ms.

Fix 4 — public summaries to the CDN. Shared cellar views are identical for every reader, so they belong at the edge. Invalidation rule: purge on cellar edit; s-maxage=600 with ±60 s jitter to avoid synchronized expiry; authenticated pages marked private so no personalized response is ever eligible for the shared cache. Worst-case staleness 10 minutes for public views, disclosed on the page.

The assembled wine-cellar architecture showing the three introduced caches on the request path, each tagged with its invalidation trigger and its measured contribution to the p99 reductionReaderCDN edgepublic summariespurge on edit · 600 s ±60App serverbatched price fetchIn-memory storeprice:{wine_id}feed webhook · 1 h backstopMaterialized viewcellar_valuationrefresh on feed + mutationPostgresmiss onlyp99 budget, before → afterN+1 price lookups2,100 ms → 4 ms (batch + cache)Valuation aggregation1,800 ms → 12 ms (materialized view)Everything else~300 ms → ~194 msp99: 4,200 ms → 210 ms · a 20× reduction, every cache carrying a stated invalidation rule
Figure 5.2 — The wine-cellar fix, assembled. Three caches placed at the layers matching what each fixes: the CDN for shared rendered output, the in-memory store for reused price data, the materialized view for expensive computation. Each is tagged with its invalidation trigger. The p99 falls from 4.2 s to 210 ms — and note that the largest single win, batching the N+1, involved no cache at all.
LayerWhat a hit savesInvalidation mechanismBlast radius when wrong
BrowserThe entire requestExpiry only; in practice, renaming the URLOne user, until expiry — and you cannot reach it
CDN edgeThe trip to origin and all work belowPurge API, versioned URLs, s-maxageEvery user of that URL — cross-user data exposure if personalized
In-processAll network hops belowTTL only, per replicaUsers routed to that replica; flapping across replicas
Shared in-memory storeDatabase work and query overheadDelete on write, TTL backstopAll users, uniformly — visible and diagnosable
Materialized viewThe computation itselfRefresh rule (scheduled or triggered)All users; silent if a refresh fails without alerting

Module 06 Beyond one Postgres

Caching moves an answer closer without changing where the truth lives. This module generalizes the move: sometimes the right fix is to change where the truth lives, because the store you are using is answering a question it was not built for.

That is a genuinely useful move and it is also the single most fashion-driven decision in software architecture. The discipline that separates the two is the same one module 2 installed for optimization: characterize before you choose. A store is an answer to an access pattern, and if you cannot state the access pattern in the vocabulary of section 1, you are not yet in a position to name a store — in either direction, because "we should stay on Postgres" made without that analysis is as unfounded as the migration it opposes.

Access patterns, not fashion

Five questions characterize a workload well enough to choose a store. Answer all five before any product name enters the conversation.

Read/write ratio. Is this read 1,000 times per write, or written continuously and read in batches? Read-heavy workloads have options (replicas, caches, denormalization) that write-heavy ones do not.

Lookup shape. Point lookup by exact key? Range scan over an ordered field? Multi-table join? Full analytical scan with aggregation? This is the question that most directly maps to a store family, and it is the one people most often skip.

Consistency need. Must a read reflect the write that just happened, or is a few seconds of lag invisible? Must multiple changes commit atomically, or is each independent?

Lifetime and durability. Does this data survive a process restart? A datacenter loss? Is it derivable from something else (a cache, an index, an embedding) or is it the only copy of a fact?

Size and growth. Does it fit in memory today? In three years? Does it grow with users, with time, or with both?

The load-bearing idea

A store is an answer to an access pattern. "Everyone's using it," "it's web scale," and "the team wants to learn it" answer none of the five questions — and neither does "we've always used Postgres." If the pattern is stated and the relational default still fits, that is a defended decision. If the pattern was never stated, it is a coin flip that happened to land on the status quo.

Cross-reference — Guide Nº 06

Guide Nº 06 makes the case for the relational default: schemas, constraints, transactions, and joins are enormous value for very little cost, and a single well-indexed Postgres serves the great majority of applications far longer than architecture blog posts suggest. This module extends rather than contradicts that. The relational default is right until a named pattern says otherwise, and the sections below are about recognizing those patterns and pricing the move honestly.

The specialists

Each non-relational family buys its speed by shedding a guarantee. Name the guarantee you are shedding, because it is a price, not a footnote.

Key-value and in-memory stores (Redis, DynamoDB, Memcached) are optimized for point lookup by exact key, and they are exceptionally good at it — sub-millisecond, at very high throughput, scaling horizontally because keys partition trivially. What they shed: ad-hoc query. You can retrieve session:abc123 instantly, and you cannot ask "which sessions were created in the last hour by users in Germany" at all without maintaining a second structure yourself. They fit sessions, caches, counters, rate-limit buckets, feature flags, and job state — data whose access is "give me this exact thing," typically ephemeral or derivable.

Document stores (MongoDB, DynamoDB's document mode, Postgres's own jsonb) fit workloads where a nested aggregate is the unit of work: you read the whole document, you write the whole document, and nothing else needs to join into its middle. A tasting note with nested scores and tags is a plausible document. What they shed: cross-entity integrity. Without foreign keys and joins, referential correctness moves into application code, where it is enforced by discipline rather than by the engine — and discipline degrades under deadline in a way constraints do not.

Analytical stores and warehouses (covered in Guide Nº 20) are built for scans over large volumes with aggregation, typically column-oriented so a query touching three columns of a hundred reads three columns' worth of data. What they shed: fast single-row writes and point lookups. They are the wrong home for transactional state and the right home for "valuation trends across all cellars over 24 months."

When it misleads

The shed guarantee is invisible at the moment of migration and expensive later. A team moves to a document store, everything works, and eighteen months on they discover orphaned references that no constraint prevented and no query can efficiently find. If you shed relational integrity, write down what now enforces it and how you will detect violations — a periodic reconciliation job is the minimum. "The application handles it" is not an enforcement mechanism; it is an intention.

Note

Modern Postgres blurs several of these lines: jsonb covers many document use cases with indexes and constraints intact, UNLOGGED tables handle some ephemeral workloads, and extensions cover others. That is often the right first move when a pattern starts to strain — it tests the hypothesis that the pattern is real, without paying for a second operational system. If jsonb does not relieve the pressure, you have learned something concrete about why.

Read replicas and the lag

The lowest-friction way to scale reads is a read replica: a copy of the primary database that receives its changes asynchronously and serves read-only queries. No schema change, no new query language, no shed guarantees — just more read capacity. It is genuinely the right move for read-heavy workloads, and it has one sharp edge that every team meets.

Asynchronous replication means the replica is always slightly in the past. Replication lag is usually milliseconds, occasionally seconds, and under load or during a long-running transaction on the primary it can be much worse. Most reads do not care. One category cares enormously.

Read-your-writes is the guarantee that a user who just made a change sees that change. It is not a technicality; it is the most basic expectation anyone has of software. The collector adds a 2016 Margaux to their cellar, the write commits to the primary, the browser redirects to the cellar list, that read is load-balanced to a replica that has not yet received the change, and the bottle is not there. The user adds it again. Now there are two.

Sequence diagram showing a write committing to the primary while replication is in flight, a following read hitting the replica and missing the write, and the corrective routing that sends reads after writes to the primaryUserPrimaryReplicaINSERT bottle · Margaux 2016201 committedreplication in flight — ~40 msGET /cellar — routed to replica at t+18 ms39 bottles — the new one is missingUser believes the add failed and submits again → duplicate bottleFix: reads within N seconds of a write route to the primaryThe bug is not "slow replication." It is a read placed where the guarantee it needs does not exist.
Figure 6.1 — The lag anomaly. A write commits to the primary; replication is still in flight when the user's next read is routed to a replica, so the user does not see their own change and re-submits. The fix is routing — reads following a write in the same session go to the primary — not faster replication, which only shrinks the window in which the bug occurs.

The fix is routing, not replication tuning. Reads that must see the user's own write go to the primary: either by marking those code paths explicitly, or by session pinning (a user who wrote within the last N seconds reads from the primary), or by carrying a replication position forward and waiting for a replica that has caught up to it. All three are correct; all three are choices you make deliberately per flow.

What is not a fix is making replication faster. That shrinks the window in which the bug occurs, converting a reproducible failure into an intermittent one — which is strictly worse for diagnosis and no better for the user who lands in it. Any "fix" that changes the probability of a race without eliminating it deserves that suspicion generally.

The decision map, applied

Five workload classes cover most of what an application does, and each maps to a store family. The map is not a rule — it is a starting position that a stated access pattern can override.

A grid mapping five workload classes to recommended store families, with the wine-cellar application's own five workloads placed as labeled markersWorkload classShapeStoreWine-cellar workloadPoint-lookup, ephemeralexact key · no durability needKey-value / in-memorysessions · cached pricessheds: ad-hoc queryMixed transactionaljoins · constraints · atomicityRelational (the default)cellar records · userssheds: nothing — pay for it in write throughput ceilingRead-heavy, lag-tolerantsame shape, more volumeRead replicapublic cellar browsingsheds: read-your-writes on that pathAggregate-shaped documentswhole-doc read and writeDocument store / jsonbtasting notes (in jsonb)sheds: cross-entity integrityAnalytical scanlarge scans · aggregationWarehouse / materialized viewvaluation trends · reportssheds: fast single-row writes and point lookupsRead the map from the left column, never from the right. Naming a store before stating the pattern is how fashion enters.
Figure 6.2 — The decision map. Five workload classes mapped to store families, each annotated with the guarantee it sheds, and the wine-cellar application's own five workloads placed. Note that two of the five stay relational and one uses jsonb inside the relational store — the map is not an argument for polyglot persistence, it is an argument for naming the pattern first.

Placing the wine-cellar app on the map produces a deliberately unexciting result. Sessions → in-memory store: point lookup by token, ephemeral, no query needs. Cellar records and users → relational, and they stay there: joins across owners, wines, and purchase records, constraints that must hold, and a write volume nowhere near a single instance's ceiling. Price history → relational, with the materialized view from module 5 handling the aggregation. Analytics on valuation trends → warehouse (Guide Nº 20), because scanning 24 months of history for reporting is exactly the scan pattern that does not belong on the transactional primary. Cached prices → in-memory store, per module 5.

Four of five workloads either stay in Postgres or use it plus a cache. That is the normal outcome of doing this exercise honestly, and it is worth stating plainly: the map's usual verdict is that your relational database is fine and one or two specific patterns want somewhere else to live.

Module 07 Saturation and the machine

Every module so far has treated latency as distance. This one covers the cases where it is not. A request can be slow because the resource it needs is busy serving other requests — a capacity problem, which behaves nonlinearly and surprises people — or because the machine underneath imposed a cost you did not write: a cache miss inside the process, a garbage collector stopping the world.

These are grouped together because they share a property: they are facts you inherit rather than choices you made, and they are invisible in the code. You cannot read a source file and see that a service is at 92% utilization, or that a loop is pointer-chasing, or that the runtime pauses 200 ms every forty seconds. You can only see them in measurements — which is why module 2 came first.

Vertical, horizontal, and what actually scales

Two ways to add capacity, with different limits.

Vertical scaling — a bigger machine — raises the ceiling without changing anything about your architecture. It is genuinely underrated: modern instances are enormous, an afternoon of vertical scaling frequently buys a year of headroom, and it introduces no new failure modes. Its limits are that machines have a maximum size, the price curve steepens badly at the top, and a single machine remains a single failure domain.

Horizontal scaling — more machines — multiplies capacity cheaply for stateless work and adds redundancy. Its limit is the one that matters in practice: your stateless tier can grow indefinitely and every request still converges on the same primary database, the same in-memory store, the same third-party API. Doubling app servers when the constraint is downstream buys nothing, which is module 2's Amdahl argument applied to capacity rather than to time.

And one property is easy to miss: horizontal scaling adds distance. More instances mean more coordination, more network hops between components that used to share a process, cache incoherence across replicas (module 5), and load-balancer indirection. It buys throughput, not per-request speed. A request may well be marginally slower on a horizontally scaled system than it was on one big box — which is a perfectly good trade when you need the capacity and a poor one when you were trying to fix latency.

Cross-reference — Guide Nº 16

Where code runs — instances, containers, serverless, and the operational properties of each — belongs to Guide Nº 16. What matters here is the performance consequence: every additional boundary you introduce is a boundary a request must cross, and module 1 priced those. Serverless in particular trades operational simplicity for cold starts, which are a tail-latency phenomenon and should be evaluated at p99, never at the mean.

The queueing cliff

Here is the fact that surprises people, and it is worth internalizing as a shape rather than a formula. As a resource's utilization rises, waiting time does not rise proportionally. It rises gently through the middle of the range and then goes nearly vertical as utilization approaches capacity.

The intuition, without any mathematics: a server is only idle-and-waiting when a request arrives at a moment nothing else is being served. As utilization climbs, that coincidence gets rarer, so more arrivals find the server busy and must wait behind work in progress. Near saturation, arrivals almost always find the server busy, and each new arrival waits behind a queue that itself has not had time to drain. Small increases in arrival rate then produce very large increases in wait, because the queue's drain rate has no margin left. Traffic is bursty — it arrives in clumps, not evenly — which is why the effect bites well before the theoretical 100%.

Curve of latency against utilization, flat through the mid range and bending sharply vertical near saturation, with a headroom operating band marked and the knee annotatedheadroom — the operating bandutilization (ρ) →0%50%70%90%95%latency70% — fine95% — on firethe knee: the last 25 points of utilizationcost more latency than the first 70 combinedBursty arrivals move the knee left: real traffic clumps, so a 70% average already contains 95% moments.
Figure 7.1 — The queueing cliff. Latency against utilization. The curve is nearly flat through the mid-range, then bends sharply upward near saturation: the last twenty-five points of utilization cost more latency than the first seventy combined. Headroom — the band left of the knee — is the design target, not waste, and because real traffic is bursty a 70% average already contains moments of near-saturation.

Three consequences follow, and they are the reason this shape is worth knowing.

Headroom is a design number. Running at 50–70% is not inefficiency; it is the purchase of the flat part of the curve. A finance-driven push to "use what we pay for" by running at 90% is a request to operate past the knee, where a 10% traffic increase does not cost 10% more latency — it can cost several hundred percent.

Autoscaling does not repeal the curve; it races it. Autoscaling reacts on a timescale of tens of seconds to minutes — detect, provision, boot, warm, join the pool. The cliff acts in seconds. Autoscaling is real capacity insurance for sustained load and no protection at all against a burst, which is why it must be paired with the backpressure of the next section rather than treated as a substitute for headroom.

Utilization must be measured on the scarcest resource, which is usually not CPU. It may be database connections, a thread pool, file descriptors, an in-memory store's single-threaded command loop, or a third-party rate limit. The wine-cellar stampede saturated database connections while application CPU sat under 30% — the dashboard everyone was watching showed a healthy system throughout.

Note

This is deliberately a shape and an intuition, not a derivation. Formal queueing theory gives precise results under specific arrival and service assumptions your production traffic does not satisfy. The qualitative claim — waiting time degrades nonlinearly as utilization approaches capacity, and bursty arrivals make it bite earlier — is robust, actionable, and enough to make correct decisions.

Connection pools and backpressure

A connection pool is the most common capacity control in application code, and it is routinely misunderstood as a performance optimization. It is not. It is a bound — a deliberate queue placed in front of a resource that degrades catastrophically under unbounded concurrency.

A database can serve some number of concurrent queries well. Past that, each additional concurrent query does not get its own slice of the machine; it adds contention for locks, buffer pool, and CPU scheduling, so all queries slow down together and total throughput falls. The pool prevents you from getting there: it caps concurrent connections and makes additional requests wait in your application, where waiting is cheap and observable, rather than inside the database, where it is expensive and opaque.

Which is why raising a pool from 50 to 500 because "requests are waiting for connections" is exactly backwards. The requests were waiting; now they proceed, all five hundred of them, into a database that could serve perhaps eighty concurrently. The queue did not disappear — you relocated it from a place you control to a place you do not, and you removed the protection that was working. Size the pool near what the downstream can genuinely serve, and let the wait happen in front of the pool where you can see it, time it, and shed it.

Which brings us to the humane response to genuine overload. Backpressure is refusing or bounding new work when you are full: bounded queues that reject when they fill, load shedding with an honest 429 Too Many Requests and a Retry-After, timeouts that free resources rather than letting a request wait forever for a client that has already left.

The load-bearing idea

A fast no preserves the service that a slow maybe destroys. When you are past capacity, accepting every request means every request is slow, everyone times out, retries multiply the load, and nobody is served — the same total failure, arrived at by way of everyone waiting. Shedding 20% of traffic so 80% succeeds is not a degraded outcome; it is the best available one, and it is the only one you get to choose deliberately.

Flowchart of requests entering a bounded connection pool with an explicit wait queue and a shed path returning 429, contrasted with an inset showing an oversized pool pushing the queue into the databaseRequestsBounded wait queuedepth 200 · timeout 2 squeue full → 429 + Retry-After (shed early, shed fast)Connection poolN = 60 · near DB capacityPostgresserving at ~70%Anti-pattern — the giant poolRequestsPool N = 500"nothing waits"Postgres — 500 concurrentlock and buffer contention · throughput fallsthe queue moved inside,where you cannot see or shed itA pool is not a throughput optimization. It is a bound that keeps the wait somewhere you can measure, time out, and reject from.Total in-flight work should be bounded at every tier — an unbounded queue anywhere converts overload into a slow, total failure.
Figure 7.2 — The pool as a valve. Top: requests wait in a bounded, observable queue; the pool admits only as many as the database can serve well; excess sheds early with a 429 and a Retry-After. Bottom: the oversized pool admits everyone, relocating the queue into the database where contention makes every query slower and no shedding is possible.
Worked example — the stampede as a capacity event

Module 4 traced the shared-cellar stampede as an invalidation failure. Look at the same incident through this module's lens and a second set of defects appears. The 800 concurrent recomputations exhausted a pool sized at 200 — well above what the database could serve concurrently — so contention inside Postgres made each 1.8 s aggregation slower still. There was no queue timeout, so requests waited for connections long after their clients had given up, holding resources for work nobody would read. And there was no shedding, so every endpoint in the product degraded rather than just the one under pressure. Single-flight fixed the cause. A pool sized to database capacity, a 2-second acquisition timeout, and a 429 on the summary endpoint would have bounded the blast radius of any similar cause — including causes nobody has thought of yet. Both fixes belong in the postmortem.

Cross-reference — Guide Nº 17

Bounded queues, retry policy, backoff, and dead-letter handling are treated properly in Guide Nº 17. One point deserves emphasis here because it interacts directly with the cliff: retries are load multiplication applied exactly when capacity is exhausted. A client retrying three times during a slowdown triples the arrival rate at the worst possible moment, converting a brownout into an outage. Exponential backoff with jitter, a retry budget, and a circuit breaker are not politeness features — they are the difference between recovering and not.

The memory hierarchy in practice

Module 1's top two rungs have consequences inside a single process that are worth recognizing even though you will rarely tune them directly.

A CPU does not fetch one byte from memory; it fetches a cache line — typically 64 bytes — and it aggressively predicts sequential access, prefetching ahead of what you have asked for. So iterating a compact array of numbers is enormously faster than following pointers between objects scattered across the heap: the first pattern gets many useful values per fetch plus successful prediction, the second gets one value per fetch and defeats the prefetcher. The same loop, over the same count of the same values, can differ by an order of magnitude based purely on memory locality.

You are unlikely to need this for a typical web request, where a 100 ns memory read is invisible next to a 500 µs round trip. It matters in the places where you process many items in a tight loop: the valuation aggregation over hundreds of thousands of price rows, an embedding similarity scan, an image or CSV transform, anything sorting or grouping large collections. When a profile shows a loop taking far longer than its arithmetic suggests, locality is a plausible suspect and "process a compact array rather than a graph of objects" is usually the entire remedy.

Cross-reference — Guide Nº 25

What a runtime actually does — memory layout, allocation, how a language's abstractions map to machine behavior — belongs to Guide Nº 25. The boundary this course draws is deliberate: recognition, not tuning. You should be able to look at a profile and say "that loop's cost is not explained by its arithmetic, and its data layout would explain it." You should not be counting cache lines or reading assembly; if a workload genuinely requires that, it needs a specialist and a different guide.

The pause with no request behind it

The last inherited cost is the most distinctive to diagnose. A garbage collector reclaims memory your program is no longer using, and depending on the runtime and collector, it may need to stop the world — pause all application threads — to do part of that work. Every request in flight at that moment stops. None of them did anything wrong.

The signature is what makes it identifiable, and it is worth learning by sight because the alternative is weeks of blaming the database. Plot p99 latency and request rate on the same time axis. A capacity problem shows latency rising with traffic. A GC pause shows sharp latency spikes on flat traffic, often on a rhythm — every thirty to sixty seconds, or after a heavy allocation burst — with a consistent height corresponding to the collector's pause duration. The correlation with traffic is the tell: there is no request behind the spike.

Dual time series over one hour showing flat request traffic and periodic sharp p99 latency spikes at regular intervals, annotated as the garbage collection rhythmOne hour · p99 latency and request rate on the same axistime →p99request rate — flat throughout~48 s~48 sPeriodic · uniform height · uncorrelated with traffic= stop-the-world collection, not load and not the databaseContrast: a capacity problem's latency curve tracks the traffic curve. A stampede is a single spike, not a rhythm.Adding a cache cannot help — the pause stops the code that would read the cache.
Figure 7.3 — The spike with no traffic behind it. p99 latency spiking on a roughly 48-second rhythm while the request rate stays flat. Periodicity, uniform spike height, and absent correlation with traffic together identify a stop-the-world collection. Compare the two other shapes: capacity problems track the traffic curve, and a stampede is one isolated spike that resolves on its own.

The responses are runtime-level, not application-level: reduce allocation rate in hot paths (the largest lever, and the one most under your control), tune heap sizing and generation ratios, or move to a low-pause collector designed to trade throughput for predictable pause times. Which applies depends on the runtime, and that is Guide Nº 25's territory.

What matters here is the negative result: no cache fixes a GC pause. The pause stops the thread that would read the cache. Neither does a bigger database, more replicas, or a CDN. This is the clearest case in the course of a latency problem that is not a distance problem, and every tool from modules 3 through 6 is simply inapplicable — which is exactly why you diagnose before you reach for a lever.

Module 08 Performance in the AI era

An LLM call breaks the assumptions the rest of this course rests on. It is measured in seconds rather than milliseconds. Its latency depends on the size of its output, not merely its input. Its cost per call is a line item rather than a rounding error. And its quality is a variable you can trade against speed — a dial that no database query offers.

Everything you have learned still applies, in new clothes: token latency is a new bottom rung on module 1's hierarchy, embedding caches obey module 4's invalidation rules, and the two-clocks discipline is module 2's insistence on measuring the right number. This module covers what is genuinely new, then assembles the whole method and indexes every anti-pattern the course has named.

Token latency is a different animal

Add a rung to module 1's ladder, below everything else on it. A model call takes on the order of 1 to 10 seconds. On the ×10⁹ human scale that is somewhere between thirty and three hundred years — a cost so far outside the rest of the hierarchy that it changes what kind of thing a request is. You do not optimize around a model call the way you optimize around a database query; you architect around it.

It also has internal structure that a single latency number hides, and the structure is what you act on.

Time-to-first-token (TTFT) is the delay before any output appears. It is composed of queueing at the provider, plus prefill — the model processing your entire input prompt before it can produce anything. TTFT therefore scales with input size and with how busy the provider is. It is what you attack by shortening prompts, trimming retrieved context, using provider-side prompt caching, or choosing a less congested deployment.

Tokens per second is the generation rate after the first token. Total time ≈ TTFT + (output tokens ÷ generation rate). It scales with output length and with model size, so you attack it by generating less — asking for a shorter answer, returning structured fields instead of prose, splitting one long generation into parallel shorter ones — or by using a smaller model.

The load-bearing idea

These two clocks fail differently and are fixed differently, so a single "latency" number for an AI feature is worse than useless — it averages two unrelated problems. A 3-second TTFT with fast generation and a fast TTFT with 3 seconds of grinding output are opposite bugs. Measure them separately from the first day, or you will optimize whichever one you happened to guess at.

Cross-reference — Guide Nº 02

Model selection, prompt construction, retrieval architecture, and evaluation are Guide Nº 02's subject. This module treats the model as a component with latency and cost characteristics, and asks where to put it, what to cache around it, and what to show the user while it works.

Streaming as a UX strategy

Streaming sends tokens as they are generated rather than withholding the response until it is complete. Be precise about what it does: it changes nothing about total generation time. An 8-second generation still takes 8 seconds. What it changes is when the user sees the first evidence that something is happening — from 8 seconds to perhaps 800 milliseconds.

That is not a trick, and it is not merely psychological. A user watching text appear can begin reading, begin evaluating, and decide to stop early if the answer is going the wrong way. Perceived latency is a real quantity that determines whether a feature feels usable, and streaming is the highest-leverage tool for it — comparable in impact to a genuine 10× speedup, at a fraction of the cost.

Two timelines of the same eight-second generation: blocked, showing one long dead wait then all output, and streamed, showing first token at eight hundred milliseconds with tokens flowing afterBlocked — response withheld until complete8 seconds of nothing — a spinnerall outputperceived start: 8,000 msStreamed — tokens sent as generatedTTFTtokens flowing — the user is readingperceived start: ~800 ms · total time: still 8,000 msWithhold when partial output is actionable and reversible: a verdict, a total, a yes/no, a compliancedetermination. Stream the reasoning if you like — but the conclusion must arrive whole.
Figure 8.1 — Two clocks: TTFT and total time. The same 8-second generation, blocked and streamed. Streaming does not shorten total time; it moves the perceived start from 8,000 ms to ~800 ms and lets the user begin reading. The caution band states the exception: when partial output can be acted on and later tokens could reverse it, the conclusion must arrive complete.

And now the exception, which is where streaming stops being free. Streaming shows the user an incomplete answer, and an incomplete answer can be wrong in a way the complete answer is not. "The transaction appears compliant with the applicable reporting threshold—" is a sentence a reader can act on. The tokens that follow may be "—however, the counterparty's jurisdiction triggers a separate obligation that has not been satisfied."

The rule: stream when partial output is informative but not actionable; withhold when partial output is actionable and reversible. A tasting note, a draft email, an explanation, a summary — stream them. A compliance determination, a computed total, an approve/deny, a risk score — withhold, or restructure so the conclusion arrives atomically at the end while the reasoning streams before it. That restructuring is usually the best of both: the user sees progress and cannot act on a verdict that has not been rendered.

Caching the expensive brain

Model calls are the most expensive thing in a modern request budget, in both latency and money, which makes them the highest-value caching target in the course. Three distinct caches, with three distinct correctness profiles.

Embeddings are the easy case and the one teams most often miss. An embedding is deterministic: the same input through the same model version yields the same vector. So it can be cached effectively forever, keyed on a hash of the content plus the model identifier. There is no staleness risk while those two are unchanged, and re-embedding an unchanged corpus on every deploy is pure waste — it is common, and it is usually one of the largest available savings in a retrieval pipeline.

Exact-match response caching keys on the full normalized prompt. It is safe — identical input, identical output — and its yield depends entirely on how repetitive your prompts are. For a free-text assistant, near zero. For a feature generating tasting notes from a fixed template with a small set of wine attributes, substantial. Module 3's rule holds unchanged: measure reuse before building it.

Semantic caching serves a cached answer to a question that is merely similar by embedding distance. It converts a near-zero hit rate into a meaningful one, and it does so by deliberately trading correctness for hit rate. That is a legitimate trade on tolerant surfaces and a serious hazard on precise ones: "what is my 2016 Margaux worth" and "what is my 2015 Margaux worth" are close in embedding space and have different correct answers. If you deploy semantic caching, name the trade explicitly, set the similarity threshold conservatively, and keep it away from anything a user acts on.

Provider-side prompt caching is a fourth mechanism worth knowing: providers can cache the processed form of a repeated prompt prefix, cutting both TTFT and input cost on subsequent calls. It rewards structuring prompts with stable content first — system instructions, retrieved documents, few-shot examples — and the variable user input last.

The invalidation rule nobody writes down

Every one of these caches is invalidated by a model version change. New model, new embeddings — vectors from different models are not comparable, so a mixed index silently returns nonsense similarity scores. New model, new responses — cached outputs now come from a model you are no longer running, so your evaluation results describe a system that is not in production. Put the model identifier in the cache key rather than relying on anyone remembering to flush at deploy time. This is module 4's forgotten-write-path failure with a longer fuse and a much less obvious symptom: nothing errors, and the quality regression looks like drift.

The cost-latency-quality triangle

Every AI feature occupies a point among three quantities: model quality (how good the output is), latency (how long the user waits), and cost (what each call spends). You can trade between them. You cannot escape them, and the corners move together — a larger model is generally better, slower, and more expensive at once.

What distinguishes teams here is not where they land but whether they chose. The default behavior is to pick the strongest model during prototyping, ship it, and discover the position months later via a cost dashboard or a complaint. The discipline is to name the corner you are sacrificing before you build, and to write down what would change the decision.

A triangle with quality, latency, and cost at its corners, with two wine-cellar AI features placed as markers showing which corner each sacrificedQualitylarger model · more reasoning · richer contextLow costsmaller model · fewer tokensLow latencyfaster model · shorter outputTasting-notes generatormid-tier model · streamedsacrificed: quality (editable prose)Valuation explainertop-tier model · not streamedsacrificed: latency and costYou pick a point. You do not escape the triangle — and a point chosen by default is still a point.
Figure 8.2 — The cost-latency-quality triangle. Two wine-cellar features placed deliberately. Tasting notes sit toward low latency and low cost, sacrificing some quality because the output is editable prose a user reviews. The valuation explainer sits at the quality corner, sacrificing latency and cost, because a customer may rely on the number it explains. Same product, opposite placements, each defensible only because it was argued.
Worked example — two features, opposite corners

Tasting-notes generator. Produces a suggested tasting note from a wine's attributes, which the collector then edits. Placement: mid-tier model, streamed, ~$0.002 per call, TTFT under 1 s. Sacrificed: quality. Defensible because the output is a first draft a human reviews and edits, errors are visible and cheap to correct, and volume is high enough that cost is a real line item. Would change if: edit rates rose above ~40%, indicating the drafts are not saving work.

Valuation explainer. Explains why a cellar is valued as it is — which comparable sales, which vintage adjustments. Placement: top-tier model, not streamed, ~$0.04 per call, ~6 s total. Sacrificed: latency and cost. Defensible because a collector may rely on this explanation when deciding to sell, the volume is a tenth of tasting notes, and a plausible-but-wrong explanation of a financial figure is precisely the misrepresentation risk module 4 discussed. Not streamed because the conclusion is actionable. Would change if: volume grew tenfold, at which point a cache keyed on (cellar valuation inputs, model version) would be the first move — not a model downgrade.

The method, assembled

Everything in this course reduces to one loop. It is deliberately small enough to run from memory in a design review.

1 · Measure the tail. p99, not the mean, over a stated window, segmented by whatever dimension the cost scales with. Count the fan-out. (Module 2.)

2 · Locate the bottleneck. Profile and trace; rank components by share of the budget; compute each candidate fix's Amdahl ceiling before doing any work. (Module 2.)

3 · Shorten the distance. Remove work first if you can — batch the N+1, add the index. Then move the answer closer: the right cache at the right layer, or the right store for the access pattern. (Modules 1, 3, 5, 6.)

4 · State the staleness. Every cache you introduced gets an invalidation trigger, a TTL backstop, a worst-case staleness number, and — where a user relies on the value — a user-facing statement of it. (Module 4.)

5 · Mind the cliff. Check that the fix did not just move the constraint. Confirm headroom on the scarcest resource, bound the queues, and verify backpressure exists. (Module 7.)

6 · Verify at the tail. The same measurement as step 1, after. If p99 did not move, the fix did not work, whatever the microbenchmark says. (Module 2.)

The performance method drawn as a cycle from measure to locate to shorten to state staleness to mind the cliff to verify, with a crossed-out dashed shortcut from symptom directly to optimize1 · Measurethe tail2 · Locateprofile · Amdahl3 · Shortenthe distance4 · State stalenessevery cache, a rule5 · Mind the cliffheadroom · bounds6 · Verifyat the tailloop"the page is slow"optimize the first suspectThe shortcut that produces the two master anti-patterns: unmeasured optimization and unplanned caching.
Figure 8.3 — The performance method. The loop: measure the tail, locate the bottleneck, shorten the distance, state the staleness, mind the cliff, verify at the tail — then measure again. The crossed-out dashed path is the shortcut from symptom straight to fix, which is where both master anti-patterns of module 1 come from.

And the index. Every anti-pattern this course named, with the symptom that reveals it in the wild and the corrective.

Anti-patternSymptom in the wildCorrectiveModule
Optimizing before measuringA shipped rewrite that benchmarks beautifully and moves p99 by 2%Profile first; compute the Amdahl ceiling before startingm1, m2
Optimizing the non-bottleneckSprint spent on a component the profile shows at 4%Rank by share of budget; refuse fixes whose ceiling cannot paym2
Reporting the average"Average is 180 ms" on the dashboard; support says "broken"p99 with a window and segmentation; demote the mean to contextm2
The N+1 queryMany fast queries in a staircase; "the database is slow"Join or batch fetch; alert on statements-per-requestm2
Cargo-cult cachingA cache with a 12% hit rate and no measured reuseMeasure reuse and run the effective-latency arithmetic firstm3
Indiscriminate cachingMemory pressure plus unexplained stale values, togetherCache the few things read many times, not the many read oncem3
Caching without invalidationStale data nobody can explain; no one can state the boundTrigger + TTL backstop + a stated worst-case stalenessm4
The forgotten write pathOne entry stale for days while the invalidation code tests greenEnumerate every writer; the TTL backstop bounds the ones you missm4
Concealed stalenessA customer disputes a figure the page implied was currentShow the as-of bound; route actionable reads to the sourcem4
Stampede misread as capacityInstant spike, identical queries, flat traffic → "bigger database"Jittered TTLs, single-flight, stale-while-revalidatem4
Personalized data at a shared cacheA user reports seeing another user's dataprivate, identity in the key, or no shared caching at allm5
Inconsistent layered TTLsValues update, then revert, then update againOuter TTL ≤ inner, or purge the layers togetherm5
Store selection by fashionA migration proposed before any access pattern is statedAnswer the five questions first; price the migration honestlym6
Replica behind read-your-writesA user's own change intermittently missing after a redirectRoute post-write reads to the primary; do not "tune replication"m6
Analytics on the OLTP primaryUnrelated endpoints degrade whenever a report runsWarehouse or materialized view refreshed off-peakm6
Running near saturationFine at 70%, on fire at 95%; "traffic only grew 10%"Headroom as a design number; measure the scarcest resourcem7
The giant connection poolPool raised, database contention rises, throughput fallsBound near downstream capacity; queue and shed in frontm7
Retry amplificationA brownout becomes an outage minutes after it startsExponential backoff with jitter, retry budgets, circuit breakersm7
GC pause read as loadPeriodic uniform p99 spikes on flat traffic blamed on the DBRecognize the signature; reduce allocation or change collectorm7
One latency number for an LLM callTuning generation speed while users wait 3 s for the first tokenMeasure TTFT and generation rate separatelym8
Streaming an actionable verdictAn operator acts on a first sentence the last one reversesStream reasoning; deliver conclusions atomicallym8
Semantic-cache overreachA similar question returns a confidently wrong cached answerName the correctness trade; conservative threshold; tolerant surfaces onlym8
Model version not in the cache keyQuality "drifts" after a model upgrade with no error anywhereModel id in the key for every embedding and response cachem8
The load-bearing idea

Speed is a property of where your data lives and how far it must travel. Nearly every fix in this course shortens that distance, and the two instincts that reliably fail — optimizing before measuring, and caching without a plan to invalidate — fail because they skip the two steps that make performance work falsifiable. Measure, locate, shorten, state the staleness, verify. Everything else is detail.

Concept index

Latency
The time from question to answer for one request — the length of the pipe, not its width.
Latency hierarchy
The canonical ladder of costs from CPU cache (~1 ns) to a cross-region round trip (~100 ms), spanning eight orders of magnitude.
Human-time scale
The ×10⁹ conversion that turns 1 ns into 1 second, making the hierarchy's gaps feel like the distances they are.
Throughput
Requests served per unit time — the pipe's width; it can be high while every individual request is slow.
Percentile (p50/p95/p99)
The latency at or below which that share of requests complete; the honest summary of a skewed distribution.
Tail latency
The slow extreme of the distribution (p99 and beyond) — the number that defines the experience under fan-out and usage skew.
Fan-out amplification
The more backend calls a page makes, the more often it waits on at least one tail outlier: 1 − 0.99ⁿ.
Bottleneck
The one component whose share of the time budget caps every other fix's payoff.
Amdahl intuition
Speeding up a component buys at most that component's share of total time, no matter how fast you make it.
Profiling
Measuring where time actually goes, so the profile convicts what intuition merely suspects.
N+1 query
One query for the list, then one more per item — clean code hiding a network round trip per row.
Cache
A copy of an answer kept closer than its source of truth.
Hit / miss
A lookup answered from the cache, versus one that must travel to the source and usually populate on the way back.
Hit rate
The fraction of lookups served from cache — the number that decides whether a cache pays.
Cache-aside
The pattern: check the cache, on miss fetch from the source, populate, return.
Effective latency
h·t_hit + (1−h)·t_miss — what a cache actually delivers at hit rate h.
Staleness
The gap between a cached copy and its source's current truth; a schedule you choose, not an accident.
TTL (time-to-live)
A cache entry's expiry: a stated promise that the answer is at most that old.
Explicit invalidation
Removing or updating the cached copy the moment the source changes; precise, and only as reliable as your most forgotten write path.
Cache stampede
A hot key expires under load and every concurrent miss dogpiles the source at once.
Single-flight
Stampede control: one request recomputes the expired entry while the rest coalesce onto its result.
Stale-while-revalidate
Serve the expired answer during recomputation so users never pay the miss.
CDN (edge cache)
A shared cache near the user, multiplying one response across many readers — and multiplying the blast radius when it is wrong.
Cache-Control
The HTTP header that instructs every cache between your server and your user what may be stored, by whom, and for how long.
Materialized view
A query's answer stored as a table with a refresh rule — a cache living inside the database.
In-memory store
A shared RAM-resident store (Redis-class) serving point lookups at memory speed, one short hop away.
Access pattern
A workload's shape — read/write ratio, lookup form, consistency need, lifetime, size — the thing that actually picks a data store.
Key-value store
A store optimized for point lookups by exact key, shedding relational guarantees in exchange for speed.
Read replica
An asynchronously copied read-only database — always slightly in the past.
Replication lag
The delay between a write landing on the primary and its visibility on a replica.
Read-your-writes
The guarantee that a user sees their own write; the guarantee a lagging replica silently breaks.
Utilization
The fraction of a resource's capacity in use — innocuous until it nears saturation.
Queueing cliff
The nonlinear blow-up of wait time as utilization approaches capacity — fine at 70%, vertical near 100%.
Connection pool
A bounded set of reusable connections: a deliberate queue protecting a resource that dies under unbounded concurrency.
Backpressure
Refusing or bounding new work when full — the fast no that preserves the service.
Load shedding
Deliberately rejecting excess requests (429) so the requests you do accept still succeed.
Memory locality
Sequential, compact access is radically faster than pointer-chasing, because CPUs fetch whole cache lines and predict strides.
GC pause
A stop-the-world garbage-collection break: a latency spike with no request behind it.
Time-to-first-token (TTFT)
How long before an LLM response begins; the clock streaming actually improves.
Semantic cache
Serving a cached model answer to a merely similar question — a hit-rate gain bought with a named correctness risk.
Cost-latency-quality triangle
The three-way trade every AI feature negotiates; you pick a point, you do not escape the triangle.

This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.