ruby 54 lines · 2 tabs

ETag + Conditional GET for JSON API

Shared by codesnips Jan 2026
2 tabs
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
2 files · ruby Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

ETag + Conditional GET for JSON API — share card
Link copied