The most surprising thing about cache invalidation is that most systems get it fundamentally wrong by treating it as a pure "delete" operation, when it’s really a "versioning" problem.

Let’s see this in action. Imagine a simple web application serving product details.

GET /products/123
Host: api.example.com

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=3600, stale-while-revalidate=60
ETag: "abcdef123456"

{
  "id": 123,
  "name": "Super Widget",
  "price": 19.99
}

The ETag is crucial. It’s a unique identifier for this specific version of the resource. If the product price changes, the server will generate a new ETag, say "ghijkl789012".

GET /products/123
Host: api.example.com
If-None-Match: "abcdef123456"

HTTP/1.1 304 Not Modified
ETag: "ghijkl789012"

The browser (or any cache) sees that its cached version ("abcdef123456") is no longer current. It receives the new ETag ("ghijkl789012") and knows it needs to fetch the new data. If the ETag hadn’t changed, the server would respond with 304 Not Modified, saving bandwidth and processing.

This is the core of effective cache invalidation: not deleting, but knowing when your cached data is stale and fetching a fresh version. The system doesn’t need to know how to invalidate; it just needs to know how to check if its current version is still valid.

This problem arises because data changes. When a user updates their profile, their avatar might change. When an administrator updates inventory, product prices fluctuate. If clients (browsers, mobile apps, other services) continue to serve old, stale data, users get a poor experience, and the system can become inconsistent.

The fundamental lever you control is how you signal changes. This is done primarily through HTTP headers, especially ETag and Last-Modified.

  • ETag (Entity Tag): A unique, opaque identifier for a specific version of a resource. It’s often a hash of the content or a version number. The server dictates the ETag.
  • Last-Modified: A timestamp indicating when the resource was last changed. The server dictates this.

When a client makes a request, it can include an If-None-Match header (with the ETag it has cached) or an If-Modified-Since header (with the Last-Modified timestamp it has cached). The server then compares these with the current version of the resource.

  • If the ETag matches or the Last-Modified date is not older than the If-Modified-Since date, the server responds with 304 Not Modified and an empty body. The client knows its cache is still valid.
  • If they don’t match, the server responds with 200 OK and the new resource, along with a new ETag and/or Last-Modified header.

This is the "stale-while-revalidate" pattern in action. The client might serve the stale data immediately (if it’s configured to do so, like with stale-while-revalidate), but it also initiates a background check to see if a fresh version is available. If it is, the stale data is discarded, and the fresh data is used.

The real complexity comes in distributed systems. When your data is spread across multiple databases, microservices, or message queues, ensuring consistency across all caches becomes a significant challenge. A common, but often brittle, approach is to broadcast "invalidate this key" messages. However, this can lead to race conditions. What if a client requests data, gets a cache miss, starts fetching the new data, but before it can complete, it receives an invalidation message for the old data? It might then incorrectly believe the data is gone or unavailable.

A more robust strategy is to leverage versioning. Instead of just invalidate(productId: 123), you can think of it as update(productId: 123, version: 5). When a client requests /products/123, the server can respond with ETag: "v5" and the data. If the data is updated again, the server responds with ETag: "v6". The client’s cache now holds ETag: "v5". When it makes a conditional request (If-None-Match: "v5"), the server sees the current version is v6 and sends the new data. The client updates its cache to ETag: "v6". This ensures that a client is always referencing a known, specific version of the data.

The most powerful, and often overlooked, aspect of cache invalidation is using the underlying data’s versioning system to drive cache updates. If your database provides reliable version numbers or timestamps for every row update, you can use those directly as ETag values. For instance, if your products table has an updated_at timestamp column that’s always updated on any modification, you can use that timestamp (formatted correctly, e.g., as a Unix epoch or ISO string) as your ETag. This ties your cache invalidation directly to the data’s state, making it much more reliable than manual invalidation signals.

The next logical step is understanding how to implement these ETag and Last-Modified strategies across a microservices architecture.

Want structured learning?

Take the full Caching-strategies course →