Introduction
Caching is one of the oldest performance levers in software engineering, and yet it remains one of the most misunderstood. Most developers have reached for stale-while-revalidate (SWR) at some point-it's ergonomic, it ships fast, and it solves the latency problem in a way that feels almost magical. Serve the cached copy immediately, kick off a background refresh, done. For a wide class of read-heavy applications with tolerably loose freshness requirements, it does exactly what it promises.
The trouble begins when your data model grows up. When cache entries carry different TTLs, when downstream writes must propagate within seconds rather than minutes, or when a stale response could surface wrong pricing, an expired session, or an inconsistent entity graph, SWR starts to look less like a silver bullet and more like a loaded footgun with a comfortable grip. The question engineers eventually ask is not "should I cache?" but "what caching contract does this data actually need?"
This article is an honest technical answer to that question. We'll examine what SWR gets right, where it structurally cannot deliver, and then work through a set of more powerful alternatives-event-driven invalidation, tiered caching with promotion, read-through with probabilistic refresh, and write-through synchronization-that you can reach for once SWR has outlived its usefulness. Each pattern comes with concrete implementation guidance, realistic trade-offs, and production-grade code examples. The goal is not to convince you that SWR is bad, but to show you the full toolbox it belongs to.
What Stale-While-Revalidate Actually Promises
The stale-while-revalidate directive was standardized in RFC 5861, an extension to HTTP caching semantics. In its simplest form it extends Cache-Control to allow a response to be served stale-past its max-age-for a configurable window while the cache asynchronously fetches a fresh copy. The contract is explicit: the client accepts that the response it receives may be up to stale-while-revalidate seconds old beyond the normal TTL, in exchange for zero added latency on that request.
In JavaScript frontend frameworks, this same mental model was popularized by Vercel's swr library and TanStack Query, which apply it to client-side data fetching. The cache hit is served immediately from memory or local storage; a fetch fires in the background; the UI re-renders if anything changed. This is an excellent default for dashboard widgets, social feeds, and configuration data where users are tolerant of a few seconds of staleness and the visual experience of "instant load then quiet update" is desirable.
What SWR cannot promise is bounded propagation latency. If a write occurs on the server and you need that write visible to all readers within a hard deadline-say, 500 milliseconds-SWR offers no mechanism to enforce that. The background revalidation fires after the next read, not after the write. In distributed systems where writes happen on a different node from the cache, SWR has no awareness of those writes at all. It is purely a time-based policy, and time-based policies are inherently decoupled from the events that actually change data.
The Core Problem: Time-Based vs. Event-Based Freshness
At its heart, every caching policy makes a bet: either it bets that time is a good proxy for data change, or it bets that it can know when data actually changes. Stale-while-revalidate, TTL-based expiry, and their cousins are all time-based bets. They're cheap to implement because they require no coupling between the write path and the cache. You simply declare "this entry is valid for N seconds" and walk away. The cache layer and the write layer never need to talk.
Time-based policies work well when writes are infrequent, when the write-to-read ratio is low, and when the acceptable staleness window is larger than the interval between writes. A product catalog that updates twice a day fits this model perfectly-a five-minute TTL is almost always fresh and the cost of a few stale reads is negligible. The model breaks down as write frequency increases, as the acceptable staleness window shrinks, or as different cache keys have radically different write frequencies. In those conditions, you're either setting TTLs too aggressively (causing thundering herd on the origin) or too conservatively (serving stale data that users or auditors will complain about).
Event-based freshness inverts the dependency. Instead of the cache deciding when to refresh based on elapsed time, the write path tells the cache when an entry is no longer valid. This is strictly more expressive because it operates at write time rather than read time, meaning propagation latency is bounded by the latency of your messaging infrastructure rather than your TTL window. The cost is coupling: the component that writes data must now also know-or at least signal-that a cache entry exists and should be invalidated. That coupling must be managed carefully to avoid tight cohesion between otherwise independent services.
The practical implication is that these two approaches are not mutually exclusive. Production systems typically layer them: event-driven invalidation handles the hot path where freshness is critical, while time-based TTLs act as a backstop for the long tail of cases where events might be dropped, delayed, or simply never generated. Understanding this layering is the key to designing caches that are both fast and trustworthy.
Pattern 1: Event-Driven Cache Invalidation
Event-driven invalidation is the most semantically precise caching strategy available. The core idea is that any mutation to a piece of data publishes an event-a cache invalidation message-on a channel that cache nodes subscribe to. When the message arrives, the cache evicts or updates the affected entries immediately, without waiting for a TTL to expire or for a subsequent read to trigger a background fetch.
The implementation substrate can range from Redis Pub/Sub to Kafka topics to a simple HTTP webhook fan-out, depending on your consistency and durability requirements. Redis Pub/Sub is the lightest-weight option and works well for single-datacenter deployments where message loss on subscriber restart is acceptable. Kafka or similar durable log systems are appropriate when you need guaranteed delivery and want to replay invalidation events for new cache nodes joining the cluster. The choice is fundamentally a durability trade-off: how bad is it if an invalidation message is lost?
TypeScript: Redis Pub/Sub Invalidation
import { createClient, RedisClientType } from "redis";
const INVALIDATION_CHANNEL = "cache:invalidate";
interface CacheEntry<T> {
value: T;
cachedAt: number;
}
class EventDrivenCache<T> {
private store = new Map<string, CacheEntry<T>>();
private subscriber: RedisClientType;
constructor(subscriber: RedisClientType) {
this.subscriber = subscriber;
}
async subscribe(): Promise<void> {
await this.subscriber.subscribe(
INVALIDATION_CHANNEL,
(message: string) => {
const keys: string[] = JSON.parse(message);
for (const key of keys) {
this.store.delete(key);
console.log(`[Cache] Evicted key: ${key}`);
}
}
);
}
get(key: string): T | undefined {
return this.store.get(key)?.value;
}
set(key: string, value: T): void {
this.store.set(key, { value, cachedAt: Date.now() });
}
}
// On the write path (separate service):
async function updateProductAndInvalidate(
publisher: RedisClientType,
productId: string,
patch: Record<string, unknown>
): Promise<void> {
// 1. Persist the change
await persistProductPatch(productId, patch);
// 2. Publish invalidation event
const affectedKeys = [
`product:${productId}`,
`product:${productId}:detail`,
`product-list:category:${patch.categoryId ?? "*"}`,
];
await publisher.publish(
INVALIDATION_CHANNEL,
JSON.stringify(affectedKeys)
);
}
async function persistProductPatch(
_id: string,
_patch: Record<string, unknown>
): Promise<void> {
// Database write implementation
}
A subtle but critical implementation detail is the atomicity boundary around your write and your publish. If you persist the database change but crash before publishing the invalidation, your caches will serve stale data indefinitely-or until your TTL backstop expires. One mitigation is the transactional outbox pattern: the write and the event record are committed in the same database transaction, and a separate relay process reads the outbox table and publishes events. This guarantees at-least-once delivery at the cost of added infrastructure complexity.
Another detail is key cardinality. Invalidation messages need to reference the exact cache keys that were affected. If your cache key construction logic is spread across dozens of call sites, keeping the invalidation logic in sync becomes a maintenance burden. Centralizing key construction behind a deterministic function-and using the same function on both cache writes and invalidation events-is a worthwhile investment.
Pattern 2: Tiered Caching with Promotion
Tiered caching acknowledges that not all cache entries deserve the same residence policy. Rather than treating every key identically, you maintain multiple cache layers with different speed, capacity, and eviction characteristics-and promote entries between tiers based on observed access patterns.
The classic two-tier layout pairs an in-process memory cache (L1) with a shared distributed cache like Redis or Memcached (L2). L1 is orders of magnitude faster-a HashMap lookup in the same JVM or Node.js process takes nanoseconds; a Redis round-trip takes hundreds of microseconds to low milliseconds-but it is local and bounded in size. L2 is shared across all application instances and can hold far more data, but adds network overhead to every miss. The promotion logic is simple: a hit in L2 populates L1 so the next request for the same key pays only the in-process cost.
This pattern compounds well with event-driven invalidation. When an invalidation event fires, it must evict the entry from both tiers. Failing to evict L1 means requests served by that process continue to see stale data even after L2 is refreshed. A common implementation is to include the application instance ID in the invalidation message and broadcast to all instances, each of which evicts from its own L1.
Python: Two-Tier Cache with Promotion
import time
from collections import OrderedDict
from typing import Optional, TypeVar, Generic, Callable, Awaitable
import asyncio
T = TypeVar("T")
class LRUCache(Generic[T]):
"""Simple bounded LRU cache for L1 (in-process)."""
def __init__(self, max_size: int, ttl_seconds: float):
self.max_size = max_size
self.ttl = ttl_seconds
self._store: OrderedDict[str, tuple[T, float]] = OrderedDict()
def get(self, key: str) -> Optional[T]:
if key not in self._store:
return None
value, expires_at = self._store[key]
if time.monotonic() > expires_at:
del self._store[key]
return None
self._store.move_to_end(key)
return value
def set(self, key: str, value: T) -> None:
expires_at = time.monotonic() + self.ttl
self._store[key] = (value, expires_at)
self._store.move_to_end(key)
if len(self._store) > self.max_size:
self._store.popitem(last=False)
def evict(self, key: str) -> None:
self._store.pop(key, None)
class TieredCache(Generic[T]):
"""
Two-tier cache: L1 (in-process LRU) backed by L2 (distributed, e.g., Redis).
L2 access is abstracted via async get/set callables.
"""
def __init__(
self,
l1: LRUCache[T],
l2_get: Callable[[str], Awaitable[Optional[T]]],
l2_set: Callable[[str, T], Awaitable[None]],
origin_fetch: Callable[[str], Awaitable[Optional[T]]],
):
self.l1 = l1
self.l2_get = l2_get
self.l2_set = l2_set
self.origin_fetch = origin_fetch
async def get(self, key: str) -> Optional[T]:
# L1 hit
value = self.l1.get(key)
if value is not None:
return value
# L2 hit -> promote to L1
value = await self.l2_get(key)
if value is not None:
self.l1.set(key, value)
return value
# Origin fetch -> populate both tiers
value = await self.origin_fetch(key)
if value is not None:
self.l1.set(key, value)
await self.l2_set(key, value)
return value
def invalidate_local(self, key: str) -> None:
"""Called when an invalidation event is received from the message bus."""
self.l1.evict(key)
Sizing L1 is a practical judgment call that many engineers underestimate. Too small and the hit rate is poor, negating the latency benefit. Too large and you risk consuming heap that should be available for request processing, causing GC pressure in JVM-based systems or memory exhaustion in constrained environments. A useful heuristic is to profile the key distribution of your hot path and size L1 to fit the working set-the set of keys that account for 80-90% of your cache reads. For most web applications, this is a surprisingly small number of keys.
Pattern 3: Read-Through with Probabilistic Early Expiry
Probabilistic early expiry-sometimes called "early expiration" or the "XFetch" algorithm, described by Vattani, Chierichetti, and Lowenthal in the context of cache stampede prevention-is a technique that proactively refreshes a cache entry before it actually expires, using a probabilistic function that becomes more likely to trigger as the entry approaches its TTL. The result is that entries are refreshed in a distributed way, with individual requests absorbing the refresh cost without coordinating with each other, rather than all requests discovering a cold miss simultaneously when the TTL finally lapses.
The algorithm works as follows: when a cache read occurs, instead of simply checking whether the entry is expired, you compute a jittered virtual expiry based on the remaining TTL and the time the last fetch took. If this virtual expiry has passed, you treat the entry as a cache miss and refresh it, even though the real TTL has not yet elapsed. The probability of triggering a refresh increases exponentially as the real expiry approaches.
This matters most for high-traffic cache keys where a simultaneous TTL expiry causes a stampede-hundreds or thousands of requests all hitting the origin database at the same instant. Mutex-based stampede protection (a single request acquires a lock and refreshes while others wait) is the most common mitigation, but it adds lock contention and can cause latency spikes during the lock-wait period. Probabilistic refresh distributes the refresh cost across time without requiring coordination.
TypeScript: Probabilistic Early Refresh
interface CacheValue<T> {
data: T;
delta: number; // Time (ms) the last fetch took
expiry: number; // Unix ms when entry expires
}
async function readWithProbabilisticRefresh<T>(
key: string,
ttlMs: number,
fetch: () => Promise<T>,
cacheGet: (key: string) => Promise<CacheValue<T> | null>,
cacheSet: (key: string, value: CacheValue<T>, ttlMs: number) => Promise<void>,
beta = 1.0 // Higher beta = more aggressive early expiry
): Promise<T> {
const now = Date.now();
const cached = await cacheGet(key);
if (cached !== null) {
// XFetch: virtual expiry check
// virtualExpiry = expiry - delta * beta * log(random())
const rand = Math.random();
const virtualExpiry = cached.expiry - cached.delta * beta * Math.log(rand);
if (now < virtualExpiry) {
// Still fresh (or not yet probabilistically expired)
return cached.data;
}
// Probabilistic early expiry: proceed to refresh
}
// Cache miss or probabilistic expiry triggered
const fetchStart = Date.now();
const freshData = await fetch();
const delta = Date.now() - fetchStart;
await cacheSet(key, {
data: freshData,
delta,
expiry: Date.now() + ttlMs,
}, ttlMs);
return freshData;
}
One practical concern with probabilistic refresh in read-through architectures is that the origin must be able to handle slightly higher than expected read rates during high-traffic periods, since multiple requests may independently decide to refresh the same key in a short window. This is generally far better than a full stampede, but it's worth benchmarking. Increasing the beta parameter makes early expiry more aggressive and spreads refreshes further from the actual expiry boundary, at the cost of fetching data slightly earlier than necessary.
Pattern 4: Write-Through and Write-Behind Caching
Write-through caching is the tightest consistency guarantee available short of cache-aside with synchronous invalidation. In a write-through setup, every write to the backing store simultaneously writes to the cache. The write is not considered complete until both the database and the cache have acknowledged it. This means that reads immediately after a write are guaranteed to see the updated value-there is no staleness window to reason about.
The trade-off is write latency. Every mutation now involves two I/O operations instead of one. For write-heavy workloads or latency-sensitive mutation paths, this can be prohibitive. Write-through is best applied selectively-to the small set of cache keys that must be immediately consistent-rather than uniformly across all cached data.
Write-behind (also called write-back) is a variant where the write is acknowledged after the cache is updated, and the database write happens asynchronously in the background. This gives write-through consistency guarantees to the reader (because the cache is immediately updated) while improving write latency. The cost is durability: if the cache node fails before flushing to the database, the write is lost. This makes write-behind appropriate only for data where loss of the most recent write is acceptable-analytics counters, view counts, and similar eventually consistent metrics are good candidates. It is a poor choice for financial transactions, inventory counts, or any data with audit requirements.
// Write-through: update cache synchronously with the database
async function writeThrough<T>(
key: string,
value: T,
dbWrite: (value: T) => Promise<void>,
cacheSet: (key: string, value: T) => Promise<void>
): Promise<void> {
// Both must succeed; if cacheSet fails, the write should not be considered complete.
// Use a try/catch and compensate if cacheSet fails after dbWrite succeeds.
await dbWrite(value);
try {
await cacheSet(key, value);
} catch (err) {
// Log and schedule a retry or invalidation.
// The DB has the truth; the cache miss on next read will self-heal.
console.error(`Write-through cache update failed for key ${key}:`, err);
// Optionally publish invalidation so stale data is not served
throw err;
}
}
// Write-behind: update cache immediately, flush to DB asynchronously
class WriteBehindBuffer<T> {
private pending = new Map<string, T>();
private flushIntervalMs: number;
constructor(
private cacheSet: (key: string, value: T) => Promise<void>,
private dbWrite: (key: string, value: T) => Promise<void>,
flushIntervalMs = 500
) {
this.flushIntervalMs = flushIntervalMs;
setInterval(() => this.flush(), this.flushIntervalMs);
}
async write(key: string, value: T): Promise<void> {
await this.cacheSet(key, value); // Synchronous cache update
this.pending.set(key, value); // Enqueue for async DB flush
}
private async flush(): Promise<void> {
if (this.pending.size === 0) return;
const batch = new Map(this.pending);
this.pending.clear();
await Promise.allSettled(
Array.from(batch.entries()).map(([k, v]) => this.dbWrite(k, v))
);
}
}
The error-handling semantics of write-through deserve careful attention. If the database write succeeds but the cache write fails, you have an inconsistency: the database has the new value but the cache has the old one. The self-healing behavior of cache-aside architectures (next read repopulates the cache from the database) is a reasonable fallback, but only if you can tolerate a brief window of stale reads. Publishing an invalidation event on cache write failure is a cleaner solution that evicts the stale entry immediately.
Trade-offs and Common Pitfalls
Every caching pattern described here introduces its own failure modes, and engineers who skip this analysis will encounter them in production. Event-driven invalidation is vulnerable to message loss and out-of-order delivery. If your message broker does not guarantee ordering and an invalidation message arrives before the write it was generated by, a subsequent cache miss will refetch the old data and store it-potentially for the entire TTL-even though a fresher write has already been committed. This is particularly insidious in replication-lagged environments where a cache miss might read from a replica that hasn't yet received the write.
Tiered caching introduces the challenge of cross-tier consistency. L1 caches are per-process, and each process has its own copy of a cache entry. Invalidation must reach every running process, not just the distributed L2 layer. In Kubernetes environments with dozens of pods, this means your invalidation fan-out must include every pod. Missing even one pod means requests handled by that pod continue to serve stale data. A common mitigation is to give L1 entries a much shorter TTL than L2-a few seconds rather than minutes-so that even if an invalidation is missed, the staleness window is bounded.
Probabilistic early refresh, while elegant, can be tricky to tune. Setting beta too high in a high-concurrency environment means too many requests will independently refresh the same key, unnecessarily loading the origin. Setting it too low provides insufficient stampede protection. The optimal beta depends on your traffic shape, your origin response time distribution, and your tolerance for origin load spikes. It is advisable to instrument the refresh trigger rate and tune beta based on observed behavior rather than guessing upfront.
Write-through and write-behind both require careful handling of cache eviction races. If L2 uses an LRU eviction policy and evicts a key between your database write and your cache write, the write-through update will succeed but will have inserted a new entry that will be evicted almost immediately under memory pressure. Consistent hashing with predictable eviction policies, combined with adequate cache sizing, is the practical answer. More importantly, always design your read path to be correct on a cache miss-treat the cache as an optimization, not as a source of truth, even when using write-through.
When to Reach for Each Pattern
Choosing a caching strategy is not a matter of ranking patterns from best to worst-it's a matter of matching the pattern's consistency and performance model to the data's actual requirements. A useful mental framework is to start by classifying your cache entries along two axes: write frequency and staleness tolerance.
For low write frequency and high staleness tolerance-think configuration data, static reference tables, product metadata that changes infrequently-time-based TTLs with SWR are entirely appropriate. The simplicity and low operational overhead of this approach are genuine advantages that more sophisticated patterns would sacrifice for no benefit. Do not over-engineer.
For high write frequency or low staleness tolerance, event-driven invalidation is the right first tool. It provides exactly the right abstraction: the cache stays fresh as long as writes publish events, and a TTL backstop handles the edge cases. Tiered caching layers on top of this to handle latency requirements-when even a Redis round-trip is too slow, L1 promotion gives you in-process speed for the hot working set.
Probabilistic early refresh is a performance optimization rather than a consistency strategy. Reach for it when you have high-traffic cache keys with hard TTLs and you've observed thundering herd behavior in your metrics. Write-through is appropriate for a small, carefully selected set of keys where write-after-read consistency is a correctness requirement, not just a preference. Write-behind is a last resort for workloads where write throughput is the binding constraint and durability can be loosened.
Best Practices
Cache key design is the discipline most often neglected and most often responsible for bugs. Keys must be deterministic, canonical, and scoped correctly. Including the tenant ID, user ID (when entries are user-scoped), entity type, entity ID, and version or variant parameters in a structured, predictable format prevents key collisions, simplifies invalidation targeting, and makes debugging dramatically easier. Treat your cache key namespace as a public API-once clients depend on a key structure, changing it requires coordinated deployment.
Observability must be built in from the start, not added later. Every cache layer should emit metrics for hit rate, miss rate, eviction rate, and latency percentiles (p50, p95, p99) broken down by key prefix or entry type. Event-driven invalidation should emit a counter per invalidation event processed, including error counts. With these metrics in place, anomalies-hit rate drops, unexpected eviction spikes, invalidation event backlogs-are visible before they produce user-facing errors. Cache debugging without these metrics is essentially impossible at scale.
Testing caching logic is harder than most engineers expect. Unit tests that mock the cache layer are nearly worthless for catching the class of bugs that caching patterns actually produce-race conditions, TTL expiry edge cases, invalidation ordering bugs. Integration tests that run against a real Redis instance with a short, controlled TTL, combined with chaos-style tests that drop invalidation messages and verify recovery behavior, are far more valuable. Make the test environment as close to production as possible, including realistic key distributions and access patterns.
Finally, embrace cache eviction as a design constraint rather than an error condition. Every cache entry will eventually be evicted-by TTL expiry, by LRU pressure, or by explicit invalidation-and your read path must produce correct results when that happens. If your application would serve incorrect data on a cold cache, your caching strategy has a correctness bug, not just a performance issue. Periodic cache flush tests in staging environments are a simple but effective tool for catching this class of problem before it reaches production.
Analogies and Mental Models
The relationship between SWR and event-driven invalidation maps cleanly to the difference between polling and push notifications on a mobile device. A polling-based app checks for new messages every N seconds-this is TTL-based caching. It works, but it's inherently latent by up to N seconds and wastes bandwidth when there are no new messages. A push notification system fires the moment a new message arrives-this is event-driven invalidation. It's more complex to operate but delivers freshness that is bounded by the notification infrastructure's latency, not by your polling interval.
Tiered caching is analogous to a desk, a filing cabinet, and an off-site archive. Things you use constantly live on your desk (L1, in-process). Things you use occasionally but don't need instantly live in the filing cabinet (L2, distributed cache). Things you rarely need live off-site (the origin database). When you fetch something from the filing cabinet, you put a copy on your desk so the next access is faster. When something changes, you update the filing cabinet and sweep your desk for outdated copies.
Write-through caching is like writing a check and simultaneously recording it in your checkbook ledger. The ledger (cache) is always in sync with the transaction (database). Write-behind is like collecting receipts throughout the day and updating the ledger each evening-faster in the moment, but if your bag is stolen before the evening reconciliation, those receipts are gone.
80/20 Insight
If you take nothing else from this article, internalize these three concepts and you will handle the vast majority of real-world caching challenges correctly. First, choose between time-based and event-based freshness deliberately, not by default. SWR is the default, and it is the wrong choice for data with low staleness tolerance or high write frequency. The moment you find yourself asking "how stale is too stale?", you are already in event-driven invalidation territory. Second, always pair your caching strategy with a self-healing read path. Every cache is wrong some of the time; your system should produce correct results when it is. Third, instrument everything. A cache hit rate metric is not a vanity metric-it is the leading indicator of whether your caching strategy is working, and a sudden drop in hit rate is almost always the first signal of a production incident. These three practices will prevent 80% of production caching incidents before they happen.
Key Takeaways
-
Audit your staleness tolerance before choosing a strategy. Document the maximum acceptable staleness for each cached entity type. Entries with sub-second requirements need event-driven invalidation; entries with minute-level tolerance can use TTL or SWR.
-
Implement the transactional outbox pattern for event-driven invalidation when using a relational database. This guarantees at-least-once event delivery without relying on application-level dual writes that can fail mid-transaction.
-
Size L1 to your working set, not to your total cache. Profile which cache keys account for 80-90% of your read volume and ensure L1 is large enough to hold that working set with headroom. Oversizing L1 wastes heap; undersizing it erases the latency benefit.
-
Test with cache flushes. Periodically clear your cache in staging and verify that the application produces correct results with a cold cache. Catch correctness bugs here, not in production during a Redis failover.
-
Start with TTL + event-driven invalidation as your default production configuration rather than pure SWR. The operational cost of publishing invalidation events is low, and the consistency improvement is substantial. Reserve pure SWR for truly static or low-stakes data.
Conclusion
Stale-while-revalidate is a well-designed primitive that belongs in every engineer's toolkit-but it is a starting point, not a destination. As data freshness requirements tighten and systems grow more distributed, the time-based bets it makes become increasingly difficult to honor. Event-driven invalidation, tiered caching, probabilistic refresh, and write-through patterns each address specific failure modes that SWR cannot reach, and understanding when to apply each of them is a mark of mature systems thinking.
The most important shift is conceptual: stop thinking of caching as a performance hack applied at the end of development, and start thinking of it as a contract between your write path and your read path. That contract has terms-consistency requirements, latency bounds, durability guarantees-and those terms should drive your pattern selection, not the other way around. When the contract is explicit, the right pattern becomes obvious, and the failure modes become tractable.
The patterns in this article are not exotic research ideas-they are production-tested techniques used at scale by companies like Slack, Cloudflare, Stripe, and others who have published engineering blog posts and conference talks on their caching infrastructure. The references below are a starting point for going deeper on any one of them.
References
- RFC 5861 - HTTP Cache-Control Extensions for Stale Content
Nottingham, M. (2010). https://www.rfc-editor.org/rfc/rfc5861 - RFC 7234 - Hypertext Transfer Protocol (HTTP/1.1): Caching
Fielding, R., Nottingham, M., & Reschke, J. (2014). https://www.rfc-editor.org/rfc/rfc7234 - Optimal Probabilistic Cache Stampede Prevention
Vattani, A., Chierichetti, F., & Lowenthal, K. (2015). Proceedings of the VLDB Endowment, 8(8). Describes the XFetch algorithm for probabilistic early expiry. https://vldb.org/pvldb/vol8/p886-vattani.pdf - Redis Documentation - Pub/Sub
Redis Ltd. https://redis.io/docs/manual/pubsub/ - Designing Data-Intensive Applications
Kleppmann, M. (2017). O'Reilly Media. Chapters 9 and 11 cover distributed consistency, event logs, and cache invalidation strategies in depth. - The Transactional Outbox Pattern
Richardson, C. microservices.io. https://microservices.io/patterns/data/transactional-outbox.html - TanStack Query Documentation - Caching
TanStack. https://tanstack.com/query/latest/docs/framework/react/guides/caching - SWR Documentation - Revalidation
Vercel. https://swr.vercel.app/docs/revalidation - Memcached Internals - Cache Stampede Prevention
Facebook Engineering (2013). Scaling Memcache at Facebook. USENIX NSDI. https://www.usenix.org/conference/nsdi13/technical-sessions/presentation/nishtala - Write-Through vs Write-Behind Caching
Hazelcast Documentation. https://docs.hazelcast.com/hazelcast/latest/data-structures/map-persistence