Rails HTTP Caching: ETags, fresh_when en stale? Patronen Die de Serverbelasting in Productie Verlagen
Rails HTTP caching goed gedaan. ETags, Last-Modified, fresh_when, stale?, CDN-headers en de conditional-GET patronen die je serverbelasting fors verlagen.
Een oprichter waarmee ik werk stuurde me op een zondag een bericht in stille paniek. Zijn Rails-app draaide al achttien maanden prima op één c6i.large en verstookte plotseling iedere werkdag rond lunchtijd 90 procent CPU, zonder verkeerspiek die het kon verklaren. De dashboards lieten zien dat dezelfde vijf productpagina’s werden gebombardeerd door dezelfde ingelogde gebruikers die op refresh drukten in hetzelfde browsertabblad. Postgres zag er prima uit. De cache-hitratio zag er prima uit. Het probleem was dat Rails HTTP caching helemaal niet aanstond — iedere refresh genereerde een verse 200-respons, volledig gerenderd, volledig geserialiseerd, volledig over de lijn gestuurd, voor een pagina waarvan de data sinds het ontbijt niet veranderd was.
Na negentien jaar Rails behandel ik HTTP-caching inmiddels op dezelfde manier als database-indexen — onzichtbaar tot je het nodig hebt, gênant zodra je beseft dat je voor de afwezigheid betaald hebt. De fix op de app van die oprichter kostte een middag, bracht de lunchtijd-CPU terug naar 12 procent en sneed de week erna een derde van zijn uitgaande dataverkeer weg. Deze post is het Rails HTTP caching-draaiboek dat ik nu standaard gebruik: ETags, Last-Modified, fresh_when, stale?, Cache-Control, CDN-integratie en de conditional-GET patronen die contact met productie daadwerkelijk overleven.
Wat Rails HTTP Caching Eigenlijk Doet
Het mentale model dat mensen ondersteboven gooit is de Rails-cache (Rails.cache, fragment caching, low-level caching) verwarren met HTTP-caching. Dat zijn niet dezelfde dingen. De Rails-cache slaat gerenderde output aan de serverkant op zodat je het niet opnieuw hoeft te berekenen. HTTP-caching vertelt de client (of een CDN, of een reverse proxy) dat de respons die hij al heeft nog steeds geldig is en dat de server de body helemaal niet hoeft te sturen. Het verschil is alles: fragment caching bespaart je renderwerk, HTTP-caching bespaart je renderen, serialiseren en netwerkbandbreedte.
Het contract is simpel. De browser stuurt een request. Je Rails-app inspecteert If-None-Match (de ETag die de browser het laatst zag) of If-Modified-Since (de timestamp die hij het laatst zag), bepaalt of de resource gewijzigd is, en stuurt óf een verse 200 met een body terug óf een 304 Not Modified met een lege body en dezelfde cache-headers. Een 304-respons in Rails is doorgaans onder de 200 bytes en slaat het renderen van views volledig over. Op een productpagina die 80 ms kost om te renderen kost een 304 zo’n 3 ms en gaat er niets over de lijn. Vermenigvuldig dat met elke ingelogde gebruiker die op refresh drukt.
De Twee Bouwstenen: fresh_when En stale?
Rails levert twee controller-helpers die al het werk doen: fresh_when en stale?. Het is dezelfde primitive met andere ergonomie. fresh_when retourneert direct als het request fresh is; stale? retourneert true als het stale is en laat jou beslissen wat je rendert.
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
fresh_when(@product, public: false)
end
end
Die ene regel is genoeg om Rails HTTP caching in te schakelen op de productpagina. Rails berekent een ETag uit @product (via cache_key_with_version, die id, updated_at en de klassenaam combineert) en een Last-Modified-header uit @product.updated_at. Op het tweede request, als de browser een If-None-Match stuurt die overeenkomt met die ETag, retourneert Rails een 304 zonder body en wordt de view nooit gerenderd. De public: false-vlag vertelt gedeelde caches (CDNs, reverse proxies) de respons niet op te slaan — belangrijk wanneer de pagina iets gebruiker-specifieks bevat.
De stale?-vorm is bruikbaar wanneer je werk alleen wilt doen als de resource daadwerkelijk veranderd is:
# 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
Als het product fresh is, draait de dure related-products query nooit. Is het stale, dan render je normaal. De flowcontrol is hetzelfde als bij fresh_when maar de body van de if is gereserveerd voor werk waarvoor je alleen wilt betalen wanneer de cache mist.
ETags Versus Last-Modified: Kies Allebei
Online zie je discussies over of je ETags of Last-Modified moet gebruiken. Het eerlijke antwoord is allebei meesturen en de client laten kiezen — Rails doet dit standaard, de kosten zijn verwaarloosbaar en de faalmodes verschillen. Last-Modified is een seconde-resolutie timestamp, wat betekent dat twee updates binnen dezelfde seconde er voor de browser identiek uitzien. ETags worden content-hashed, wat betekent dat ze sub-seconde wijzigingen wel oppakken maar marginaal duurder zijn om te berekenen. Samen dekken ze elkaars blinde vlekken.
# 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
Twee productiepatronen tellen hier. Ten eerste is de ETag-invoer een array — Rails hasht alle elementen samen, dus je kunt associaties opnemen waarvan de updated_at de cache moet busten. Ten tweede is @order.items.maximum(:updated_at) aanroepen één geïndexeerde query, veel goedkoper dan de orderpagina renderen. Gebruik dat patroon altijd wanneer de gerenderde output afhangt van data die de updated_at van het parent-object niet vangt.
Het cache_key_with_version Contract
De reden dat fresh_when(@product) überhaupt werkt is dat ActiveRecord-modellen cache_key_with_version implementeren. De standaardsleutel ziet eruit als products/42-20260629143521000000, een combinatie van id en een updated_at op microseconde-precisie. Zolang iets updated_at bumpt wanneer de onderliggende data wijzigt, blijft Rails HTTP caching zichzelf eerlijk houden.
Twee plekken waar dit stilletjes breekt: associaties die wijzigen zonder de parent aan te raken, en update_columns-aanroepen die callbacks overslaan. Het eerste fix je met touch: true aan de belongs_to-kant, of met touch op de parent in callbacks. Het tweede fix je door geen update_columns te gebruiken op records waarvan iemands freshness afhankelijk is.
# 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 # bumpt product.updated_at als images wijzigen
end
Als een klant de afbeeldingen op een product opnieuw rangschikt, wordt updated_at van het product bijgewerkt, verandert de ETag en wordt de gecachete productpagina correct geïnvalideerd. Zonder touch: true zou de pagina stale blijven totdat iets anders een save op het product zelf triggerde.
Cache-Control Is De Header Die Het Meest Telt
fresh_when regelt ETag en Last-Modified maar zet Cache-Control niet op een betekenisvolle manier voor je. Dat is de header die browsers en CDNs vertelt hoe agressief ze mogen cachen, en de defaults die Rails meelevert zijn voorzichtig. Voor een publieke productpagina die zelden wijzigt wil je zoiets als dit:
# 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 vertelt iedere gedeelde cache (CloudFront, Fastly, Cloudflare, een Nginx reverse proxy) dat deze respons vijf minuten lang aan andere gebruikers geserveerd mag worden zonder Rails te bellen. stale_while_revalidate: 60.seconds is een stilletjes krachtige directive die een CDN toestaat de stale respons direct te serveren terwijl hij op de achtergrond revalideert, wat betekent dat je gebruikers de pagina in 5 ms zien, zelfs als je app druk is. Combineer dat met fresh_when en je krijgt drie cachinglagen voor de prijs van één.
Voor authenticated, gebruiker-specifieke pagina’s keert de regel om: public: false, kort of geen expires_in, alleen leunen op conditional GET. De browser cachet nog, het CDN niet.
# 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
current_user in de ETag opnemen voorkomt dat het gecachete dashboard van één gebruiker via een gedeelde cache naar een ander lekt, zelfs als public: false stroomafwaarts verkeerd is geconfigureerd. flash.now opnemen zorgt dat een net gezette flash-bericht de cache buste zodat gebruikers het niet missen.
De Vary Header Valkuil
De meest voorkomende bug in Rails HTTP caching is de Vary-header vergeten. Als je respons verschilt op basis van Accept-Language, Accept of een willekeurige cookie, moet de cachesleutel dat opnemen — anders serveert een CDN vrolijk je Engelse pagina aan een Nederlandse bezoeker of je JSON-respons aan een browser die om HTML vroeg.
# 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
Voor een meertalige Rails-app — en deze site, /nl/2026/02/06/database-migrations-zero-downtime/ is er één — is Vary: Accept-Language niet-onderhandelbaar. Zonder dat zet de eerste bezoeker die een pagina raakt de CDN-cache voor iedereen, ongeacht welke taal ze wilden.
Conditional GET Voor JSON-API’s
Het patroon werkt net zo goed voor JSON-endpoints, en het maakt waarschijnlijk dáár het meeste uit omdat mobiele clients ze op elk scherm raken.
# 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
De ETag is opgebouwd uit het maximum updated_at, het aantal rijen en de gebruikers-id. Het aantal telt: als één order vernietigd wordt, blijft het laatste updated_at hetzelfde maar daalt het aantal, dus verandert de ETag. Beide queries zijn geïndexeerd en goedkoop. De serializer draait nooit op een 304, en een mobiele app die elke minuut polt betaalt ongeveer 200 bytes per poll in plaats van 80 KB.
Russian-Doll En HTTP Caching Zijn Niet Hetzelfde
Als je fragment caching al op de Russian-doll manier hebt ingericht, denk je misschien dat je klaar bent. Dat ben je niet. Fragment caching bespaart je renderwerk op een cache miss. HTTP-caching bespaart je renderen, serialiseren en netwerk. De twee combineren prachtig — fragment caching woont in de view, HTTP-caching omhult de hele action — en op een echte productpagina wil je beide. De fragment-cache zorgt dat een miss in 20 ms rendert in plaats van 80 ms; de conditional GET maakt van 90 procent van de requests 304’s die de renderer nooit eens halen.
Meten Of Het Daadwerkelijk Werkt
Een caching-header die je zet maar niet kunt bewijzen dat hij werkt is erger dan geen header, omdat het de regressie verbergt. Drie metingen die ik op elk project controleer:
# 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 je logs zodat je ze kunt greppen. Voeg een Prometheus- of StatsD-counter toe voor http_cache_hits versus http_cache_misses per controller — de verhouding vertelt je welke pagina’s daadwerkelijk profiteren en welke caches voortdurend gebust worden. Gebruik je Sentry, dan werken de patronen die ik beschreef in /nl/2026/06/25/rails-sentry-error-tracking-source-maps-pii-scrubbing/ ook voor het taggen van cache-status op traces.
Voor een sanity check van buitenaf is curl -I je vriend:
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
Een 304 op de tweede aanroep, met dezelfde etag teruggekaatst, is het bewijs dat de conditional GET end-to-end goed bedraad zit.
Veelvoorkomende Valkuilen In Rails HTTP Caching
Drie die ik blijf tegenkomen op klantopdrachten. Ten eerste kunnen protect_from_forgery en Rails CSRF-tokens ETags stilletjes vergiftigen als je het token in de body rendert — elke respons is uniek, dus elke ETag is uniek, dus niets geeft ooit 304 terug. Verplaats het token naar een meta-tag en een header op JSON-requests, of zet de ETag expliciet op de data die ertoe doet en negeer de CSRF-kreukel.
Ten tweede bevatten ActiveStorage-URL’s gesigneerde verlooptimestamps, wat betekent dat een pagina met afbeeldings-URL’s technisch elke keer andere content is, zelfs als de onderliggende data niet veranderd is. Houd de TTL van de gesigneerde URL lang (een week) en rond de timestamp af op het uur, gebruik een CDN dat de URLs herschrijft, of accepteer dat pagina’s met veel image-varianten niet schoon 304’en.
Ten derde devise en flash-berichten. Elk request dat draaide met een flash-hash in de sessie genereert een andere response-body, wat ETags buste. Gebruik flash.now voor berichten die bij het huidige request horen, zet alleen persistente flash[:notice] als je op het punt staat te redirecten, en je dashboardpagina’s stoppen met vechten tegen je cachinglaag.
Wanneer Je Rails HTTP Caching Helemaal Moet Overslaan
Niet elk endpoint profiteert. Pagina’s die real-time werk doen (chat, live dashboards, alles aangestuurd door Turbo Streams zoals in /nl/2026/06/05/rails-turbo-morphing-broadcasts-refreshes-dom-updates/) moeten conditional GET overslaan en op websockets leunen. Endpoints die state muteren (POST, PATCH, DELETE) kunnen niet gecachet worden op HTTP-niveau — punt. Endpoints waarvan het freshness-venster korter is dan de round-trip (financiële tickers, sportuitslagen) zijn beter af met een korte Cache-Control: max-age zonder ETag-overhead. Het patroon is niet “cache alles” maar “cache de long tail van leeszware endpoints die je CPU-grafiek domineren.”
Veelgestelde Vragen Over Rails HTTP Caching
Wat is het verschil tussen fresh_when en stale? in Rails?
fresh_when en stale? zijn twee ergonomieën voor dezelfde primitive: conditional GET. fresh_when retourneert direct met een 304 als het request fresh is, dus je schrijft niets na de aanroep. stale? retourneert true als het request stale is, dus je wikkelt duur werk zoals database-queries of externe API-aanroepen binnen if stale?(...). Gebruik fresh_when als de enige kost het renderen van de view is; gebruik stale? als er echt werk is dat je alleen op een cache miss wilt doen.
Hoe werken Rails ETags met CDN’s zoals CloudFront of Cloudflare?
CDN’s honoreren ETag-, Last-Modified- en Cache-Control-headers zolang je public: true op de respons zet. CloudFront en Cloudflare forwarden allebei If-None-Match van de browser wanneer hun eigen cache mist, dus je Rails-app krijgt nog steeds de kans om 304 te retourneren. Voor volledige CDN-caching zonder revalidatie combineer je expires_in 5.minutes, public: true met stale_while_revalidate zodat het CDN stale content serveert tijdens achtergrond-revalidatie. Zet altijd Vary: Accept-Language voor meertalige sites, anders lekt het CDN de verkeerde taal naar de verkeerde gebruikers.
Werkt Rails HTTP caching met ingelogde gebruikers?
Ja, met zorg. Gebruik public: false zodat gedeelde caches geen gebruiker-specifieke responses opslaan, neem current_user op in de ETag zodat twee gebruikers nooit botsen, en let op flash-berichten en CSRF-tokens die in de response-body lekken. Conditional GET is juist waardevol voor authenticated dashboards omdat dezelfde gebruiker die elke minuut op refresh drukt het ergste geval is voor serverbelasting en het beste geval voor ETag-hits.
Waarom matchen mijn Rails ETags nooit?
De drie gebruikelijke oorzaken: iets in de response-body verandert bij elk request (CSRF-token, gesigneerde URL-vervaltijd, flash-bericht, request-id), de updated_at van het model wordt bijgewerkt door een ongerelateerde callback waardoor de cache-sleutel verandert, of een middleware stroomafwaarts herschrijft de body (compressie met gzip-headers, response-signing). Gebruik curl -I om te bevestigen dat de ETag identiek is op twee opeenvolgende requests zonder app-wijzigingen ertussen. Is dat niet zo, dan verandert de body en moet je uitzoeken waarom.
Rails HTTP caching is een van die features die sinds versie 2 in het framework zit en op de meeste apps ongebruikt blijft omdat niemand de ontbrekende performance opmerkt. Voeg fresh_when toe aan je drie drukste actions vanmiddag, meet de 304-ratio volgende week, en je CPU-grafiek vertelt je of het de twintig regels code waard was. In mijn ervaring is dat altijd zo.
Hulp nodig bij het vinden van de cacheable endpoints in je Rails-app, of bij het inrichten van een CDN dat daadwerkelijk 304’s teruggeeft? TTB Software is gespecialiseerd in Rails performance en infrastructuur voor founders en groeifase-teams. We doen dit al negentien jaar.
Related Articles
Rails Stripe Billing: Abonnementen, Webhooks, Proratie en Dunning die de Productie Overleven
Rails Stripe billing goed gedaan. Abonnementslevenscyclus, idempotente webhooks, proratie, dunning, en de valkuilen d...
Rails LLM Kostentracking: Spend per Tenant, Budgetlimieten en Realtime Quota-handhaving
Rails LLM kostentracking die een verrassing van 40k overleeft. Tokenboekhouding per tenant, budgetlimieten, quota-han...
Rails Counter Cache: N+1 COUNT-queries elimineren zonder productievalkuilen
Rails counter cache elimineert N+1 COUNT-queries op has_many-associaties. Stel hem juist in, reset achterhaalde telle...