Freshness is a relationship between two versions.
The source owns $10.00 · v1. The cache is warm with that value. An admin update
commits $12.00 · v2 to the source, but it does not automatically change the cached
copy.
When the reader arrives, a cache hit can be fast and wrong for the current source. A cache miss can fetch and refill, but concurrent misses can duplicate the same source work. The policy is the part that turns those states into an application contract.
One authoritative source · one warm replica · one reader schedule.
- Source before
$10.00 · v1- Source after
$12.00 · v2, after the admin commit- Cache-aside hit
- Fast response, but version 1 can remain stale.
- Invalidation
- Remove after commit, then refill the next read with version 2.
- Concurrent miss
- Independent fills duplicate source work; single-flight can share one fill.
Every freshness choice moves work somewhere.
Cache-aside is simple: read the cache, then populate it on a miss. It does not know that a warm entry is stale unless a TTL, version check, or invalidation signal makes that fact visible. Invalidation reduces the stale window but adds coordination to writes. Bypass is fresh for the request but gives up cache savings. None of these alone solves concurrent misses.
Low read cost after warmup; freshness depends on expiry or invalidation.
Next read refills the new value, but the signal must reach every relevant cache.
Fresh for this request, with source load on every reader and no warm refill.
Where TTL fitsA bound on age, not a write protocol
A TTL puts an upper bound on how long an entry may remain eligible, subject to clock and scheduling behavior. It does not make a source write and cache expiry simultaneous. Use it with an explicit stale-value policy, and decide whether stale-while-revalidate is acceptable.
Run the readers, then count freshness and duplicate work.
The lab runs the displayed TypeScript model. Start with the warm cache after the source update, then compare invalidation and bypass. Switch to two concurrent misses to see independent fills versus single-flight coordination.
What does the reader observe?
Keep the source timeline fixed. Change the cache policy or miss coordination.
The cache is warm at version 1; the source commits version 2 before the reader arrives.
Start with the warm cache after a source update, then compare invalidation and bypass.
This lab models one source key, one warm value, and one deterministic miss schedule. It does not model distributed clocks, eviction, serialization, or network failure.
A miss is a control-flow decisionFallback, refill, and failure
A cache miss is not only a performance event. The caller needs a source deadline, a response when the source is unavailable, a rule for negative results, and a way to prevent a burst of misses from becoming a source outage.
Hold the cache contract steady. Change the language.
The source update, warm entry, and reader timeline make freshness observable.
export function runCache(
policy: CachePolicy,
scenario: Scenario = 'after-update',
fillPolicy: FillPolicy = 'independent'
): CacheRun {
let source = copyPrice(initialPrice);
let cache = scenario === 'after-update' ? copyPrice(initialPrice) : null;
let sourceWrites = 0;
let sourceReads = 0;
let cacheReads = 0;
let cacheWrites = 0;
let invalidations = 0;
if (scenario === 'after-update') {
source = copyPrice(updatedPrice);
sourceWrites = 1;
if (policy === 'invalidate-on-write') {
cache = null;
invalidations = 1;
}
}
const requestCount = scenario === 'concurrent-miss' ? 2 : 1;
const observed: PriceView[] = [];
if (policy === 'bypass') {
sourceReads = requestCount;
for (let request = 0; request < requestCount; request += 1) observed.push(copyPrice(source));
} else if (cache) {
cacheReads = requestCount;
for (let request = 0; request < requestCount; request += 1) observed.push(copyPrice(cache));
} else {
cacheReads = requestCount;
const fills = scenario === 'concurrent-miss' && fillPolicy === 'independent' ? requestCount : 1;
sourceReads = fills;
cacheWrites = fills;
cache = copyPrice(source);
for (let request = 0; request < requestCount; request += 1) observed.push(copyPrice(source));
}
const cacheState = stateOf(cache, source);
const outcome =
scenario === 'after-update'
? policy === 'cache-aside'
? 'stale cache hit'
: policy === 'invalidate-on-write'
? 'fresh refill after invalidation'
: 'fresh source read'
: scenario === 'concurrent-miss'
? policy === 'bypass'
? 'source thundering herd'
: fillPolicy === 'single-flight'
? 'coalesced cache fill'
: 'cache stampede'
: policy === 'bypass'
? 'cold source read'
: 'cold miss filled';
const explanation =
scenario === 'after-update'
? policy === 'cache-aside'
? 'The source is newer, but a cache-aside read does not know that the warm entry is stale unless a freshness or invalidation rule says so.'
: policy === 'invalidate-on-write'
? 'The source write commits first, then removes the key. The next read misses, refills, and observes version 2.'
: 'Bypassing the cache gives the reader the current source value, but every reader pays the source cost.'
: scenario === 'concurrent-miss'
? fillPolicy === 'single-flight' && policy !== 'bypass'
? 'Both readers share one in-flight refill, so the source and cache each do one unit of fill work.'
: 'Both readers observe a miss and refill independently. The source receives duplicate work for one key.'
: policy === 'bypass'
? 'The empty cache is deliberately ignored, so this read is fresh but does not warm a future reader.'
: 'The first reader misses, fetches the source, and stores the value for a later reader.';
return {
policy,
scenario,
fillPolicy,
observed,
cacheState,
sourceWrites,
sourceReads,
cacheReads,
cacheWrites,
invalidations,
outcome,
explanation
};
} func RunCache(policy CachePolicy, scenario Scenario, fillPolicy FillPolicy) CacheRun {
source := initialPrice
cachePresent := scenario == AfterUpdate
cache := initialPrice
sourceWrites := 0
invalidations := 0
if scenario == AfterUpdate {
source = updatedPrice
sourceWrites = 1
if policy == InvalidateWrite {
cachePresent = false
invalidations = 1
}
}
requestCount := 1
if scenario == ConcurrentMiss {
requestCount = 2
}
observed := []PriceView{}
sourceReads := 0
cacheReads := 0
cacheWrites := 0
switch {
case policy == Bypass:
sourceReads = requestCount
for request := 0; request < requestCount; request++ {
observed = append(observed, source)
}
case cachePresent:
cacheReads = requestCount
for request := 0; request < requestCount; request++ {
observed = append(observed, cache)
}
default:
cacheReads = requestCount
fills := 1
if scenario == ConcurrentMiss && fillPolicy == Independent {
fills = requestCount
}
sourceReads = fills
cacheWrites = fills
cachePresent = true
cache = source
for request := 0; request < requestCount; request++ {
observed = append(observed, source)
}
}
cacheState := "empty"
if cachePresent {
cacheState = "stale"
if cache.Version == source.Version {
cacheState = "fresh"
}
}
outcome := "cold miss filled"
if scenario == AfterUpdate {
switch policy {
case CacheAside:
outcome = "stale cache hit"
case InvalidateWrite:
outcome = "fresh refill after invalidation"
case Bypass:
outcome = "fresh source read"
}
} else if scenario == ConcurrentMiss {
if policy == Bypass {
outcome = "source thundering herd"
} else if fillPolicy == SingleFlight {
outcome = "coalesced cache fill"
} else {
outcome = "cache stampede"
}
} else if policy == Bypass {
outcome = "cold source read"
}
explanation := "The first reader misses, fetches the source, and stores the value for a later reader."
if scenario == AfterUpdate {
switch policy {
case CacheAside:
explanation = "The source is newer, but a cache-aside read does not know that the warm entry is stale unless a freshness or invalidation rule says so."
case InvalidateWrite:
explanation = "The source write commits first, then removes the key. The next read misses, refills, and observes version 2."
case Bypass:
explanation = "Bypassing the cache gives the reader the current source value, but every reader pays the source cost."
}
} else if scenario == ConcurrentMiss {
if fillPolicy == SingleFlight && policy != Bypass {
explanation = "Both readers share one in-flight refill, so the source and cache each do one unit of fill work."
} else {
explanation = "Both readers observe a miss and refill independently. The source receives duplicate work for one key."
}
} else if policy == Bypass {
explanation = "The empty cache is deliberately ignored, so this read is fresh but does not warm a future reader."
}
return CacheRun{
Policy: policy, Scenario: scenario, FillPolicy: fillPolicy, Observed: observed,
CacheState: cacheState, SourceWrites: sourceWrites, SourceReads: sourceReads,
CacheReads: cacheReads, CacheWrites: cacheWrites, Invalidations: invalidations,
Outcome: outcome, Explanation: explanation,
}
} Both examples preserve the source versions, cache policies, miss schedules, observations, and work counts. The language changes the state representation; it does not change the freshness claim.
Copy the complete examplesStandard library only
These files model cache policy without pretending to be a Redis client or a distributed lock. Use the same sequence to test your actual cache adapter and source failure behavior.
TypeScriptnode --experimental-strip-types pricing.ts
Gogo run pricing.go
Cache correctness is a system boundary.
This lesson establishes stale-versus-fresh observations and duplicate refill work in one deterministic model. It does not establish distributed ordering, clock accuracy, eviction behavior, serialization, network partitions, or source performance under real load.
Define ownership, key construction, TTL and stale-read rules, negative caching, invalidation delivery, retry limits, and what happens when the cache or source is unavailable. Then test the policy with production-shaped traffic.
Build UIs?See where this shows up in your components.
Make freshness part of the response contract.
If a caller can act on the value, say whether it may be stale and which version or timestamp it represents. A fast response without a freshness rule is an implicit product decision.
Choose a freshness policy the caller can explain.
Prices change through an admin write, while product pages read them heavily. Which next move gives the stale-data claim a concrete recovery path?
Prices change through an admin write, while product pages read them heavily.
What cache policy gives the stale-data claim a concrete recovery path?
Make the next cache decision cheaper.
Record the source of truth, key, freshness window, invalidation event, miss fallback, and coordination rule before looking at the cache hit rate.
- Why
- Product pages read prices heavily, and the source must stay the authority when a price changes.
- What
- Cache-aside for reads, invalidate the exact key after the source commit, and single-flight fills for concurrent misses.
- Constraint
- The invalidation signal must reach every relevant cache, and the stale window a caller may see must be stated.
- Fallback
- When invalidation is lost or the cache is unavailable, a TTL bounds the stale window and reads go to the source with a deadline.
- Reconsider when
- Write rate, key construction, cache topology, or the acceptable staleness changes, or the source cannot absorb a burst of misses.
A freshness note to adapt to your own cache. Nothing here is saved to an account.
Explore more concepts & practices →