Skip to main content
Performance Engineering

Caching strategies that do not lie to you

A cache is a second copy of your data that is permitted to be wrong. Everything that follows is a decision about how wrong, for how long, and whether you find out before a customer does.

Every cache is a promise nobody wrote down

When you put a cache in front of something, you are making a promise: a stale answer is acceptable on this path, for roughly this long, in exchange for this much latency and this much load taken off the origin. Almost nobody writes the promise down. The team ships the cache, the dashboard turns green, and the terms of the promise get discovered later during an incident, when a customer is looking at a price that changed forty minutes ago or a revoked user is still holding a session that should have died at 9:14. The fix is not a smarter eviction policy. It is stating the promise first and then building the cache that keeps it.

There are three questions per cached read path, and they take about ten minutes to answer. How stale may this be, in seconds? What does it cost when it is wrong, and who absorbs that? How would we know? A path that cannot answer the first does not get a cache yet. A path that cannot answer the third gets one that will lie for months before anyone notices, because a stale cache throws no exceptions. It returns 200 OK, quickly, with the wrong body.

You are probably here because

  • A customer saw a price, a permission or an availability flag that had changed half an hour earlier, and nothing in your monitoring noticed
  • Latency spikes arrive on a schedule — every few minutes, or right after every deploy — and the database looks healthy in between
  • Nobody on the team can say how old the data on a given screen is allowed to be, so every TTL argument ends in opinion
  • The only cache number on the dashboard is hit rate, and it looks excellent

The staleness budget and key sections below address the first and third, the stampede and layered-TTL sections address the second, and the divergence probe near the end addresses the fourth — and all four usually share one root cause, which is a cache whose promise was never written down.

Hit rate is the wrong number to lead with

Hit rate is the first metric every cache library exposes and the least informative one you can look at. It says nothing about correctness and surprisingly little about load. A path serving 20,000 requests per second at a 95 percent hit rate still sends 1,000 requests per second to the origin, and those misses are not spread evenly through the second. They cluster hard at expiry boundaries, which is exactly where the origin is least able to absorb them.

Worse, hit rate improves when the cache is wrong. Extend every TTL by 10x and the dashboard looks better, because nothing on it degrades as the data ages. That is a measurement system pointed away from the failure mode.

Four numbers tell you what is actually happening. The age of what you served, as a histogram, not an average. The divergence rate between the cached answer and a fresh one. The p99 latency of a miss, because misses are the user-visible tail. And origin request rate at its peak relative to its mean, which is the number that predicts whether an expiry event takes the database down. Hit rate is worth tracking per key class, and it belongs near the bottom of the list.

Write the staleness budget before you pick a TTL

A TTL chosen without a budget is a number somebody typed. Five minutes appears in more codebases than any other duration, and in most of them it means only that five minutes felt safe. Start from the other end: how old can this answer be before it causes a problem someone files a ticket about? Write that number in seconds, get the product owner to agree with it, and put it in the code next to the TTL. Then derive the TTL so total observed age stays inside it.

Data classTypical staleness budgetWho notices, and how fastRight mechanism
Permissions, roles, session validitySeconds, or event-drivenSecurity review, and the customer whose access you failed to revokePush invalidation on the write path, with a short TTL as a backstop
Price, availability, anything at checkout5–60 secondsThe customer, immediately, at the worst momentShort TTL plus invalidate-on-write, single-flight on misses
Search results, listings, feeds1–5 minutesRarely anyone, unless a deletion stays visibleTTL with jitter, stale-while-revalidate
Dashboards and aggregate reporting5–60 minutesNobody, if the page states the as-of timePrecomputed table or materialized view on a schedule
Configuration and reference dataMinutes to hoursEngineers, during a rolloutVersioned key, changed by deploy rather than expired
Built assets and immutable objectsUnboundedNever, if the URL contains a content hashImmutable caching, new content gets a new URL

The last row generalizes. Content-addressed objects need no invalidation at all, because the identity of the object changes when the object changes. Every time you can push a cached item toward that shape, the problem disappears instead of getting harder.

The key is the contract

A cache key is a claim that any two requests producing the same key deserve the same answer. Most cache defects are that claim being false. The inputs that change an answer are almost always more numerous than the ones a developer types into a key at 4 p.m.: tenant or account, the caller's permission set, locale and currency, the API or schema version, the feature-flag variant assigned to the user, the model or ruleset version, every query parameter after normalization, and the version of the code that shapes the response.

Two rules make this tractable. First, keys are produced by one function in one module, never assembled with string concatenation at call sites. Scattered concatenation is how a key acquires four different spellings and how a new query parameter gets added to a handler and forgotten in the key. Second, every key carries a namespace prefix with a schema version, something like v7:search:. When a deploy changes the response shape, you bump the prefix and the entire old generation becomes unreachable in one edit, no purge required, and the dead entries age out under eviction.

If two requests can legitimately produce different correct answers and the key cannot tell them apart, you do not have a cache. You have a fast way to serve one customer another customer's data.

The cross-tenant version is the expensive one. A response gets computed after authorization and stored under a key derived only from the request path, so the first user's answer is served to the second, correctly according to the cache and catastrophically according to everyone else. The defense is mechanical: any value produced downstream of an authorization decision carries the authorization inputs in its key, and review treats a key change like a schema migration.

Three honest ways to invalidate, and one that only looks like one

Expiry. The value carries a TTL and dies on its own. Honest because the staleness is bounded and you can state the bound. Weak because the bound is a guess, and it is the same guess for a row that changes hourly and a row that has not changed since 2023.

Versioned keys. Delete nothing. Change the key so the old value becomes unreachable and let eviction reclaim it. This is the strongest mechanism available and it is under-used outside asset pipelines. A key like user:8123:profile:rev41, where the revision is a counter on the row, turns invalidation from a distributed delete into a local read of a number you already have.

Write-path invalidation. The code that mutates the data also deletes or overwrites the cached copy. Necessary wherever the budget is measured in seconds, and it carries a race that catches nearly everyone: a read misses, fetches from origin, and is descheduled; a write commits and deletes the key; the stale read then finishes and writes its pre-write value back. The cache holds a wrong value until its TTL expires, and no amount of correct-looking delete code prevents it. Mitigate with a short TTL backstop, a second delayed delete a few hundred milliseconds after the write, or a write-through under the lock that guards the mutation.

The one that only looks honest: manual purge. A purge endpoint or a dashboard button is a break-glass tool. As the primary invalidation design it makes correctness depend on a human noticing, and the first sign of trouble is a support ticket. Keep the button. Do not build on it.

How We Weight a Cached Read Path in Review

Staleness budget written down, in seconds, per path
25
Key completeness: every input that changes the answer
22
Stampede behavior at expiry and on a cold start
18
Blast radius when the cache is empty or unreachable
15
Invalidation on the write path, and the race it misses
12
Raw hit ratio
8

Weights sum to 100. Hit ratio is last on purpose: it is the number that improves when the cache gets less truthful.

Stampede is what a cache outage actually looks like

Caches rarely fail by returning errors. They fail by all expiring at once. A hot key dies, several hundred concurrent requests miss together, every one calls the origin, latency climbs under the pile-on, connection pools saturate, and nothing repopulates the cache because every worker is stuck on a query that used to take 80 milliseconds. From the outside it presents as a database outage. The database is fine. It is being asked to do the same work four hundred times at once.

Four mitigations, in the order we usually apply them.

Jitter every TTL. A deploy that warms ten thousand keys in one second with a fixed 300-second TTL creates a wave that re-forms every five minutes, forever. Randomize each TTL by plus or minus 10 to 20 percent. One line of code, and an entire class of periodic latency spikes disappears.

Coalesce concurrent misses. Let one request compute and make the rest wait on its result. Nginx has proxy_cache_lock, Varnish coalesces by default, and Go ships golang.org/x/sync/singleflight; in-process it is a single-flight map keyed on the cache key. Coalescing turns four hundred origin calls into one, and on a hot path nothing pays as well for the effort.

Recompute early, probabilistically. Instead of waiting for expiry, let each reader roll dice weighted by how expensive the last recompute was and how close expiry is. The published version is optimal probabilistic cache stampede prevention, from Vattani, Chierichetti and Lowenstein at VLDB 2015: refresh when now − delta × beta × ln(random) reaches the expiry time, where delta is the duration of the previous recompute and beta defaults to 1. Expensive values refresh earlier, cheap ones stay lazy, and the herd never forms.

Serve stale while you refresh. The most useful primitive on this list, and the least used.

Stale-while-revalidate, and the sibling that saves your availability

RFC 5861 defines two Cache-Control extensions that most teams never send. A header like Cache-Control: max-age=60, stale-while-revalidate=600, stale-if-error=86400 says three separate things: this is fresh for a minute; for ten minutes after that, serve the stale copy immediately and refresh in the background; and if the origin is failing, keep serving the stale copy for a day rather than serving an error. Browsers and the major CDNs implement it, and the same pattern is straightforward to implement inside an application cache by storing a soft expiry alongside a hard one.

The effect on the latency profile is larger than it sounds. Under plain expiry every miss pays full origin latency and lands on a user. Under stale-while-revalidate the user gets a cache-speed response and the refresh happens off the request path, so the tail flattens and the origin sees a trickle instead of a burst.

Serving a ten-minute-old answer during an origin outage is not a compromise. It is most of the reason you built the cache.

stale-if-error is an availability feature disguised as a caching directive. A read path with a generous window survives a dependency outage in degraded form rather than failing. That trade is right for content, listings and reference data, and wrong for balances, permissions and anything transactional. Decide it per path, next to the staleness budget.

Layered TTLs add up; they do not average out

Real systems stack four or five caches, each configured by a different person in a different year. Worst-case staleness is the sum of the layers, not the largest one, because each layer can hand a nearly expired copy to the layer above it at the last possible moment.

LayerWhat it holdsTypical TTLHow you actually clear it
BrowserWhatever max-age you sent, including mistakes0–300sYou cannot. Change the URL, or send a short max-age and rely on ETag revalidation
CDN or edgeResponses keyed by URL plus the Vary set60s–1 day (s-maxage)Purge by tag or URL. Propagation is fast but is not instantaneous or globally atomic
Application cacheSerialized objects, fragments, computed results10s–1 hourDelete on write, or bump a key version
Query or ORM cacheResult sets, often keyed by SQL textSeconds to minutesUsually TTL only, which is why it is the layer that surprises people
Materialized view or rollup tablePrecomputed aggregatesRefresh interval, 1–60 minRerun the refresh. In Postgres, the concurrent form needs a unique index

Do the arithmetic once and it changes the design. Browser 60 seconds, edge 300, application 900, rollup every 15 minutes: worst-case age is about 36 minutes on a path whose budget somebody wrote as five. The repair is not to shave every layer. Pick one layer to be authoritative for freshness, keep the rest short or content-addressed, and stop treating each config file as an independent decision.

One edge-specific trap: sending Vary on Authorization or Cookie disables edge caching for logged-in traffic, because the cache stores a copy per credential. Cache an anonymous shell at the edge with a long TTL and fetch personalized fragments underneath it, rather than teaching the edge your permission model.

Send it over and we will tell you what we would change.

Email the key-building code for one hot read path, plus the TTLs set at every layer in front of it — browser, edge, application, query cache — to contact@precisionfederal.com. You get back a short written note naming the three things we would change and why. One business day. No charge, no meeting, no deck.

contact@precisionfederal.com

Eviction quietly rewrites your TTLs

The lifetime of a cached value is not its TTL. It is the smaller of the TTL and the time until eviction, and under memory pressure the second term dominates. A cache near its memory limit is not honoring your TTLs. It is honoring an eviction policy you may not have chosen.

Two Redis specifics bite in production. The default maxmemory-policy is noeviction, so a full instance rejects writes instead of making room, and a cache path that is not defensive turns those rejections into user-visible errors on an optional component. The volatile-* policies only consider keys that have a TTL, so a codebase storing some values without expiry can fill an instance with unevictable entries while the config claims LRU. Redis LRU is also approximate, sampling candidates rather than maintaining a true ordering.

Watch three counters: evicted keys per second, memory used against the limit, and hit ratio per key class over time. Sizing has a cliff rather than a slope. If trimming memory by 20 percent in a load test collapses the hit ratio, the working set barely fits and the next traffic increase will find that out for you.

If losing the cache takes you down, it is not a cache

Here is the test that settles most architecture arguments about caching. In a load test at realistic traffic, flush the cache completely and watch what happens. If the system serves the miss storm with elevated latency and recovers, you have a cache. If the origin falls over, you do not have a cache. You have a database with no durability, no backups and no replication, sitting in the critical path of every request, and you should either give it those properties and operate it accordingly or fix the origin so it can serve the cold miss rate.

When the origin cannot be fixed cheaply, stop caching the query and precompute into a real table. A rollup written by a scheduled job has durability, replicas, backups and a query planner, and it does not evaporate on restart. More work than a cache decorator, and on a path whose cold-start cost is an outage, the correct amount of work.

The related failure is the miss storm from things that do not exist. Without negative caching, every request for a deleted object, an empty result set or a denied permission is a full origin round trip, and any process walking identifiers becomes an unintentional load test. Cache the absence, with a shorter TTL than the positive case. Thirty to sixty seconds absorbs a storm without noticeably delaying when a new object becomes visible.

Five ways to build a very fast source of wrong answers

  • Keys assembled at call sites. A new query parameter reaches the handler and never reaches the key. Two requests with different meanings collide, and the symptom arrives as a support ticket months later.
  • One TTL for everything, with no jitter. Five minutes on every key means every key written during a deploy expires in the same second, forever, in a wave the origin has to absorb on a fixed schedule.
  • Caching after authorization under an unauthenticated key. The most expensive bug in this article, and it passes every functional test written by one logged-in user.
  • Treating the purge button as the invalidation design. Correctness that depends on a person remembering is correctness you do not have.
  • Measuring hit rate and nothing else. The one metric that improves as the cache becomes less truthful is the one on the dashboard.

Instrumentation Priority — Cached Read Path

Age of the value served, as a histogram
24
Divergence probe against the origin
22
Miss latency at p99, split from hit latency
18
Origin request rate, peak against mean
16
Evictions and memory headroom
12
Hit ratio, broken out by key class
8

Build them in this order. The first two are the ones almost no existing system has when we start looking.

The probe that catches a lying cache

The highest-value thing missing from most caching setups costs about a day to build. Store the write timestamp inside the cached value so every read can compute the age of what it served and emit it as a histogram. Then run a background job that samples key classes at a low rate, reads through the cache, reads the same value from the origin, canonicalizes both and compares. Emit a divergence rate per key class and the age distribution of the values that diverged.

That gives you a real alert. Not "hit rate dropped below 80 percent", which wakes people up for nothing, but "the pricing key class is serving values older than its 60-second budget at the 99th percentile", which is a genuine defect with a location attached. Sampling a few keys per second per class is enough for statistical confidence within minutes and adds load the origin will not notice.

Canonicalization is where the work is. Timestamps, generated identifiers and unordered collections make two identical answers compare unequal, so each key class needs a normalization function, and writing it forces a useful argument about which fields are semantically part of the answer. Where exact comparison is impractical, hash the fields that matter and ignore the rest.

Write these down before the cache ships

  • The staleness budget in seconds for each read path, and the person who agreed to it
  • Every input that changes the answer, and its position in the key
  • A namespace prefix carrying the schema version, bumped by deploy
  • The TTL, its jitter band, and the behavior at expiry: coalesce, refresh early, or serve stale
  • What happens when the cache is unreachable: fail open to the origin, or fail the request
  • The write-path invalidation, and the race it does not cover
  • The eviction policy, the memory headroom, and an alert on evicted keys
  • The divergence probe, its sampling rate, and the threshold that pages someone

Where the effort belongs

Two ordering rules save more time than any specific technique. First, when the origin call is slow because the query is bad, fix the query before adding a cache. Otherwise the problem is still there and harder to find, because the normal path now looks fast.

A cache in front of a four-second query is a four-second query with a delay fuse. It still fires, only now during incidents and cold starts.

Second, cache the widest object whose staleness budget you can honor. One assembled response beats twelve cached fragments on latency and on origin load, and a shorter TTL is worth paying to get it. Split into fragments only when a real freshness requirement forces it, and let each fragment carry its own budget instead of inheriting the strictest one on the page.

When we review a system, the finding is rarely that caching is missing. It is that the cache exists, works, and has never been asked what it promises: nobody can say how old the data on a screen may be, nothing measures how old it is, and the keys are missing an input that has not collided yet. Those gaps take a couple of weeks to close, and closing them turns a component nobody trusts into one you can reason about during an incident.

Bottom line

A cache trades correctness for speed. The trade is often excellent, it is never free, and the engineering is in making the terms explicit rather than in choosing a library. Write the staleness budget in seconds. Put every input that changes the answer into the key, from one function. Prefer versioned keys over deletes, jitter every TTL, coalesce concurrent misses, serve stale while you refresh, and add up the layers instead of assuming they overlap. Then measure the age of what you served and probe for divergence, because a cache that is wrong raises no alarm. It answers quickly and confidently, which is what makes it dangerous.

Frequently asked questions

What is a good cache hit rate?

There is no target worth chasing in the abstract, because the number improves when you make the cache less truthful. What matters is whether the miss rate times your request rate is a load the origin can serve, and whether the age of what you serve stays inside a written budget. Track hit ratio per key class as a diagnostic and treat a sudden change as a signal, not the level as a goal.

How do you choose a TTL?

Work backward from the staleness budget. Decide how old the answer can be before it causes a real problem, then subtract every other cache layer between the origin and the user, because worst-case age is the sum of the layers. What remains is the TTL, and it should carry 10 to 20 percent jitter so keys written together do not expire together.

How do you stop a cache stampede?

Coalesce concurrent misses so one request computes and the rest wait on its result, jitter the TTLs, and add probabilistic early recomputation so refreshes spread out ahead of expiry. Serving stale content while a background refresh runs removes the remaining user-visible cost. Coalescing alone usually delivers most of the gain on a hot key.

Should the application fail if the cache is unavailable?

Decide it per path and write the decision down. Most read paths should fail open to the origin with a degraded latency profile, which requires the origin to survive the full uncached rate. Test that by flushing the cache under a realistic load. If the origin cannot take it, the cache is load-bearing infrastructure and needs durability and an operations plan, or the origin needs fixing.

How do you know a cache is serving stale data?

Store the write time inside the cached value and emit the age of every served value as a histogram, then run a sampling job that compares cached answers with fresh ones from the origin and reports a divergence rate per key class. Alert on divergence and on age exceeding the budget. Without those two signals, a stale cache returns wrong answers with a 200 status and nothing in the system objects.

1 business day response

Want a second opinion on a cache you do not trust?

We review cached read paths, write the staleness budgets, fix the keys, and build the divergence probe that tells you when the cache is wrong. Send the read path and the numbers you have to contact@precisionfederal.com and we will tell you what we would change.

Email contact@precisionfederal.comCapabilitiesMore insights →