Skip to content
advanced Phase 14 · Queues, Caching, and Background Jobs

Cache Invalidation Strategies

Keep cached data fresh without serving stale results.

45m
0 problems
Topic Progress 0%

One Order, Three Screens: Keys, Tags, and Targeted Invalidation

An order is edited. The order page, the customer's history, and the monthly revenue widget all show the old total until three unrelated TTLs expire. The bug is not the TTL — it is that you cached three derived views under keys nobody could connect back to the entity.

Key design: every input belongs in the key

A cache key is a promise: this exact string maps to this exact value. Anything that changes the output must be part of the string.

// wrong: same key for every locale, currency, and price tier
$key = "product:{$product->id}";

// right: one key per rendered variant
$key = sprintf(
    'product:%d:%s:%s:v%d',          // v = schema version, bump to invalidate everything
    $product->id,
    $locale,                          // en-GB vs de-DE
    $user?->isVip() ? 'vip' : 'std',
    $product->prices_version          // only changes when prices change
);

That gives you a hierarchy of invalidation strategies, each with a different blast radius:

Strategy Mechanism Blast radius Cost
Short TTL expire and recompute one key, bounded staleness recurring miss cost
Key versioning bump v in the key every variant of one entity old keys linger until TTL
Tag invalidation flush a tag set every entry derived from one entity needs a tag-capable store
Explicit delete Cache::forget() exactly one entry you must remember all keys

Tag-based invalidation

Laravel lets you group entries by entity and drop the group the moment the entity changes:

use Illuminate\Support\Facades\Cache;

// writing: attach tags while caching
Cache::tags(['orders', "order:{$order->id}", "customer:{$order->customer_id}"])
     ->remember("totals:{$order->id}", now()->addHour(),
        fn () => $this->computeTotals($order));

// after the update: drop everything derived from this order
Cache::tags(["order:{$order->id}"])->flush();

Tags are supported by the file, database, memcached, redis, dynamodb, and array stores — check your store first, because a flush against a non-tag store throws rather than silently doing nothing. Each tag maps to a set of member keys, so flush() deletes only entries tagged with that entity while unrelated caches survive. That is the difference between Cache::flush() (drop the world, deploy stampede) and invalidating 40 entries belonging to order 512.

Tags solve the derived views problem from the opening scenario: tag every view with order:512, flush that one tag after the write, and all three screens refresh together. The limitation is that tags do not compose across queries — you cannot tag "every order page" generically — so paginated lists and search results still need versioned keys or short TTLs.

Bounding staleness with TTL

A TTL is an upper bound on how wrong a cached value can ever be. Choose it from the product question, not from habit:

// "prices may lag by at most 60 seconds during a sale" → TTL 60s
Cache::put($key, $value, now()->addSeconds(60));

// negative caching: remember "no results" briefly, so hot bad queries calm down
Cache::put("search:{$query}", [], now()->addSeconds(15));

Short TTLs convert a correctness bug into a latency blip; long TTLs do the reverse. When a value must be exactly right at read time — inventory count, balance — the answer is not a cleverer cache, it is no cache or a read-through to the row.

The remaining failure mode is not staleness but concurrency: while a hot key sits one second from expiry, thousands of requests miss at once and each recomputes. That is the stampede, and it needs locks — covered in the next chapter — while the cron-side cousin of invalidation (nightly recomputation of expensive aggregates) belongs to Scheduling Recurring Tasks. Review the store capabilities you are relying on in Caching with Redis and Files before you ship any of this: tags and locks are store features, not framework features.

The Hot Key at Midnight: TTL Jitter, Locks, and Stale-While-Revalidate

At 00:00 the monthly-reports key expires. At that second 4,000 requests all miss, all run the 900 ms aggregation, and all write the same value — the database spikes, the p99 goes to two seconds, and the cache you installed to protect the database becomes the thing that detonates it. A cache stampede is a herd of simultaneous misses on one key, and the cure is ensuring only one process recomputes.

Laravel gives you three tools, from cheapest to most deliberate.

1. Jittered expiry — stop the synchronized clock

If every entry expires exactly at T, every miss lands in the same millisecond. Randomize the boundary so the herd spreads out:

// probabilistic early expiry: refresh a fraction of requests before hard TTL
$ttl  = 600;
$soft = $ttl - random_int(0, 60);   // begin recomputing in the last minute

$value = Cache::get($key);
if ($value === null || time() > $value['expires']) {
    $value = ['data' => $this->build(), 'expires' => time() + $ttl];
    Cache::put($key, $value, $ttl);
}

Each request computes its own refresh point, so misses arrive as a trickle instead of a cliff. Laravel 11.23+ ships this as stale-while-revalidate:

// serve stale value immediately, refresh once in the background
$data = Cache::flexible('monthly-report', ['stale' => 60, 'release' => 600]);

The stale window (stale) is your bounded staleness; release is the hard TTL after which the entry is gone. Users never wait for the rebuild — that is the whole point.

2. Atomic locks — one builder, everyone else waits

When only one process may compute, use the store's compare-and-set primitive:

$lock = Cache::lock("report:{$month}", 10);       // key, seconds-to-auto-release

try {
    $result = $lock->block(3, fn () => $this->buildReport($month));
} finally {
    $lock->release();
}

block(3) spins (with backoff) for up to three seconds waiting for the owner; if the owner dies, the lock auto-releases after 10 seconds — always set that expiry, or a crashed builder blocks the key forever. Locks require a lock-capable store (redis, database, file, array, memcached); on anything else you get a runtime error, which is the safe behavior.

The distributed variant: when many web servers share one Redis, Cache::lock() is a cross-machine mutex — exactly what the scheduler uses for withoutOverlapping() in Scheduling Recurring Tasks, and what unique jobs (ShouldBeUnique) use to prevent duplicate dispatch.

3. Rebuild out of band

The strongest pattern: let the miss path return stale-or-empty cheaply and have a worker rebuild the key. Dispatch a job from the miss handler (idempotent, ShouldBeUnique), and the queued-job pipeline absorbs the load at your worker's pace instead of the web tier's.

Deciding, in one table

Symptom Tool Cost
Keys expire together, small herd TTL jitter / Cache::flexible a bounded stale window
One expensive key, thundering herd Cache::lock()->block() waiters burn CPU spinning
Rebuild itself is slow/heavy queue the rebuild, serve stale temporary staleness by design
Key is never hot again short TTL, skip the cache pay the compute every time

The trade-off you are making is explicit: you are trading strict freshness for latency stability. If an interviewer pushes ("but now users see old data?"), the answer is the stale window number you chose — 60 seconds of staleness on a report beats a two-second page for every visitor, and it is a product decision you can state, not an accident of TTLs.

Delete After Commit: Write Ordering and the Staleness You Accept

The write path is where cache correctness actually dies. Reads are easy: hit or miss. The question that matters is what exactly do you do to the cache when the database row changes, and in which order?

Delete after commit — never before

$order->update(['total' => $newTotal]);   // 1. database commit
Cache::forget("order:{$order->id}");      // 2. THEN invalidate

Reverse the order and you have a race: reader A misses, computes the old value, writer B updates the DB and deletes the cache, then A's stale write lands and sits there for the full TTL. The database is the source of truth, so the cache must be made stale after the truth changes — and the delete must happen after the transaction commits, not inside it:

DB::transaction(function () use ($order) {
    $order->update(['total' => $newTotal]);
    Cache::forget("order:{$order->id}");   // inside the transaction: a rollback leaves a wiped cache
});

If the transaction rolls back after you deleted, the cache is empty (harmless — next read rebuilds). If you deleted first and the transaction rolled back, the cache holds a value derived from a state that never existed. Commit-then-delete, or Laravel's DB::afterCommit(fn () => Cache::forget(...)).

Why delete rather than update

Updating in place invites the lost-writer race: two requests update the same order, their cache writes interleave in the wrong order, and the older value wins. Invalidation is a one-way operation with no ordering problem — every subsequent read rebuilds from the committed row. The known residual race is small enough to handle deliberately: the double delete pattern deletes once after commit and again after a short delay (say 1 second), catching any reader that rebuilt with pre-commit data during the gap.

Choosing the strategy per entity

Write pattern Recommended treatment Why
Occasional edits, read-heavy cache-aside + delete on write cheapest; staleness window ≈ rebuild time
Must read-your-writes delete + short TTL, or read-through on miss bounds the window to seconds
Derived aggregates (reports) rebuild by scheduled job / queue compute off the request path
High-churn rows (counters) don't cache — index or materialize miss rate approaches 100%

The last row is the honest one: a cache with a 30% hit rate is pure overhead — you pay serialization, network, and invalidation bookkeeping to save nothing. The metric that decides caching is the hit rate multiplied by the miss cost; if either factor is small, skip the cache and fix the query instead (an index from Indexing and Query Tuning is a permanent fix where a cache is a lease).

When the cache still lies

Even with correct ordering, three situations produce stale reads that look like bugs:

  1. Multi-server invalidation lag with non-shared stores. The file driver deletes on one server only; other servers serve the old copy until TTL. This is a driver property, not a bug — see Caching with Redis and Files.
  2. Key omits a variant. The vip user's price was cached under a key without the tier, so the std user sees it. Key composition from the first chapter of this topic is the fix.
  3. A queued write. The row was updated by a job that ran after the read that rendered the page — invalidation cannot travel back in time; a shorter TTL is the only bound.

Debug all three the same way: print the key, the value, and the row side by side. redis-cli get "prefix:order:512" next to SELECT * FROM orders WHERE id = 512 settles whether you have a key problem, a timing problem, or a driver problem in under a minute.

Interviewers probe this area with "how do you keep cached data fresh?" The strong answer names the ordering rule (delete after commit), the reason deletion beats update (no write-ordering race), the bound you accept (TTL as staleness ceiling), and the case where you refuse to cache. From here, the natural extension is automating the rebuilds — Scheduling Recurring Tasks.

Quiz

1. A product's cached page must differ by locale, currency, and VIP tier. What is the correct key?

Question 1 options

2. 4,000 requests hit a key in the same second it expires, each recomputing a 900 ms report. What is the canonical fix?

Question 2 options

3. Where should Cache::forget() run relative to the database update that changed the value?

Question 3 options

4. Your invalidation logic is correct, but users on web02 still see old prices after edits made through web01. What is the most likely cause?

Question 4 options

Flashcards

Question

The four invalidation strategies and their blast radii

Answer

Short TTL: one key, staleness bounded by the expiry. Key versioning (bump v): every variant of one entity, old keys linger till TTL. Tag invalidation (Cache::tags([...])->flush()): every entry derived from one entity, needs a tag-capable store. Explicit Cache::forget(): exactly one entry — you must know every key.

Question

What a cache stampede is, and the three tools that stop it

Answer

A herd of simultaneous misses on one hot key, usually at synchronized expiry, each recomputing the expensive value. Tools: (1) jittered/probabilistic expiry or Cache::flexible stale-while-revalidate, (2) Cache::lock()->block() so exactly one process rebuilds, (3) rebuild out of band via a queued job while serving stale.

Question

Why invalidate by delete rather than by writing the new value into the cache

Answer

Update-in-place has an ordering race: two writers can interleave so the older computed value lands last and sticks for a full TTL. A delete is order-free — every later read rebuilds from the committed row. Residual race (reader rebuilding pre-commit data) is handled by delete-after-commit plus an optional delayed second delete.

Question

What TTL you should choose, in product terms

Answer

The TTL is the upper bound on how stale cached data can ever be: set it to the longest staleness you could defend in an incident (60s for live prices, hours for reference data, none for balances you simply don't cache). Staleness beyond that must be prevented by invalidation, not tolerated.

Question

When you should not cache at all

Answer

When hit rate multiplied by miss cost is small: high-churn rows (counters, live inventory) miss nearly always and pay serialization plus invalidation for nothing — fix the query with an index or a materialized view instead. Same verdict when the data must be read-your-writes correct.

Revision Notes

Key Takeaways

  • 1. The key is the contract: any input that changes the output must appear in it, and a version segment gives you cheap mass invalidation
  • 2. Tag-based invalidation flushes every entry derived from one entity at once — a store feature, so verify tag support before designing around it
  • 3. A stampede is synchronized misses on a hot key; jitter, stale-while-revalidate, and a single-builder lock each remove the synchronization
  • 4. Delete after commit, never update in place: the delete has no write-ordering race and the commit boundary prevents pre-commit rebuilds
  • 5. TTL is your staleness ceiling; when the required ceiling approaches zero, stop caching and fix the read path instead

Interview Tips

  • • "How do you keep cached data fresh?" — name delete-after-commit as the ordering rule, deletion over update because of write races, TTL as the bound, and tags for multi-view entities
  • • "Explain a cache stampede and how you'd prevent it" — synchronized expiry plus concurrent misses; answer with lock-based single rebuild, jittered expiry, and stale-while-revalidate, noting the lock needs an auto-release expiry
  • • "Cache invalidation is hard — prove it isn't for you" — give the four-strategy table (TTL, versioning, tags, explicit delete) and the one hard rule (key composition), showing you choose blast radius deliberately
  • • "When would you refuse to cache?" — hit rate × miss cost is low, or correctness demands read-your-writes; mention the permanent alternative (index/materialized view) to show you optimize the read path, not just hide it

Cheat Sheet

Cache Invalidation Strategies

Keys: every output-changing input + a version segment

$key = sprintf('product:%d:%s:%s:v%d', $id, $locale, $tier, $pricesVersion);

Tags (file, database, memcached, redis, dynamodb, array stores):

Cache::tags(['orders', "order:{$id}"])->remember("totals:{$id}", $ttl, $fn);
Cache::tags(["order:{$id}"])->flush();   // one entity, all derived views

Stampede control:

$data = Cache::flexible('report', ['stale' => 60, 'release' => 600]);  // SWR
$lock = Cache::lock("report:$m", 10);     // always set expiry
$lock->block(3, fn () => $this->build()); // one builder, others wait

Write path: update DB → delete cache after commit (DB::afterCommit) — delete, don't update (no write-order race); optional delayed second delete for the rebuild gap.

Decide: staleness budget ⇒ TTL · many derived views ⇒ tags · mass schema change ⇒ version bump · churny data ⇒ don't cache, index it.

Continue Learning

Before You Start

Related Reading