Back to Course

Interview Question

"How would you cache an expensive query?"

The Rails interview question that tests caching depth. The mid-level answer is one line. The senior answer walks fragment caches, Russian-doll patterns, cache invalidation, key design, and the production failure modes that teach the lesson.

What the interviewer is actually checking

Caching is one of the two hardest problems in software (the other is naming, and arguably both are caching). The interview question filters for whether the candidate has hit cache invalidation in production, whether they understand the layers Rails offers (Russian-doll fragment caching, low-level Rails.cache, HTTP caching with ETag), and whether they can design a cache key that does not poison the cache or return stale data.

The strong signal: the candidate fixes the underlying query first, then caches what is left expensive. The weak signal: the candidate jumps to caching without asking why the query is slow.

Behind the question is also a check on whether the candidate has heard of Solid Cache (Rails 8 default) and has an opinion on Postgres-backed cache vs Memcached vs Redis.

The mid-level answer

"I would use Rails.cache.fetch with a key based on the query parameters. Set an expiration and return the cached result if it is there."

The answer is mechanically correct and stops there. The senior answer adds three things: why caching might be the wrong fix; how to design the cache key so it invalidates correctly; what layer of cache to use depending on what is being cached.

The senior answer

Four points: ask why first, design the key, pick the layer, plan the invalidation.

1. Ask why first. "Before caching, I would EXPLAIN the query and see why it is slow. If it is slow because of an N+1, missing index, or stale stats, the right fix is to make it fast, not to cache the slow version. Caching the wrong query gives you correct caching on incorrect work. EXPLAIN ANALYZE is the first tool, not the last."

2. Pick the right layer. "Three options in Rails:

  • HTTP caching: ETag and Last-Modified headers. Best when the response is the whole answer; the request returns 304 with no body. Browsers and CDNs handle it automatically.
  • Fragment caching: cache rendered HTML for a partial. Right when the view is expensive and the data is stable. The Russian-doll pattern nests fragments so invalidating an inner record automatically invalidates the outer wrapping.
  • Low-level Rails.cache: the generic Rails.cache.fetch(key) { expensive_computation } wrapper. Right for computed values that are expensive but not view-coupled."

3. Design the key so invalidation is automatic. "I want the cache key to change when the underlying data changes. Active Record's cache_key_with_version gives you a key that includes the record's updated_at. cache @user in a view picks that up automatically. For collection caches, I would use a max of updated_at across the collection. The senior trap is keys that include something expensive to compute (a SQL count, a sort key); the key derivation itself becomes the bottleneck."

4. Plan the invalidation. "Russian-doll caching invalidates by changing keys. Manual invalidation (Rails.cache.delete) is the right shape when keys do not change automatically. The Phil Karlton aphorism applies: invalidation is the hard part. I prefer key-based invalidation (where the key changes when the data does) over explicit deletion."

Russian-doll caching, named

"If the question is about caching views specifically, I would walk through the Russian-doll pattern, which is the canonical Rails answer."

<%# views/posts/show.html.erb %>
<% cache @post do %>
  <h1><%= @post.title %></h1>

  <% cache @post.author do %>
    <%= render @post.author %>
  <% end %>

  <% @post.comments.each do |comment| %>
    <% cache comment do %>
      <%= render comment %>
    <% end %>
  <% end %>
<% end %>

"Each fragment's cache key includes the inner records' updated_at. When a comment is edited, its updated_at changes, which changes its fragment key. The outer post fragment's key depends on the comments collection's max updated_at, so it also changes, and the cascade invalidates up the tree. Touch helpers (touch: true on belongs_to) ensure the cascade propagates even when only a child changes."

The cache stampede problem

"The senior follow-up: what happens when the cache expires? If Rails.cache.fetch is called by 1000 concurrent requests right after expiry, all 1000 fire the expensive computation. That is a cache stampede. The fix: pass race_condition_ttl to fetch. One request rebuilds the cache; others get the stale value for the duration of the race window. This pattern is detailed in Scaling: Caching, Properly."

The cache store choice

"In Rails 8, the default is Solid Cache (Postgres-backed). For most apps, this is fine and removes the Memcached or Redis dependency. For very-high-traffic apps where cache writes themselves stress the database, Memcached or Redis is still the right call. The senior view: pick Solid Cache by default; switch to Memcached/Redis when the cache traffic shows up as Postgres load."

The follow-ups

"How do you invalidate a cache when one record in a collection changes?" Right answer: use touch: true on the parent's belongs_to, so changing the child touches the parent. Or use a fragment cache keyed by the collection's max updated_at, which is invalidated implicitly when the max changes.

"What about caching a query result that does not map to a model?" Right answer: use Rails.cache.fetch with a manually-versioned key. Include a version number in the key so changes to the computation invalidate everything at once. "reports/dashboard/v3/user_#{user.id}".

"How do you debug a cache miss that should have been a hit?" Right answer: log the cache key being generated. Confirm two requests with the same intent produce the same key. Common causes of unexpected misses: a key that includes a timestamp that varies, a key that depends on a memoization order, a key that includes a serialized hash whose key order is unstable.

"When would HTTP caching beat fragment caching?" When the entire response is cacheable and a CDN can serve it. Marketing pages, public posts, API endpoints with versioned URLs. stale?(@post) in the controller returns 304 if the browser already has the latest version, saving the entire render.

What signals what

  • "I would use Rails.cache.fetch." Mid. The mechanical answer.
  • "I would EXPLAIN the query first." Senior. Diagnoses before caching.
  • "I prefer key-based invalidation over manual delete because it stays correct under concurrency." Senior+. Has dealt with invalidation race conditions.
  • "Cache stampede is the production failure mode for naive caching; race_condition_ttl is the fix." Senior+. Has hit the failure mode.

The principle at play

Caching is rarely the first answer to a slow query. Caching is correct when the underlying computation is fundamentally expensive (image processing, complex aggregation, paid API calls) and the inputs are stable. For everything else, fix the underlying code first. The senior reflex is "make it fast first, cache what is left expensive."

The interview reframing: leading with EXPLAIN and "why is the query slow" signals you have shipped Rails apps in production where the wrong caching fix bit you. Leading with Rails.cache.fetch signals you have caught the question in a study guide. Both end up using cache eventually; the order matters for the interviewer.

Related lessons