Cache invalidation is famously one of the two hard problems in computer science (along with naming things and off-by-one errors). The difficulty lies in ensuring caches serve fresh data without sacrificing the performance benefits of caching. Stale data causes bugs; aggressive invalidation eliminates cache benefits. Finding the right balance requires understanding your data's consistency requirements and change patterns.
Caching improves performance by serving data from fast storage instead of recomputing or fetching from slow sources. But cached data becomes stale when the source changes. Invalidation strategies determine when and how to update or remove stale cache entries.
Time-Based Expiration
The simplest invalidation strategy is time-based expiration (TTL). Cache entries automatically expire after a configured duration. After expiration, the next request fetches fresh data.
TTL works well when some staleness is acceptable and change patterns are unpredictable. A product catalog might tolerate 5-minute-old data. A user's profile might accept 1-hour staleness for most fields. Here's how you might implement this in a Laravel service.
class ProductService
{
private const CACHE_TTL = 300; // 5 minutes
public function getProduct(int $id): Product
{
$cacheKey = "product:{$id}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($id) {
return Product::with(['category', 'images'])->findOrFail($id);
});
}
}
TTL's weakness is the tradeoff between freshness and hit rate. Short TTLs provide fresher data but more cache misses. Long TTLs improve hit rates but serve staler data. There's no way to serve fresh data immediately after changes without additional invalidation logic.
Stale-while-revalidate extends TTL by serving stale data while asynchronously refreshing. Users get fast responses even when data is slightly stale, while the cache updates in the background. This approach gives you the best of both worlds: fast responses and eventually fresh data.
class StaleWhileRevalidateCache
{
public function get(string $key, int $ttl, int $staleTtl, callable $fetch): mixed
{
$entry = $this->cache->get($key);
if ($entry === null) {
// Cache miss - fetch synchronously
$value = $fetch();
$this->cache->put($key, [
'value' => $value,
'expires_at' => now()->addSeconds($ttl),
], $ttl + $staleTtl);
return $value;
}
if (now()->gt($entry['expires_at'])) {
// Stale - serve stale data, refresh async
dispatch(function () use ($key, $ttl, $staleTtl, $fetch) {
$value = $fetch();
$this->cache->put($key, [
'value' => $value,
'expires_at' => now()->addSeconds($ttl),
], $ttl + $staleTtl);
});
}
return $entry['value'];
}
}
The separate ttl and staleTtl parameters let you control how long data is considered fresh versus how long you're willing to serve stale data while refreshing. This gives you fine-grained control over the freshness-performance tradeoff.
Event-Based Invalidation
Event-based invalidation removes or updates cache entries when the underlying data changes. Instead of waiting for TTL expiration, changes trigger immediate invalidation. This approach provides the freshest data but requires more implementation effort.
class ProductObserver
{
public function updated(Product $product): void
{
// Invalidate product cache
Cache::forget("product:{$product->id}");
// Invalidate related caches
Cache::forget("category:{$product->category_id}:products");
Cache::tags(['products', 'homepage'])->flush();
}
public function deleted(Product $product): void
{
Cache::forget("product:{$product->id}");
Cache::forget("category:{$product->category_id}:products");
}
}
Event-based invalidation provides immediate consistency but requires knowing all cache keys affected by a change. As systems grow complex, tracking these relationships becomes error-prone. A product change might affect product detail caches, category listing caches, search result caches, recommendation caches, and more.
Cache tags help manage related entries. Tag entries when caching, then invalidate by tag when data changes. This approach simplifies invalidation logic by letting you think in terms of relationships rather than individual keys.
class ProductService
{
public function getProduct(int $id): Product
{
return Cache::tags(['products', "product:{$id}"])
->remember("product:{$id}", 3600, function () use ($id) {
return Product::findOrFail($id);
});
}
public function getCategoryProducts(int $categoryId): Collection
{
return Cache::tags(['products', "category:{$categoryId}"])
->remember("category:{$categoryId}:products", 3600, function () use ($categoryId) {
return Product::where('category_id', $categoryId)->get();
});
}
}
class ProductObserver
{
public function updated(Product $product): void
{
// Invalidate all caches tagged with this product
Cache::tags(["product:{$product->id}"])->flush();
// Also invalidate category if it changed
if ($product->wasChanged('category_id')) {
Cache::tags(["category:{$product->getOriginal('category_id')}"])->flush();
Cache::tags(["category:{$product->category_id}"])->flush();
}
}
}
You'll notice the observer checks whether the category changed. This conditional invalidation prevents unnecessary cache clearing when unrelated fields change.
Write-Through and Write-Behind
Write-through caching updates the cache synchronously with the database. Every write goes to both the database and cache. The cache is never stale because it's updated at write time. This approach works well when you can tolerate slightly slower writes in exchange for guaranteed cache freshness.
class WriteThoughProductRepository
{
public function update(int $id, array $data): Product
{
return DB::transaction(function () use ($id, $data) {
$product = Product::findOrFail($id);
$product->update($data);
// Update cache in same transaction
Cache::put("product:{$id}", $product, 3600);
return $product;
});
}
}
Write-through adds latency to writes but guarantees cache freshness. It works well when write frequency is low relative to reads.
Write-behind (write-back) caching writes to the cache immediately and asynchronously updates the database. This improves write performance but risks data loss if the cache fails before the database write completes. Use this pattern carefully and understand the risks.
class WriteBehindProductRepository
{
public function update(int $id, array $data): Product
{
$product = Product::findOrFail($id);
$product->fill($data);
// Update cache immediately
Cache::put("product:{$id}", $product, 3600);
// Queue database write
dispatch(new PersistProductUpdate($id, $data));
return $product;
}
}
Write-behind is appropriate when write speed is critical and some data loss is acceptable, such as analytics or session data.
Cache Stampede Prevention
Cache stampede occurs when many requests simultaneously find an expired cache entry and all try to regenerate it. Instead of one expensive operation, you get hundreds, potentially overwhelming the database.
Locking prevents stampede by ensuring only one request regenerates the cache. Other requests wait for the result or receive stale data. This pattern is essential for high-traffic applications with expensive cache regeneration.
class StampedeProtectedCache
{
public function remember(string $key, int $ttl, callable $fetch): mixed
{
$value = $this->cache->get($key);
if ($value !== null) {
return $value;
}
// Try to acquire lock
$lock = Cache::lock("lock:{$key}", 10);
if ($lock->get()) {
try {
// Double-check after acquiring lock
$value = $this->cache->get($key);
if ($value !== null) {
return $value;
}
$value = $fetch();
$this->cache->put($key, $value, $ttl);
return $value;
} finally {
$lock->release();
}
}
// Couldn't get lock - wait for other process
return $lock->block(5, function () use ($key, $fetch, $ttl) {
$value = $this->cache->get($key);
return $value ?? $fetch();
});
}
}
The double-check pattern after acquiring the lock is important. Another request might have populated the cache while this request was waiting for the lock.
Probabilistic early expiration regenerates cache entries before they expire based on probability. As expiration approaches, requests have increasing probability of triggering regeneration. This spreads regeneration load over time rather than concentrating it at expiration.
public function getWithEarlyExpiration(string $key, int $ttl, callable $fetch): mixed
{
$entry = $this->cache->get($key);
if ($entry === null) {
$value = $fetch();
$this->cache->put($key, [
'value' => $value,
'created_at' => now(),
], $ttl);
return $value;
}
// Calculate remaining TTL
$age = now()->diffInSeconds($entry['created_at']);
$remaining = $ttl - $age;
// Probabilistically refresh as expiration approaches
$probability = 1 - ($remaining / $ttl);
if (mt_rand() / mt_getrandmax() < $probability * 0.1) {
dispatch(function () use ($key, $ttl, $fetch) {
$value = $fetch();
$this->cache->put($key, [
'value' => $value,
'created_at' => now(),
], $ttl);
});
}
return $entry['value'];
}
The probability calculation ensures that entries closer to expiration are more likely to be refreshed. The 0.1 multiplier keeps the overall refresh rate low while still preventing stampedes.
Versioned Cache Keys
Version-based invalidation embeds a version number in cache keys. Incrementing the version effectively invalidates all entries without explicitly deleting them. This approach is particularly useful when you need to invalidate many related entries at once.
class VersionedCache
{
public function getProduct(int $id): Product
{
$version = $this->cache->get('products:version', 1);
$key = "product:{$id}:v{$version}";
return Cache::remember($key, 3600, function () use ($id) {
return Product::findOrFail($id);
});
}
public function invalidateAllProducts(): void
{
$this->cache->increment('products:version');
}
}
This approach is useful for bulk invalidation. Instead of tracking and deleting thousands of individual keys, increment one version number. Old entries remain but are never accessed; they expire naturally.
Conclusion
Cache invalidation requires matching strategy to consistency requirements. TTL provides simplicity with bounded staleness. Event-based invalidation provides immediate consistency with implementation complexity. Write-through guarantees freshness at the cost of write latency.
Prevent cache stampedes through locking or probabilistic early expiration. Use cache tags to manage related entries. Consider versioned keys for bulk invalidation.
The best strategy depends on your specific tradeoffs: how stale can data be? How complex can invalidation logic get? How critical is write performance? Understanding these tradeoffs enables choosing the right invalidation approach for each use case.