Skip to main content
Redis and Performance

Cache Invalidation Is a Write-Path Problem

Most stale-cache bugs are not caching bugs. They are write paths that forgot they had readers. Here is how to make invalidation structural instead of remembered.

Farhan Tanvir7 min read
Abstract diagram of write paths converging on a single cache invalidation point

Almost every stale-cache incident I have reviewed has the same shape. The read path is fine. Someone added a cache, it worked, latency dropped. Months later a new write path appears — a bulk import, an admin action, a scheduled job — and nobody evicts anything, because eviction was never part of what a write is.

That is the actual failure: invalidation was treated as a thing you remember to do, rather than a property of the write.

The version that breaks

Here is the pattern that causes it.

@Cacheable(cacheNames = "projects", key = "#slug")
public ProjectView findBySlug(String slug) {
    return repository.findBySlugAndPublishedTrue(slug)
        .map(mapper::toView)
        .orElseThrow(() -> new ResourceNotFoundException("Project", slug));
}

@CacheEvict(cacheNames = "projects", key = "#project.slug")
public Project update(Project project) {
    return repository.save(project);
}

This looks correct and is not. The eviction only covers the one entry keyed by that slug. It misses:

  • the projectList cache holding the paginated listing this project appears in
  • the featuredProjects cache, if the featured flag changed
  • the entry under the old slug, if the slug itself was edited
  • anything written by a different service that also saves projects

Every one of those is a separate incident waiting for a different trigger.

Route writes through one place

The fix is not a longer annotation. It is making every write to a content type go through a single method that owns both the persistence and the invalidation.

@Service
@RequiredArgsConstructor
public class ProjectCommandService {

    private final ProjectRepository repository;
    private final ContentCacheManager cacheManager;

    @Transactional
    public Project save(Project project, String previousSlug) {
        Project saved = repository.save(project);
        cacheManager.evictProjectContent(saved.getSlug(), previousSlug);
        return saved;
    }
}

And the cache manager owns the full blast radius in one readable place:

public void evictProjectContent(String slug, String previousSlug) {
    evictEntry(CacheNames.PROJECT_DETAIL, slug);
    if (previousSlug != null && !previousSlug.equals(slug)) {
        evictEntry(CacheNames.PROJECT_DETAIL, previousSlug);
    }
    clear(CacheNames.PROJECT_LIST);
    clear(CacheNames.FEATURED_PROJECTS);
    clear(CacheNames.SITEMAP);
}

Now when someone adds the sixth write path, they call save, and invalidation comes with it. They do not have to know the cache topology. That is the point.

Clear the list, key the detail

A recurring question is whether to evict list caches surgically or clear them wholesale.

For a content site, clear them. The listing caches are small, they rebuild in a single query, and admin writes are rare — a few dozen a day against thousands of reads. Spending engineering effort on surgical list invalidation buys you nothing measurable and costs you a class of bug that is genuinely hard to reproduce.

Reserve keyed eviction for detail entries, where the key is unambiguous and the entry is expensive to rebuild.

Never cache the things that hurt

Three categories stay uncached regardless of read volume:

  • Anything behind authentication. A cache keyed without the principal will eventually serve one user's data to another. If you must cache per-user, the principal goes in the key, and you should be certain it is worth it.
  • Anything you allocate. Availability can be cached; the allocation decision cannot. The moment a cached read decides who gets the last unit, you have a correctness bug on a timer.
  • Anything you have not measured. A cache is a permanent invalidation obligation. Pay it for reads you have actually observed to be hot.

Give every cache a TTL anyway

Even with correct eviction, set a TTL. Not because you expect eviction to work — because you expect it eventually not to.

spring:
  cache:
    redis:
      time-to-live: 10m
      cache-null-values: false

A TTL converts an invalidation bug from permanent corruption into a bounded window. It is the cheapest insurance in the system, and the one thing that determines whether a missed eviction is a ten-minute annoyance or a support ticket six weeks later.

The test that matters

Assert the invariant directly: after a write, a read returns the new value.

@Test
void updatingProjectInvalidatesCachedDetail() {
    service.findBySlug("example");                       // populate

    Project project = repository.findBySlug("example").orElseThrow();
    project.setTitle("Updated Title");
    commandService.save(project, "example");

    assertThat(service.findBySlug("example").title())
        .isEqualTo("Updated Title");
}

This test fails the moment someone adds a write path that bypasses the command service. That is precisely the regression you want to catch, and it is worth more than any amount of documentation about which caches to clear.