Caching: When Read-Path and Write-Path Diverge
The cache was wrong after every update. The write path had no idea the read path was caching.
Introduction
Caching is used to speed up reads. You store a copy of data (in memory, Redis, or the browser) and serve it instead of hitting the source every time. The catch: when the source changes, the cache can become wrong. That wrongness often comes from the read path and the write path not following the same rules. This post is about how that happens and how to align them.
The Classic Mistake
A typical setup: the app reads an entity (e.g. a candidate or a job) from the database and caches it by ID. When the user updates the entity, the app writes to the database and then... does nothing to the cache. So the next read returns the old cached value. The write path updated the source of truth but not the cache; the read path still prefers the cache. So read-path and write-path diverge: the writer does not know about the cache, or does not invalidate or update it.
The fix is to make the write path part of the caching contract. On every write that changes data that is cached, you must either invalidate the cache (delete the key so the next read refetches) or update the cache (write the new value). Then the read path and the write path both respect the same rule: "the cache holds the latest value we care about, and we keep it in sync on write."
Other Places They Diverge
Read path and write path can diverge in other ways:
- Different keys: The read path caches by (userId, jobId). The write path invalidates by jobId only and forgets that there is a per-user view. So some cached entries never get invalidated.
- Aggregations: The read path caches a list or an aggregate (e.g. "candidates in this pipeline"). The write path updates one candidate and invalidates only that candidate’s record. The list cache is still wrong until it expires.
- Multiple layers: The browser caches, the API caches, and the database is the source of truth. A write might invalidate the API cache but not the browser cache, so the user still sees stale data until they refresh.
In each case, the fix is to make the write path aware of every cache that can contain the updated data and to invalidate or update those entries. That might mean invalidating by a pattern (e.g. "all keys for this job") or updating both the entity cache and the list cache when one entity changes.
Patterns That Help
Cache-aside with explicit invalidation: The app owns the cache. On read, it checks the cache; on miss, it loads from the source and populates the cache. On write, it updates the source and then invalidates (or updates) the relevant cache entries. The same code path that writes is responsible for keeping the cache correct.
Single key space: Use a consistent scheme for keys (e.g. entity:type:id, or list:type:queryHash) so that when you write, you know which keys to invalidate. If lists are cached by query, document which queries are affected by which writes and invalidate those query keys when the underlying data changes.
TTL as a backstop, not the strategy: A short TTL (e.g. 60 seconds) can limit how long stale data is served, but it does not fix the underlying problem. Prefer explicit invalidation on write so that the cache is correct as soon as the write completes. Use TTL to catch writes that missed invalidation (e.g. from another service) or to reclaim memory, not as the main way to achieve consistency.
Summary
- When the read path uses a cache and the write path does not update or invalidate it, the cache goes stale. Align them: every write that affects cached data must invalidate or update the right cache entries.
- Be aware of key design (per-user, per-entity, per-query) and of aggregations. One write can affect many cache entries; invalidate all of them.
- Prefer explicit invalidation on write; use TTL as a safety net. That way the cache stays correct by design instead of by expiry.
Have thoughts on this post or questions? Get in touch. More blog posts.