require 'digest'
require 'json'
module CacheHelpers
def cache_control_public(max_age = 60)
cache_control :public, :must_revalidate, max_age: max_age
end
def stable_etag(payload)
Digest::SHA256.hexdigest(payload)
end
# last_modified: a Time used as a coarse validator
# &block: lazily builds the response body (only run on a cache miss)
def serve_cacheable(last_modified:, max_age: 60)
cache_control_public(max_age)
# Coarse revalidation first; halts 304 if If-Modified-Since matches.
last_modified(last_modified) if last_modified
body = yield
content_type :json
# Precise revalidation; halts 304 if If-None-Match matches.
etag stable_etag(body)
body
end
end
require 'sinatra/base'
require_relative 'cache_helpers'
class ReportsAPI < Sinatra::Base
helpers CacheHelpers
configure do
set :protection, except: [:json_csrf]
end
get '/reports/:id' do
report = Report.find(params[:id]) or halt 404
serve_cacheable(last_modified: report.updated_at, max_age: 300) do
# Expensive work is skipped entirely on a 304 revalidation.
summary = report.render_summary
JSON.generate(
id: report.id,
generated_at: report.updated_at.utc.iso8601,
totals: summary.totals,
breakdown: summary.breakdown
)
end
end
get '/reports' do
scope = Report.recent
serve_cacheable(last_modified: scope.maximum(:updated_at), max_age: 60) do
JSON.generate(scope.map { |r| { id: r.id, name: r.name } })
end
end
end
class Report < ActiveRecord::Base
has_many :entries, dependent: :destroy
scope :recent, -> { order(updated_at: :desc).limit(50) }
Summary = Struct.new(:totals, :breakdown)
def render_summary
grouped = entries.group(:category).sum(:amount)
totals = { count: entries.count, amount: grouped.values.sum }
Summary.new(totals, grouped)
end
end
This snippet shows how a Sinatra API can avoid re-serializing and re-sending expensive route responses by implementing HTTP conditional GET around ETag and Last-Modified. The core idea is that a client already holding a cached copy sends If-None-Match and If-Modified-Since; when the resource is unchanged, the server can short-circuit with a 304 Not Modified and an empty body, saving bandwidth and CPU while keeping correctness.
The CacheHelpers module centralizes the logic. cache_control_public sets a public, revalidatable Cache-Control header so intermediaries may store the response but must revalidate once stale. serve_cacheable is the workhorse: it accepts a last_modified timestamp and a block that lazily builds the body. It computes a strong validator with stable_etag, then calls Sinatra's built-in etag and last_modified helpers, which internally compare the request's If-None-Match/If-Modified-Since and halt 304 before the block ever runs. That laziness matters — the whole point is that on a cache hit the expensive JSON.generate and any database work behind it are skipped.
stable_etag deliberately hashes the serialized payload with Digest::SHA256 so the validator changes only when the bytes change; it is derived from content rather than a random token so the same representation always yields the same tag. Note the order: last_modified is set first as a coarse validator, and etag as the precise one, matching how Sinatra evaluates conditional requests.
In Reports API, the Sinatra::Base app mixes in the helpers and uses them on /reports/:id. The route fetches a lightweight updated_at for validation, passes it to serve_cacheable, and only inside the block performs the costly report.render_summary aggregation. Because the block is skipped on 304, a repeated request from a warm client costs almost nothing.
The trade-offs are worth understanding: strong ETags require reading enough to serialize, so pairing them with Last-Modified lets clients revalidate cheaply on the timestamp alone. Last-Modified has one-second granularity, which is why the ETag is kept as the authoritative validator. This pattern fits read-heavy, slowly-changing JSON resources; it is a poor fit for personalized or rapidly-mutating data, where Cache-Control: private, no-store is safer.
Related snips
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
class Comment < ApplicationRecord
belongs_to :post, touch: true
belongs_to :author, class_name: "User"
validates :body, presence: true, length: { maximum: 10_000 }
Granular Cache Invalidation with touch: true
Share this code
Here's the card — post it anywhere.