RUBY ON RAILS · 18 MIN READ ·

Rails HTTP Caching: ETags, fresh_when and stale? Patterns That Cut Server Load in Production

Rails HTTP caching done right. ETags, Last-Modified, fresh_when, stale?, Cache-Control, and CDN patterns that quietly slash server load in production.

Rails HTTP Caching: ETags, fresh_when and stale? Patterns That Cut Server Load in Production

A founder I work with messaged me on a Sunday in a quiet panic. His Rails app had been running fine on a single c6i.large for eighteen months and was suddenly burning 90 percent CPU at lunchtime every weekday, with no traffic spike to explain it. The dashboards showed the same five product pages getting hammered by the same logged-in users hitting refresh on the same browser tab. Postgres looked fine. The cache hit rate looked fine. The problem was that Rails HTTP caching was not switched on at all — every refresh was generating a fresh 200 response, fully rendered, fully serialized, fully shipped over the wire, for a page whose data had not changed since breakfast.

After nineteen years of Rails I have come to treat HTTP caching the same way I treat database indexes — invisible until you need it, embarrassing once you realize you have been paying for the absence. The fix on that founder’s app took an afternoon, dropped lunchtime CPU to 12 percent, and cut his egress bill by a third the following week. This post is the Rails HTTP caching playbook I now use by default: ETags, Last-Modified, fresh_when, stale?, Cache-Control, CDN integration, and the conditional-GET patterns that actually survive contact with production.

What Rails HTTP Caching Actually Does

The mental model that trips people up is treating the Rails cache (Rails.cache, fragment caching, low-level caching) as the same thing as HTTP caching. They are not. The Rails cache stores rendered output server-side so you do not have to recompute it. HTTP caching tells the client (or a CDN, or a reverse proxy) that the response it already has is still valid and the server does not need to send the body at all. The difference is everything: fragment caching saves you rendering work, HTTP caching saves you rendering, serialization, and network bandwidth.

The contract is simple. The browser sends a request. Your Rails app inspects If-None-Match (the ETag the browser last saw) or If-Modified-Since (the timestamp it last saw), decides whether the resource has changed, and either returns a fresh 200 with a body or a 304 Not Modified with empty body and the same caching headers. A 304 response in Rails is typically under 200 bytes and skips view rendering entirely. On a product page that takes 80 ms to render, a 304 takes 3 ms and ships nothing over the wire. Multiply by every authenticated user hitting refresh.

The Two Building Blocks: fresh_when And stale?

Rails ships two controller helpers that do all the work: fresh_when and stale?. They are the same primitive with different ergonomics. fresh_when returns early if the request is fresh; stale? returns true if it is stale and lets you decide what to render.

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def show
    @product = Product.find(params[:id])
    fresh_when(@product, public: false)
  end
end

That single line is enough to enable Rails HTTP caching on the product show page. Rails computes an ETag from @product (using its cache_key_with_version, which combines the id, updated_at, and class name) and a Last-Modified header from @product.updated_at. On the second request, if the browser sends If-None-Match matching that ETag, Rails returns a 304 with no body and the view is never rendered. The public: false flag tells shared caches (CDNs, reverse proxies) not to store the response — important when the page contains anything user-specific.

The stale? form is useful when you want to do work only when the resource has actually changed:

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def show
    @product = Product.find(params[:id])

    if stale?(@product, public: false)
      @related = ExpensiveRelatedProductsQuery.call(@product)
      render :show
    end
  end
end

If the product is fresh, the expensive related-products query never runs. If it is stale, you render normally. The flow control is the same as fresh_when but the body of the if is reserved for work you only want to pay for when the cache misses.

ETags Versus Last-Modified: Pick Both

You will see arguments online about whether to use ETags or Last-Modified. The honest answer is to ship both and let the client pick — Rails does this by default, the cost is negligible, and the failure modes are different. Last-Modified is a second-resolution timestamp, which means two updates inside the same second look identical to the browser. ETags are content-hashed, which means they catch sub-second changes but cost a tiny bit more to compute. Together they cover each other’s blind spots.

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def show
    @order = current_user.orders.find(params[:id])
    fresh_when(
      etag:          [@order, @order.items.maximum(:updated_at)],
      last_modified: @order.updated_at,
      public:        false
    )
  end
end

Two production patterns matter here. First, the ETag input is an array — Rails will hash all of its elements together, so you can include associations whose updated_at should bust the cache. Second, hitting @order.items.maximum(:updated_at) is a single indexed query, far cheaper than rendering the order page. Use that pattern whenever the rendered output depends on data the parent object’s updated_at does not capture.

The cache_key_with_version Contract

The reason fresh_when(@product) works at all is that ActiveRecord models implement cache_key_with_version. The default key looks like products/42-20260629143521000000, combining the id and a microsecond-precision updated_at. As long as something bumps updated_at when the underlying data changes, Rails HTTP caching keeps itself honest.

Two places this quietly breaks: associations that change without touching the parent, and update_columns calls that skip callbacks. The first you fix with touch: true on the belongs_to side, or with touch on the parent in callbacks. The second you fix by not using update_columns on records whose freshness anyone depends on.

# app/models/product.rb
class Product < ApplicationRecord
  has_many :images, dependent: :destroy
end

# app/models/product_image.rb
class ProductImage < ApplicationRecord
  belongs_to :product, touch: true   # bumps product.updated_at when images change
end

If a customer reorders the images on a product, the product’s updated_at updates, the ETag changes, and the cached product page invalidates correctly. Without touch: true, the page would stay stale until something else triggered a save on the product itself.

Cache-Control Is The Header That Matters Most

fresh_when handles ETag and Last-Modified but it does not set Cache-Control for you in any meaningful way. That header is what tells browsers and CDNs how aggressively to cache, and the defaults Rails ships are conservative. For a public product page that updates rarely, you want something like this:

# app/controllers/public/products_controller.rb
class Public::ProductsController < ApplicationController
  def show
    @product = Product.published.find(params[:id])

    expires_in 5.minutes, public: true, stale_while_revalidate: 60.seconds
    fresh_when(@product, public: true)
  end
end

expires_in 5.minutes, public: true tells any shared cache (CloudFront, Fastly, Cloudflare, an Nginx reverse proxy) that this response can be served to other users for five minutes without asking Rails. stale_while_revalidate: 60.seconds is a quietly powerful directive that lets a CDN serve the stale response immediately while it revalidates in the background, which means your users see the page in 5 ms even if your app is busy. Combine that with fresh_when and you get three layers of caching for the price of one.

For authenticated, per-user pages, the rule flips: public: false, short or no expires_in, lean on conditional GET only. The browser still caches, the CDN does not.

# app/controllers/dashboards_controller.rb
class DashboardsController < ApplicationController
  before_action :authenticate_user!

  def show
    @summary = DashboardSummary.for(current_user)
    fresh_when(
      etag:   [current_user, @summary.updated_at, flash.now],
      public: false
    )
  end
end

Including current_user in the ETag prevents one user’s cached dashboard from leaking to another through a shared cache, even if public: false is misconfigured downstream. Including flash.now makes sure a freshly set flash message busts the cache so users do not miss it.

The Vary Header Trap

The single most common bug in Rails HTTP caching is forgetting the Vary header. If your response differs based on Accept-Language, Accept, or any cookie, the cache key has to include that — otherwise a CDN happily serves your English page to a Dutch visitor or your JSON response to a browser that asked for HTML.

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])

    response.headers["Vary"] = "Accept-Language, Accept"
    fresh_when(@article, public: true)
  end
end

For a multilingual Rails app — and this site, /en/2026/02/06/database-migrations-zero-downtime/ being one — Vary: Accept-Language is non-negotiable. Without it, the first visitor to hit a page sets the CDN cache for everyone, regardless of which language they wanted.

Conditional GET For JSON APIs

The pattern works just as well for JSON endpoints, and arguably matters more there because mobile clients hit them on every screen.

# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < Api::BaseController
  def index
    scope = current_user.orders.recent
    latest = scope.maximum(:updated_at) || Time.at(0)
    count  = scope.count

    if stale?(etag: [latest, count, current_user.id], last_modified: latest, public: false)
      render json: scope.includes(:items).limit(50)
    end
  end
end

The ETag is built from the maximum updated_at, the row count, and the user id. The count matters: if a single order is destroyed, the latest updated_at stays the same but the count drops, so the ETag changes. Both queries are indexed and cheap. The serializer never runs on a 304, and a mobile app polling once a minute pays roughly 200 bytes per poll instead of 80 KB.

Russian-Doll And HTTP Caching Are Not The Same

If you already have fragment caching set up the Russian-doll way, you might think you are done. You are not. Fragment caching saves you rendering work on a cache miss. HTTP caching saves you rendering, serialization, and network. The two compose beautifully — fragment caching lives inside the view, HTTP caching wraps the whole action — and on a real product page you want both. The fragment cache makes a miss render in 20 ms instead of 80 ms; the conditional GET turns 90 percent of requests into 304s that never reach the renderer in the first place.

Measuring Whether It Is Actually Working

A caching header you set but cannot prove is working is worse than no header at all because it hides the regression. Three measurements I check on every project:

# config/initializers/cache_metrics.rb
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  payload = event.payload

  if payload[:status] == 304
    Rails.logger.tagged("http_cache") do
      Rails.logger.info(
        controller: payload[:controller],
        action:     payload[:action],
        status:     304,
        duration:   event.duration.round(2)
      )
    end
  end
end

Tag 304 responses in your logs so you can grep them. Add a Prometheus or StatsD counter for http_cache_hits versus http_cache_misses per controller — the ratio tells you which pages are genuinely benefiting and which have caches that are constantly busting. If you are using Sentry, the patterns I laid out in /en/2026/06/25/rails-sentry-error-tracking-source-maps-pii-scrubbing/ work for tagging cache state on traces too.

For sanity checking from the outside, curl -I is your friend:

curl -I https://ttb.software/en/blog/
# HTTP/2 200
# etag: W/"7d4f1e2b..."
# last-modified: Mon, 29 Jun 2026 09:14:32 GMT
# cache-control: public, max-age=300

curl -I -H 'If-None-Match: W/"7d4f1e2b..."' https://ttb.software/en/blog/
# HTTP/2 304

A 304 on the second call, with the same etag echoed back, is proof the conditional GET is wired end to end.

Common Gotchas In Rails HTTP Caching

Three I keep running into on client engagements. First, protect_from_forgery and Rails CSRF tokens can quietly poison ETags if you render the token into the body — every response is unique, so every ETag is unique, so nothing ever returns 304. Move the token to a meta tag and a header on JSON requests, or set the ETag explicitly on the data that matters and ignore the CSRF wrinkle.

Second, ActiveStorage URLs include signed expiry timestamps, which means a page rendering image URLs is technically different content every time even when the underlying data has not changed. Pin the signed URL TTL long (a week) and round the timestamp to the hour, or use a CDN that rewrites the URLs, or accept that pages with many image variants will not 304 cleanly.

Third, devise and flash messages. Every request that ran with a flash hash in the session generates a different response body, which busts ETags. Use flash.now for messages tied to the current request, only set persistent flash[:notice] when you are about to redirect, and your dashboard pages stop fighting your caching layer.

When To Bypass Rails HTTP Caching Entirely

Not every endpoint benefits. Pages that do real-time work (chat, live dashboards, anything driven by Turbo Streams as in /en/2026/06/05/rails-turbo-morphing-broadcasts-refreshes-dom-updates/) should skip conditional GET and lean on websockets instead. Endpoints that mutate state (POST, PATCH, DELETE) cannot be cached at the HTTP layer — full stop. Endpoints whose freshness window is shorter than the round-trip time (financial tickers, sports scores) are better served by short Cache-Control: max-age with no ETag overhead. The pattern is not “cache everything” but “cache the long tail of read-heavy endpoints that dominate your CPU graph.”

Frequently Asked Questions About Rails HTTP Caching

What is the difference between fresh_when and stale? in Rails?

fresh_when and stale? are two ergonomics for the same primitive: conditional GET. fresh_when returns early with a 304 if the request is fresh, so you write nothing after it. stale? returns true if the request is stale, so you wrap expensive work like database queries or external API calls inside if stale?(...). Use fresh_when when the only cost is rendering the view; use stale? when there is real work you only want to do on a cache miss.

How do Rails ETags work with CDNs like CloudFront or Cloudflare?

CDNs honour ETag, Last-Modified, and Cache-Control headers as long as you set public: true on the response. CloudFront and Cloudflare both forward If-None-Match from the browser when their own cache misses, so your Rails app still gets a chance to return 304. For full CDN caching without revalidation, combine expires_in 5.minutes, public: true with stale_while_revalidate so the CDN serves stale content during background revalidation. Always set Vary: Accept-Language for multilingual sites or the CDN will leak the wrong language to the wrong users.

Does Rails HTTP caching work with authenticated users?

Yes, with care. Use public: false so shared caches do not store user-specific responses, include current_user in the ETag so two users never collide, and watch out for flash messages and CSRF tokens leaking into the response body. Conditional GET is especially valuable for authenticated dashboards because the same user hitting refresh every minute is the worst case for server load and the best case for ETag hits.

Why are my Rails ETags never matching?

The three usual causes: something in the response body changes on every request (CSRF token, signed URL expiry, flash message, request id), the model’s updated_at is being updated by an unrelated callback so the cache key churns, or a middleware downstream is rewriting the body (compression with gzip headers, response signing). Use curl -I to confirm the ETag is identical on two consecutive requests with no app changes between them. If it is not, the body is changing and you need to figure out why.

Rails HTTP caching is one of those features that has been in the framework since version 2 and stays unused on most apps because nobody notices the missing performance. Add fresh_when to your three highest-traffic actions this afternoon, measure the 304 rate next week, and your CPU graph will tell you whether it was worth the twenty lines of code. In my experience it always is.

Need help finding the cacheable endpoints in your Rails app, or wiring up a CDN that actually returns 304s? TTB Software specializes in Rails performance and infrastructure for founders and growth-stage teams. We’ve been doing this for nineteen years.

#rails-http-caching #rails-etags #rails-fresh-when #rails-conditional-get #rails-stale-check #rails-cdn-caching #rails-cache-control

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software