A time-based cache (TTL) is a compromise: a short TTL means frequent database load, a long one means an editor waits a long time to see their change. If you know when the data changes, though, you can discard the cache at that moment. This article describes how we do it on a comparison page on one of our portals, what the code does and does not do, and when we would not recommend the same approach. It is written for developers and architects dealing with expensive reads over rarely changing data.
Starting point: expensive reads, rare writes
The page assembles a dataset from several sources: ratings, aggregated counts over tables with thousands of rows, an overview of related offers and a change history. Without a cache that is up to nine database queries per view. The finished dataset is stored as a single file, and other parts of the site read the same file: detail pages, category pages, cards on the home page and newsletter content. Within one request the file is read at most once, and further calls get the result from memory.
This data changes only through a release or a save in our admin, and there is no third writer we do not know about. That is why event-driven invalidation works here.
Whoever changes the data clears the cache
Admin forms that change comparison data (ratings, supplementary details, change records, assigning and activating catalogue items, creating or deleting an offer and recording its check) delete the cache file after a successful write. The next public page view rebuilds the dataset from the database and stores it. After a release, when data may have been changed by a script outside the admin, the cache is refreshed with a button on the dashboard, which deletes it and rebuilds it straight away, so the first visitor does not wait for the queries. A 24-hour TTL remains as a safety net for a forgotten reload or a write that does not trigger invalidation.
While writing this article we confirmed that the safety net is not redundant. One admin module changed data shown in the comparison but cleared the cache only when a record was deleted, not on an ordinary edit, so until then freshness there depended on the TTL or the manual button alone. We have since added the missing calls. Event-driven invalidation is only as reliable as the list of places that call it, and that list easily drifts from reality as a system grows. The delete belongs after a successful commit. If it ran before, a concurrent request could rebuild the cache from the old state.
Why delete rather than overwrite
The alternative to deleting (invalidate-on-write) is write-through: after changing the database, the writer immediately stores the new value in the cache as well. The advantage is that a write leaves no cache miss and no burst of queries. The drawback is that the writer must be able to assemble the whole derived value. For our dataset that would mean running every aggregation in every admin form and duplicating the assembly logic. With two concurrent writes, the new values can also reach the cache in a different order from the one in which they reached the database. That is why the authors of “Scaling Memcache at Facebook” (NSDI 2013) delete the cached value on write rather than update it. A delete can be safely repeated and its order does not matter.
A version on the data contract, and versioned keys
The stored dataset carries a version number for its structure. When the code that builds or reads it changes, the version is incremented and a file with the old version is ignored regardless of age. Without that, new code would read data in the old shape after a deployment and fail on missing keys. A related technique is versioned keys: the version or namespace is part of the key, so raising the version “invalidates” a whole group of entries at once without deleting them. The memcached documentation describes it as a substitute for missing namespaces. With a single file, a version in the content is enough; with thousands of keys, a version in the key is cheaper than mass deletion, but old entries have to be left to expire or cleaned up.
When the database fails
If any of the sub-queries fails, the dataset is flagged “incomplete” and is not written to the cache. If a previous successful file of the same version exists, that one is used with a “stale” flag, and the page tells the visitor so, including the date of the last successful update. An outage therefore does not show up as zeros where numbers should be. It is the equivalent of the stale-if-error directive from RFC 5861.
The approach has a weak point. After invalidation the old file no longer exists, so if the database fails at the first view after a delete, no last successful version is available and the page shows incomplete data with a notice. The same applies to the refresh button, which deletes first and builds afterwards. A safer design would build the new dataset alongside the old one and replace it only on success.
Concurrency: what the code handles and what it does not
The file is written under an exclusive lock, but readers do not take a lock, and on Linux PHP file locks are advisory by default. A reader can therefore, in theory, read a half-written file. The code copes with this indirectly: incomplete JSON cannot be decoded, so it behaves like a missing cache and the dataset is loaded from the database. This costs unnecessary queries but does not produce wrong numbers. The cleaner solution is to write to a temporary file in the same directory and rename it to the target name. On POSIX systems, according to the documentation, rename replaces the target atomically as long as both files are on the same file system.
The code does not address the burst at the first view after a delete (the thundering herd): every request arriving while the file is missing runs its own queries and writes the file. Given our traffic, the cost of a rebuild and a button that warms the cache after a release, this is an acceptable trade-off. It would be worth solving if a rebuild took seconds, traffic were high or the database ran close to capacity. The options are a non-blocking lock, where only one process rebuilds and the others get the old copy, or “leases” as in the memcache paper mentioned above.
The third and least visible problem is writing stale data after invalidation (a stale set): request A starts assembling the dataset, meanwhile an editor saves a change and clears the cache, and A then writes a result built from the old state. The code does not prevent this. The window lasts as long as a rebuild, and the stale value survives at most until the next invalidation or until the TTL expires. One remedy is a generation counter: note the generation before building, then check it and publish the cache atomically with respect to invalidation. A separate check followed by a write leaves another race. Rebuilding after the commit or a second, delayed delete can reduce the risk, but neither alone guarantees that an older request cannot overwrite fresh data later.
Stale-while-revalidate as an alternative
RFC 5861 (an Informational RFC from 2010) defines the stale-while-revalidate directive for HTTP caches: after expiry, a cache may keep returning the stale response for a set time while refreshing it in the background. At application level, this means marking the file as outdated rather than removing it, with one process rebuilding it while the others read the original. It solves both the burst and the missing fallback copy during an outage. The price is that the first reader after a save does not yet see the change. For an editorial system, where editors want to see their edit straight away, a sensible combination is an immediate rebuild from the admin plus stale-while-revalidate for public requests.
When event-driven invalidation is not enough
- Several writers outside your control: imports, other services, manual SQL. Each would have to trigger invalidation, otherwise only the TTL decides. It is more reliable to derive invalidation from the database (for example from its change log via change data capture) than from application code.
- Several web servers with a local file cache. Deleting a file on one server is invisible to the others, so you need a shared cache or broadcast invalidation.
- Distributed systems with replicas. An invalidation message can be lost or arrive before a replica has caught up with the write, and the cache is then rebuilt from old data. Without a TTL as a safety net, such an error persists indefinitely.
- Data that changes constantly or is changed by visitors. The cache would be empty most of the time. That is why comment counts are not in our dataset. The page reads them directly with one cheap query.
Checklist before going live
- List every write path into the affected tables and check that each one triggers invalidation.
- Invalidate only after a successful commit, never before it.
- Keep a TTL as a safety net and show the cache status (when it was built, until when it is valid, whether the version matches) in the admin.
- Never store an incomplete result, and tell the reader that the data is older or incomplete.
- Store the cache outside the web root or in a directory the server blocks from outside access.
- Show an unknown value as unverified rather than estimating it.
- Decide consciously whether you handle atomic writes, the post-delete burst and stale sets, or accept them as trade-offs.
Event-driven invalidation is a good choice when you know every writer and writes are rare relative to reads. It can make editorial changes visible on the next read without waiting for a TTL, provided every write path and concurrent rebuild is handled correctly. A TTL still provides a safety net, and with several writers or servers the complexity grows quickly. In that case, start with stale-while-revalidate or a shared cache with invalidation driven by the database.