module ConditionalGet
extend ActiveSupport::Concern
private
def render_conditional(resource, extra: nil)
etag = strong_etag(fingerprint_for(resource, extra))
last_modified = last_modified_for(resource)
if stale?(etag: etag, last_modified: last_modified, public: false)
body = yield
render json: body, status: :ok
end
end
def fingerprint_for(resource, extra)
parts = [resource.class.name, resource.try(:cache_version), extra].compact
Digest::SHA256.hexdigest(parts.join("/"))
end
def last_modified_for(resource)
if resource.respond_to?(:maximum)
resource.maximum(:updated_at)
else
resource.updated_at
end
end
def strong_etag(digest)
%("#{digest}")
end
end
class ArticlesController < ApplicationController
include ConditionalGet
before_action :authenticate_user!
def show
@article = current_user.articles.find(params[:id])
render_conditional(@article) do
ArticleSerializer.new(@article).as_json
end
end
def index
scope = current_user.articles.order(updated_at: :desc)
stamp = [scope.maximum(:updated_at), scope.count].join("-")
render_conditional(scope, extra: stamp) do
{ articles: scope.map { |a| ArticleSerializer.new(a).as_json } }
end
end
end
Conditional GET is an HTTP mechanism that lets a server skip re-sending a response body when the client already holds a fresh copy. This snippet wires it into a Rails JSON API by combining a strong ETag (a fingerprint of the representation) with Last-Modified, so repeat requests can return a bare 304 Not Modified instead of a full payload.
In ConditionalGet concern, the logic is factored into a controller mixin. render_conditional builds a stable fingerprint from the resource by digesting its class name, id, and cache_version — mirroring what stale? does internally but under explicit control so the fingerprint is deterministic and independent of view rendering. It calls Rails' stale? with both etag: and last_modified:, plus public: false to keep the response private to the authenticated user. When stale? returns false, Rails has already set the status to 304 and the passed block never runs, so no JSON is serialized at all. The strong_etag helper wraps the digest in double quotes because a strong validator must be byte-for-byte identical, which matters when clients later send If-Range or when caches compare weak versus strong tags.
In ArticlesController, show delegates entirely to render_conditional, passing the loaded @article and a block that produces the serialized body only when needed. The key detail is that the expensive work — serialization via ArticleSerializer — lives inside that block, so a 304 path avoids it completely. index shows the collection variant: the fingerprint derives from the maximum updated_at and the row count, which changes whenever any article is created, updated, or destroyed, giving a cheap but correct collection validator.
The trade-off is that the fingerprint must capture everything that can vary in the response; forgetting a field (like the current user's locale or role) can serve a stale 304 to the wrong audience, which is why public: false and user-scoped data are handled carefully. The payoff is significant: on a cache hit the server does a couple of index lookups and returns an empty body, saving both bandwidth and CPU. This pattern suits read-heavy endpoints where clients poll frequently, and it composes cleanly with Cache-Control for CDNs. It is worth noting stale? also short-circuits HEAD requests, and that cache_version requires ActiveRecord::Base.cache_versioning to be enabled for the version to participate in the key.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.