DEV Community

Alex Aslam
Alex Aslam

Posted on

The Ghost in the Machine: Taming Cache Invalidation in Rails

There's a famous quote, often misattributed to Phil Karlton: "There are only two hard problems in computer science: cache invalidation and naming things." For a long time, I thought this was a clever joke. Then, I built a non-trivial Rails application.

You start with naming. You create a PaymentService that handles payments. Clean. Intentional. You feel a sense of order.

Then, you introduce caching. It begins innocently enough. A fragment cache here to save a few milliseconds on a rendered collection. A low-level Rails.cache.fetch there to store the result of an expensive calculation. The performance gains are immediate and intoxicating. Your P95 latencies drop. Your dashboard graphs look healthy. You are a hero.

This is the calm before the storm. You have just released the ghost into your machine.

The First Haunting: When the Data Betrays You

The bug report is always confusing at first.

"User says their updated profile picture isn't showing up. I can see it in the DB, but the front-end is still showing the old one."

"Someone added a new product to the 'On Sale' category, but it's not appearing on the page. A hard refresh fixes it."

The ghost has made itself known. This is the problem of stale cache. The data in your cache no longer reflects the truth in your database. The user performed a write—an UPDATE or an INSERT—but the application served them a pre-computed, outdated read.

You have a cache, but you lack a strategy for its invalidation. You've built a beautiful, high-performance statue with feet of clay.

The Art of Letting Go: Invalidation as a Philosophy

Cache invalidation is not a technical implementation. It is first and foremost a state of mind. It's the acknowledgment that all cached data is temporary, a fleeting representation of a truth that exists elsewhere. The art is not in keeping the cache, but in knowing precisely when and how to let it go.

In Rails, our palette is rich. We have:

  • Low-Level Caching: Rails.cache
  • Fragment Caching: cache @project do...
  • Russian Doll Caching: Nested fragments
  • Page & Action Caching: (Now mostly supplanted by faster, more flexible tools)

Each requires a different brushstroke for invalidation. Let's paint the strategies.

The Master's Toolkit: Patterns for the Rails Artisan

1. The Precision Scalpel: Key-Based Expiration

This is the most elegant, most robust pattern. The core principle is beautiful: you never explicitly invalidate the cache. You simply allow the old, now-incorrect key, to expire naturally.

The key itself is a function of the object's state. When the state changes, the key changes, and the old cache entry is orphaned, eventually reclaimed by the garbage collector of your cache store (like Memcached or Redis).

# app/views/projects/_project.html.erb
<% cache [project, "show"] do %>
  <h1><%= project.name %></h1>
  <p>Last updated by: <%= project.updated_at %></p>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Here, the cache key is a combination of the project object and a string. project implicitly calls cache_key, which, by default, is projects/1-20231020150000000000 (model name, id, updated_at timestamp).

The Art: When you project.touch or update any field that changes updated_at, the key changes. The next render looks for a fragment with the new key, doesn't find it, and generates a new one. The old fragment, with the old key, is now irrelevant data. You have achieved invalidation through indirection.

This is your first-class citizen. Use it for all fragment and low-level caches that are directly tied to an ActiveRecord object.

2. The Strategic Sweep: Dependency Tracing with touch: true

But what about nested relationships? A comment's view might be cached, but it's rendered within a project. If the comment is updated, the project's updated_at remains unchanged. The project's cache key is the same, so it serves the stale comment.

Enter the touch option.

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :project, touch: true
end
Enter fullscreen mode Exit fullscreen mode

The Art: Now, when a comment is saved, it automatically "touches" its associated project, updating the project's updated_at timestamp. This, in turn, busts the entire project's cache key, forcing a re-render of all nested fragments. It's a cascade of invalidation, a single pebble creating ripples across the entire data lake.

This is the foundation of Russian Doll Caching. It's a declarative way of describing data dependencies.

3. The Targeted Bomb: Explicit Invalidation with Sweepers

Sometimes, the change is more lateral. Perhaps you have a sidebar cache for "Top 5 Projects," calculated by a complex query. It's not tied to any one project's updated_at. How do you invalidate it?

You can be explicit.

# app/models/project.rb
class Project < ApplicationRecord
  after_commit :expire_top_projects_cache, on: [:create, :update, :destroy]

  private

  def expire_top_projects_cache
    Rails.cache.delete('top_5_projects')
  end
end
Enter fullscreen mode Exit fullscreen mode

The Art: This is a deliberate, targeted strike. You are connecting the business logic (a project changing) directly to a cache consequence. It's powerful, but dangerous. This logic can become scattered and hard to reason about. Use it sparingly, and only for caches that are truly disconnected from the natural key-based flow.

4. The Inevitable Tide: Time-Based Expiration

For some data, "good enough" is, in fact, good enough. A list of "public news articles" might only need to be updated every 5 minutes.

# low-level cache with expiry
Rails.cache.fetch('latest_news', expires_in: 5.minutes) do
  News.published.limit(10).to_a
end
Enter fullscreen mode Exit fullscreen mode

The Art: You are trading perfect consistency for simplicity and performance. The user might not see a new article the second it's published, but they will within 5 minutes. This is a conscious trade-off, not a compromise. It accepts the ghost but puts it on a timer.

The Grand Composition: Weaving the Strategies

A senior developer doesn't choose one strategy. They compose them, like a conductor weaving together the sections of an orchestra.

Imagine a dashboard view:

  • The main content is cached with a key based on the current user and the main object's updated_at (Key-Based).
  • A nested list of tasks uses Russian Doll caching, and each task belongs_to :main_object, touch: true (Dependency Tracing).
  • A "Weekly Stats" widget is explicitly invalidated via an after_commit hook when a relevant financial transaction occurs (Explicit Invalidation).
  • A "Trending Topics" feed in the sidebar expires every hour (Time-Based).

This composition is your masterpiece. It's a system that is both highly performant and, crucially, correct.

The Final Acceptance: Living with the Ghost

You will never fully exorcise the ghost of stale cache. It is the natural shadow of the performance light you are trying to create. The goal is not to eliminate it, but to understand it so deeply that you can build systems where its appearances are rare, predictable, and manageable.

Cache invalidation is hard not because the code is complex, but because it requires a profound understanding of your application's data flow. It forces you to think not in terms of requests and responses, but in terms of a living, interconnected graph of state.

Embrace the challenge. See your cache not as a simple performance hack, but as a core part of your application's architecture. Design its lifecycle with the same care you design your database schema.

Master this, and you move from being a developer who writes code to an architect who composes systems. You learn to live in harmony with the ghost, and in doing so, you build applications that are not just fast, but reliably so.

Top comments (0)