Overview
HTTP caching headers form a control mechanism between servers, intermediary caches, and client browsers that determines whether responses can be stored, how long they remain valid, and under what conditions they can be reused. These headers reduce server load by avoiding redundant processing, decrease network traffic by preventing unnecessary data transfer, and improve perceived application performance by serving cached content instead of making round-trip requests.
The HTTP caching model operates through response headers that servers attach to outgoing responses and request headers that clients send to validate cached content. Servers specify caching policies using directives in headers like Cache-Control, while validation mechanisms through ETag and Last-Modified headers enable conditional requests that return 304 Not Modified responses when content hasn't changed.
Caching occurs at multiple layers: browser caches store resources locally on the client device, proxy caches sit between clients and servers to serve multiple users, CDN edge servers cache content geographically close to users, and reverse proxies like Varnish cache at the application edge. Each layer respects the cache directives set by the origin server.
# Rails controller setting cache headers
class ArticlesController < ApplicationController
def show
@article = Article.find(params[:id])
# Set caching for 1 hour
expires_in 1.hour, public: true
# Use conditional GET with ETag
fresh_when(etag: @article, last_modified: @article.updated_at)
end
end
The distinction between private and shared caches determines header behavior. Private caches store responses for individual users in their browsers, while shared caches serve multiple users through proxies or CDNs. The Cache-Control directive private restricts storage to browser caches, while public permits intermediary caches to store responses.
Key Principles
Cache headers operate on the principle of freshness lifetime, validation, and revalidation. A response enters the cache with a freshness lifetime determined by max-age or Expires headers. During this period, the cache serves the stored response without contacting the origin server. After expiration, the cache must revalidate the content before serving it again.
The Cache-Control header contains directives that define caching behavior. Multiple directives combine with commas: Cache-Control: public, max-age=3600, must-revalidate. Each directive modifies caching behavior independently, with some directives taking precedence over others. Request directives from clients can further restrict caching but cannot override server restrictions.
Freshness determination follows a hierarchy. The max-age directive specifies seconds until expiration and takes precedence over Expires. When both appear, max-age wins. The s-maxage directive applies specifically to shared caches, overriding max-age for proxies while browsers still respect max-age. The Expires header provides an absolute timestamp for expiration but suffers from clock synchronization issues between client and server.
# Setting multiple cache directives
response.headers['Cache-Control'] = 'public, max-age=3600, s-maxage=7200, must-revalidate'
# Equivalent to:
# - Cacheable by browsers and proxies (public)
# - Browsers cache for 1 hour (max-age=3600)
# - Proxies cache for 2 hours (s-maxage=7200)
# - Must revalidate after expiration (must-revalidate)
Validation mechanisms enable efficient cache revalidation without transferring full response bodies. The server generates validators - either strong ETags that change with any content modification or weak ETags that change only with semantically significant changes. The Last-Modified timestamp provides a time-based validator. Clients include these validators in conditional requests using If-None-Match for ETags or If-Modified-Since for timestamps.
When a cached response expires, the client sends a conditional request including the stored validator. If the content hasn't changed, the server responds with 304 Not Modified and an empty body, confirming the cached version remains valid. The client updates the freshness lifetime from the new Cache-Control headers and continues serving the cached content. If content changed, the server returns 200 OK with the new content and validator.
The no-cache directive doesn't prevent caching but forces revalidation before every use. Caches store the response but must validate with the origin server before serving it, even during the freshness lifetime. The no-store directive actually prevents caching, instructing all caches to discard the response immediately and never store it. This distinction causes frequent confusion.
The Vary header indicates that cached responses depend on specific request headers beyond the URL. A response with Vary: Accept-Encoding must be cached separately for gzip, deflate, and uncompressed versions. The cache uses both the URL and the varying header values as the cache key. Excessive Vary values reduce cache effectiveness by creating too many cache entries.
Stale-while-revalidate and stale-if-error extend cache behavior during revalidation. The stale-while-revalidate directive allows serving stale content while asynchronously fetching fresh content in the background, reducing latency. The stale-if-error directive permits serving stale content when the origin server returns errors, improving availability during failures.
# Cache-Control directives breakdown
directives = {
'public' => 'Allow shared caches to store',
'private' => 'Only browser caches allowed',
'no-cache' => 'Must revalidate before use',
'no-store' => 'Do not cache at all',
'max-age=3600' => 'Fresh for 3600 seconds',
's-maxage=7200' => 'Shared cache freshness override',
'must-revalidate' => 'Strict revalidation after expiration',
'proxy-revalidate' => 'Shared caches must revalidate',
'immutable' => 'Never revalidate during freshness',
'stale-while-revalidate=60' => 'Serve stale for 60s during revalidation',
'stale-if-error=86400' => 'Serve stale for 24h on errors'
}
Ruby Implementation
Rails provides high-level methods in ActionController that abstract cache header management. The expires_in method sets Cache-Control max-age and optionally includes public, private, must-revalidate, or proxy-revalidate directives. The expires_now method marks responses as immediately stale. The fresh_when and stale? methods handle conditional GET requests by comparing ETags and Last-Modified timestamps.
class ProductsController < ApplicationController
# Declarative cache control for all actions
before_action :set_cache_headers
def index
@products = Product.all
# Cache for 5 minutes in browser only
expires_in 5.minutes, public: false
end
def show
@product = Product.find(params[:id])
# Combine time-based and validation-based caching
expires_in 1.hour, public: true
# Generate ETag from product and current user
# Returns 304 if ETag matches and not stale
fresh_when(
etag: [@product, current_user],
last_modified: @product.updated_at,
public: true
)
end
private
def set_cache_headers
# Default to no caching for authenticated requests
if user_signed_in?
expires_now
end
end
end
The fresh_when method generates an ETag from the provided objects using MD5 hashing and sets both ETag and Last-Modified headers. If the request includes If-None-Match or If-Modified-Since headers that match, Rails automatically renders a 304 response and skips view rendering. The stale? method provides the inverse logic, returning true when the cache is stale and the action should proceed normally.
def show
@article = Article.find(params[:id])
# Manual conditional handling with stale?
if stale?(etag: @article, last_modified: @article.updated_at)
# This block only executes if cache is stale
respond_to do |format|
format.html
format.json { render json: @article }
end
end
# If not stale, Rails automatically returns 304
end
Rack middleware operates at a lower level, providing direct access to response headers. Setting cache headers in middleware applies them to all responses matching certain conditions. Custom middleware can implement sophisticated caching logic based on request characteristics.
# Rack middleware for cache control
class CacheControl
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
# Set cache headers based on path patterns
if env['PATH_INFO'] =~ /\.(css|js|png|jpg|gif)$/
# Cache static assets for 1 year
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
elsif env['PATH_INFO'].start_with?('/api/')
# API responses cached for 5 minutes
headers['Cache-Control'] = 'public, max-age=300'
else
# HTML pages not cached by default
headers['Cache-Control'] = 'no-store'
end
[status, headers, body]
end
end
# config/application.rb
config.middleware.use CacheControl
Sinatra applications set headers directly on the response object. The cache_control helper method constructs the Cache-Control header from named parameters and symbols.
require 'sinatra'
get '/articles/:id' do
@article = Article.find(params[:id])
# Set cache headers with helper
cache_control :public, :must_revalidate, max_age: 3600
# Set ETag manually
etag Digest::MD5.hexdigest(@article.to_json)
# Set Last-Modified
last_modified @article.updated_at
erb :article
end
# Or set headers directly
get '/data' do
response['Cache-Control'] = 'private, no-cache'
response['ETag'] = '"abc123"'
{ data: fetch_data }.to_json
end
Rails asset pipeline automatically appends content hashes to asset filenames, creating cache-busting URLs like application-a3b4c5d6.css. These fingerprinted assets receive far-future expires headers because changing content generates new filenames, naturally invalidating caches. Rails configures this through config.public_file_server.headers for static files.
# config/environments/production.rb
Rails.application.configure do
# Serve static files with cache headers
config.public_file_server.enabled = true
config.public_file_server.headers = {
'Cache-Control' => 'public, max-age=31536000, immutable'
}
# Asset pipeline fingerprinting
config.assets.compile = false
config.assets.digest = true
end
Conditional GET handling in Rails compares weak ETags by default. Strong ETags require exact byte-for-byte matches, while weak ETags allow semantically equivalent content to match. Rails generates weak ETags with a W/ prefix. Applications can generate strong ETags by providing raw strings.
class DocumentsController < ApplicationController
def show
@document = Document.find(params[:id])
# Weak ETag (default) - W/"abc123"
fresh_when(etag: @document)
# Strong ETag - "abc123"
fresh_when(etag: Digest::MD5.hexdigest(@document.content))
# Complex ETag from multiple attributes
etag_value = [@document.id, @document.version, @document.updated_at].join('-')
fresh_when(etag: etag_value)
end
end
The ActionController::ConditionalGet module provides additional methods for cache control. The expires_in method accepts durations from ActiveSupport like 1.hour or 2.days, converting them to seconds for the max-age directive. Options hash includes :public, :private, :must_revalidate, :proxy_revalidate, and :immutable.
# Various expires_in configurations
class PagesController < ApplicationController
def home
# Public cache for 10 minutes
expires_in 10.minutes, public: true
end
def dashboard
# Private cache (browser only) for 5 minutes
expires_in 5.minutes, public: false
end
def feed
# Shared caches get 1 hour, browsers get 15 minutes
response.headers['Cache-Control'] = 'public, max-age=900, s-maxage=3600'
end
def secure_data
# Disable all caching
expires_now
# Equivalent to: response.headers['Cache-Control'] = 'no-cache, no-store'
end
end
Practical Examples
A news website implements tiered caching based on content mutability. Breaking news articles update frequently and receive short cache times with revalidation, while older archived articles remain static and cache for extended periods. The homepage aggregates multiple articles and requires shorter caching than individual articles.
class ArticlesController < ApplicationController
def index
@articles = Article.recent.limit(20)
# Homepage changes frequently with new articles
# Cache for 2 minutes, must revalidate
expires_in 2.minutes, public: true
response.headers['Cache-Control'] += ', must-revalidate'
# ETag based on latest article timestamps
fresh_when(etag: @articles.maximum(:updated_at))
end
def show
@article = Article.find(params[:id])
# Age-based caching strategy
article_age = Time.current - @article.published_at
if article_age < 1.day
# Recent articles cache for 5 minutes
cache_duration = 5.minutes
elsif article_age < 1.week
# Week-old articles cache for 1 hour
cache_duration = 1.hour
else
# Archived articles cache for 1 day
cache_duration = 1.day
end
expires_in cache_duration, public: true
fresh_when(etag: @article, last_modified: @article.updated_at)
end
end
An e-commerce API implements cache control based on request authentication and data sensitivity. Public product listings cache aggressively in CDNs, while user-specific cart data remains private. Inventory checks require revalidation to prevent stale stock information.
class Api::ProductsController < ApplicationController
def index
@products = Product.active.includes(:images)
# Public product listings cache in CDN
expires_in 15.minutes, public: true
# Vary by Accept header for JSON/XML content negotiation
response.headers['Vary'] = 'Accept'
# ETag includes product data and active count
fresh_when(
etag: [@products.maximum(:updated_at), @products.count],
last_modified: @products.maximum(:updated_at)
)
render json: @products
end
def show
@product = Product.find(params[:id])
# Individual products cache longer
expires_in 1.hour, public: true
# Include inventory in ETag so stock changes invalidate cache
fresh_when(
etag: [@product, @product.stock_count],
last_modified: [@product.updated_at, @product.inventory_updated_at].max
)
render json: @product
end
end
class Api::CartsController < ApplicationController
before_action :authenticate_user!
def show
@cart = current_user.cart
# Cart data is user-specific and private
expires_in 0, public: false
response.headers['Cache-Control'] = 'private, no-store'
render json: @cart
end
end
A content API implements conditional requests with strong ETags for data integrity. Document downloads use ETags matching file content hashes, ensuring clients detect any corruption or unauthorized modifications. The API supports partial content updates where clients request only changed portions.
class DocumentsController < ApplicationController
def show
@document = Document.find(params[:id])
# Generate strong ETag from content hash
content_hash = Digest::SHA256.hexdigest(@document.file_content)
# Set strong ETag (no W/ prefix)
response.headers['ETag'] = "\"#{content_hash}\""
response.headers['Last-Modified'] = @document.updated_at.httpdate
# Check conditional request headers
if request.headers['If-None-Match'] == "\"#{content_hash}\""
head :not_modified
return
end
if request.headers['If-Modified-Since']
request_time = Time.parse(request.headers['If-Modified-Since'])
if @document.updated_at <= request_time
head :not_modified
return
end
end
# Cache document for 1 hour with revalidation
response.headers['Cache-Control'] = 'private, max-age=3600, must-revalidate'
send_data @document.file_content,
type: @document.content_type,
disposition: 'inline'
end
end
A dashboard application separates caching strategies for different data freshness requirements. Real-time metrics disable caching entirely, hourly reports cache at the edge, and historical analytics cache for extended periods. The application uses stale-while-revalidate for graceful handling of backend latency.
class DashboardController < ApplicationController
def realtime_metrics
@metrics = MetricsService.current_values
# No caching for real-time data
expires_now
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
render json: @metrics
end
def hourly_report
@report = ReportCache.fetch_hourly(params[:date])
# Cache for 5 minutes, serve stale during revalidation
response.headers['Cache-Control'] =
'public, max-age=300, stale-while-revalidate=60'
# ETag based on report generation timestamp
fresh_when(etag: @report.generated_at.to_i)
render json: @report
end
def historical_analytics
@analytics = Analytics.for_date_range(params[:start], params[:end])
# Historical data doesn't change - cache for 7 days
expires_in 7.days, public: true
response.headers['Cache-Control'] += ', immutable'
# Strong ETag from data hash
etag_value = Digest::MD5.hexdigest(@analytics.to_json)
fresh_when(etag: etag_value)
render json: @analytics
end
end
Performance Considerations
Cache hit ratios determine caching effectiveness. A 90% cache hit ratio means 90% of requests serve from cache, reducing origin server load by 90%. Monitoring hit ratios identifies which content benefits from caching and which cache settings require adjustment. CDN providers report cache hit ratios in their analytics dashboards.
Cache key size affects storage efficiency and lookup performance. ETags generated from large model graphs create bloated cache keys. The ETag generation process itself consumes CPU cycles. Applications should generate ETags from minimal necessary data like id, updated_at, and version numbers rather than serializing entire object trees.
# Inefficient - serializes entire object graph
fresh_when(etag: @article.to_json)
# Efficient - uses minimal identifying data
fresh_when(etag: [@article.id, @article.updated_at, @article.version])
# Even more efficient - single timestamp
fresh_when(etag: @article.cache_key_with_version)
# Rails cache_key_with_version generates: "articles/123-20250110120000000000"
The max-age directive balances freshness against server load. Setting max-age too high serves stale content, while too low defeats caching benefits. Applications adjust max-age based on content update frequency. Content that updates hourly needs max-age under an hour, while static content tolerates longer caching.
Browser cache storage limitations force cache eviction using LRU algorithms. Browsers typically allocate 50MB to 100MB for HTTP cache per origin. Large responses with long max-age values fill cache storage, evicting other potentially valuable cached content. Caching many 10MB resources prevents caching numerous smaller resources.
Vary headers reduce cache efficiency by fragmenting cached responses. A response with Vary: Accept-Encoding, Accept-Language, Cookie creates separate cache entries for each combination of these headers. If an application serves 3 encodings, 10 languages, and varies by cookie, the cache stores up to 30 copies of each response. Minimizing Vary headers improves cache hit ratios.
# Problematic - excessive Vary headers
response.headers['Vary'] = 'Accept-Encoding, Accept-Language, User-Agent, Cookie'
# Creates cache entries for every unique combination
# Better - Vary only on essential headers
response.headers['Vary'] = 'Accept-Encoding'
# Or normalize on the server side instead of using Vary
The immutable directive improves performance for cache-busted assets by preventing conditional requests. When browsers see immutable during the freshness lifetime, they serve cached content without revalidation requests. This eliminates unnecessary round trips for fingerprinted assets that never change.
# Asset responses with immutable
class AssetsController < ApplicationController
def show
# Fingerprinted URLs like /assets/app-a1b2c3.js
# Content never changes for this URL
expires_in 1.year, public: true
response.headers['Cache-Control'] += ', immutable'
send_file asset_path, disposition: 'inline'
end
end
Stale-while-revalidate reduces perceived latency by serving cached content immediately while fetching fresh content in the background. The client returns stale content instantly and updates the cache asynchronously. This works best for content where eventual consistency suffices.
def feed
@posts = Post.recent.limit(50)
# Serve stale content for up to 1 minute while revalidating
response.headers['Cache-Control'] =
'public, max-age=300, stale-while-revalidate=60'
fresh_when(etag: @posts.maximum(:updated_at))
end
Cache warming populates caches proactively before user requests. Background jobs request critical pages after deployments or data updates, storing fresh content in CDN edge nodes. This prevents cache misses during traffic spikes when cold caches would overload origin servers.
Conditional request processing still executes controller code and database queries in Rails even when returning 304 responses. The fresh_when method doesn't prevent query execution - it only skips view rendering. Applications should implement low-level caching or query optimization for ETag generation.
def show
# This query always executes, even for 304 responses
@article = Article.find(params[:id])
# Fragment caching for expensive computations
@related = Rails.cache.fetch(
"article/#{@article.id}/related",
expires_in: 1.hour
) do
@article.find_related_articles
end
fresh_when(etag: @article)
end
Security Implications
Private data must never receive public cache directives. Setting Cache-Control: public on authenticated responses stores them in shared caches where other users can retrieve them. Proxies and CDNs cache public responses across users, creating data leaks when personal information appears in supposedly private responses.
class AccountsController < ApplicationController
before_action :authenticate_user!
def show
@account = current_user.account
# WRONG - authenticated data marked public
# expires_in 1.hour, public: true
# CORRECT - private data only in browser cache
expires_in 5.minutes, public: false
# Or explicitly disable caching
expires_now
end
end
The no-store directive prevents all caching for sensitive data like credit card forms, SSN inputs, or financial transactions. Browser caches and all intermediaries must not store these responses. Using no-cache instead of no-store allows caching but requires revalidation, which still risks exposing sensitive data in cache storage.
class PaymentsController < ApplicationController
def new
# Prevent caching of payment forms
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
response.headers['Pragma'] = 'no-cache' # HTTP/1.0 compatibility
response.headers['Expires'] = '0'
end
end
Cookie-based authentication complicates caching because cookies in requests make responses user-specific. Setting Vary: Cookie creates separate cache entries per user session, defeating shared cache benefits. Applications should structure URLs to separate authenticated and public content, applying different cache policies to each.
# Problem: Varying on Cookie fragments cache excessively
def index
@posts = current_user ? Post.for_user(current_user) : Post.public_posts
if current_user
# User-specific view not cacheable in shared caches
expires_in 1.minute, public: false
else
# Public view cacheable
expires_in 10.minutes, public: true
end
# Varying on Cookie creates cache entry per user
response.headers['Vary'] = 'Cookie'
end
# Better: Separate endpoints for authenticated and public content
def index
@posts = Post.public_posts
expires_in 10.minutes, public: true
end
def dashboard
@posts = Post.for_user(current_user)
expires_in 1.minute, public: false
end
ETag timing attacks can leak information about resource modifications. If ETag generation depends on access control, attackers probe ETags to determine when restricted content changes. Applications should include user identity in ETag calculation for access-controlled resources.
class DocumentsController < ApplicationController
def show
@document = Document.find(params[:id])
authorize! @document # Check permissions
# VULNERABLE - ETag same for all users
# fresh_when(etag: @document)
# SECURE - ETag includes user context
fresh_when(etag: [@document, current_user.id, current_user.permissions])
end
end
HTTP Strict Transport Security (HSTS) headers must not be cached by browsers beyond their max-age directive. Applications should set separate cache policies for HSTS headers versus response content. Overly long HSTS max-age values persist even after certificate issues or domain changes.
Proxy cache poisoning occurs when attackers manipulate cache keys to store malicious content. If cache keys depend on unvalidated headers, attackers craft requests that cache under keys normal users request. Applications must sanitize headers used in cache keys and Vary specifications.
Authorization-dependent responses require careful ETag design. When document access depends on user roles, ETags must reflect both document state and user permissions. Otherwise, users with different permissions might receive cached responses intended for other authorization levels.
def document
@doc = Document.find(params[:id])
# User permissions affect what fields are visible
visible_fields = @doc.fields_visible_to(current_user)
# ETag must include permission level
fresh_when(
etag: [
@doc.id,
@doc.updated_at,
current_user.role,
current_user.document_permissions(@doc)
]
)
render json: @doc.as_json(only: visible_fields)
end
Common Pitfalls
Confusing no-cache with no-store causes inappropriate caching of sensitive data. The no-cache directive allows storage but requires revalidation before use, while no-store prohibits storage entirely. Developers mistakenly use no-cache expecting no storage, but caches still store these responses on disk.
# WRONG - This still caches the response
response.headers['Cache-Control'] = 'no-cache'
# Cache stores but revalidates before serving
# CORRECT - This prevents all caching
response.headers['Cache-Control'] = 'no-store'
# Cache never stores the response
Setting Cache-Control on requests instead of responses fails silently. Applications frequently set cache headers in before_action filters but misconfigure them to apply to requests rather than responses. Cache directives in request headers have different semantics from response headers.
# WRONG - Sets request header, not response header
before_action do
request.headers['Cache-Control'] = 'public, max-age=3600'
end
# CORRECT - Sets response header
before_action do
response.headers['Cache-Control'] = 'public, max-age=3600'
end
ETags generated from random data change on every request, preventing cache hits. Applications that include request_id, session tokens, or timestamps in ETags create unique ETags for identical content. Each request generates different ETags even when content hasn't changed.
# WRONG - ETag changes every request
fresh_when(etag: [request.uuid, @article])
# WRONG - Timestamp creates new ETag each request
fresh_when(etag: [Time.current, @article])
# CORRECT - ETag based on stable content identifiers
fresh_when(etag: [@article.id, @article.updated_at])
Forgetting Last-Modified when using ETags reduces cache validation efficiency. HTTP allows either or both validators. Providing both enables caches to use whichever validator matches. Some proxies prefer Last-Modified timestamps over ETags for simplicity.
# Suboptimal - ETag only
fresh_when(etag: @article)
# Better - Both validators
fresh_when(
etag: @article,
last_modified: @article.updated_at
)
Must-revalidate confusion leads to unexpected stale content serving. The must-revalidate directive applies after expiration, not during freshness lifetime. During max-age, caches serve content without revalidation. After expiration, must-revalidate requires successful revalidation or error response instead of serving stale content.
Overusing Vary headers creates cache fragmentation. Adding Vary headers seems like a safe way to prevent cache collisions, but each Vary header multiplies cache entries. Applications with Vary: Cookie, User-Agent, Accept-Language create separate entries for each combination, drastically reducing hit ratios.
# PROBLEMATIC - Creates excessive cache entries
response.headers['Vary'] = 'Accept-Encoding, Accept-Language, User-Agent'
# BETTER - Vary only on essential headers
response.headers['Vary'] = 'Accept-Encoding'
# Or normalize on server instead of varying
def index
# Detect client capabilities and normalize response format
# Don't vary cache on minor differences
@format = detect_optimal_format(request)
response.headers['Vary'] = 'Accept-Encoding'
end
Caching POST responses violates HTTP semantics. HTTP specifies that only GET and HEAD responses are cacheable by default. Applications attempting to cache POST endpoints confuse client behavior and violate standard expectations. POST responses should use 303 redirects to cacheable GET endpoints.
Setting max-age without considering clock skew causes premature expiration. Shared caches compare current time against max-age but clocks may differ. A 60-second max-age might expire in 55 or 65 seconds depending on clock drift. Applications should add buffer time or use must-revalidate with longer max-age values.
Missing charset in Content-Type combined with cached responses causes character encoding issues. Browsers guess encoding for cached content without explicit charset, potentially corrupting international characters. Applications must include charset in Content-Type headers for cached text responses.
# WRONG - Missing charset
response.headers['Content-Type'] = 'application/json'
response.headers['Cache-Control'] = 'public, max-age=3600'
# CORRECT - Explicit charset
response.headers['Content-Type'] = 'application/json; charset=utf-8'
response.headers['Cache-Control'] = 'public, max-age=3600'
Reference
Cache-Control Directives
| Directive | Description | Use Case |
|---|---|---|
| public | Cacheable by all caches including shared proxies and CDNs | Public content like product pages, articles, documentation |
| private | Cacheable only by browser, not shared caches | User-specific data like account settings, personalized feeds |
| no-cache | Must revalidate with origin server before serving from cache | Content requiring freshness validation on every use |
| no-store | Do not cache at all, not even in browser cache | Sensitive data like payment forms, confidential documents |
| max-age=N | Fresh for N seconds from response time | General time-based caching, N depends on update frequency |
| s-maxage=N | Shared cache freshness override, ignores max-age for proxies | Different cache times for browsers vs CDNs |
| must-revalidate | Must revalidate after expiration, cannot serve stale | Content requiring strict freshness guarantees |
| proxy-revalidate | Shared caches must revalidate, browsers can serve stale | Relaxed freshness for browsers, strict for CDNs |
| immutable | Never revalidate during freshness lifetime | Fingerprinted assets that never change for a given URL |
| stale-while-revalidate=N | Serve stale for N seconds while fetching fresh content | Reduce latency with background revalidation |
| stale-if-error=N | Serve stale for N seconds if origin returns error | Improve availability during backend failures |
| no-transform | Proxies must not modify response | Prevent proxy compression or format changes |
Validation Headers
| Header | Type | Description | Example |
|---|---|---|---|
| ETag | Response | Entity tag validator for content version | ETag: "686897696a7c876b7e" |
| Last-Modified | Response | Timestamp of last modification | Last-Modified: Wed, 15 Mar 2025 07:28:00 GMT |
| If-None-Match | Request | Client sends ETag for conditional request | If-None-Match: "686897696a7c876b7e" |
| If-Modified-Since | Request | Client sends timestamp for conditional request | If-Modified-Since: Wed, 15 Mar 2025 07:28:00 GMT |
| If-Match | Request | Require ETag match for PUT/DELETE operations | If-Match: "686897696a7c876b7e" |
| If-Unmodified-Since | Request | Require no modification since timestamp | If-Unmodified-Since: Wed, 15 Mar 2025 07:28:00 GMT |
Response Status Codes
| Status | Description | When Used |
|---|---|---|
| 200 OK | Content returned, cache can store with appropriate headers | Normal response with content body |
| 304 Not Modified | Cached version still valid, no body returned | Conditional request with matching validator |
| 412 Precondition Failed | If-Match or If-Unmodified-Since precondition failed | Conditional request on PUT/DELETE with mismatched validator |
Rails Cache Helper Methods
| Method | Parameters | Description |
|---|---|---|
| expires_in | duration, options | Set Cache-Control max-age and optional directives |
| expires_now | none | Set headers to prevent all caching |
| fresh_when | etag, last_modified, public, template | Generate validators and respond 304 if not stale |
| stale? | etag, last_modified, public, template | Return true if cache stale, false if fresh (auto 304) |
| http_cache_forever | public | Set far-future expires for immutable content |
Common Cache Duration Patterns
| Content Type | Recommended max-age | Rationale |
|---|---|---|
| HTML pages | 0 to 5 minutes | Frequently updated, requires freshness |
| API responses | 1 to 15 minutes | Balance freshness and load reduction |
| User avatars | 1 hour to 1 day | Change infrequently, tolerate staleness |
| Product images | 1 day to 1 week | Rarely change, optimize bandwidth |
| Fingerprinted assets | 1 year, immutable | Never change for given URL, cache forever |
| Static documentation | 1 to 7 days | Infrequent updates, high cachability |
| Real-time data | no-store | Must reflect current state, never cache |
Rack Header Setting Patterns
# Public cache for 1 hour
response['Cache-Control'] = 'public, max-age=3600'
# Private cache for 5 minutes
response['Cache-Control'] = 'private, max-age=300'
# Cache with revalidation required
response['Cache-Control'] = 'public, max-age=3600, must-revalidate'
# Different durations for browsers and shared caches
response['Cache-Control'] = 'public, max-age=300, s-maxage=3600'
# Serve stale during revalidation
response['Cache-Control'] = 'public, max-age=600, stale-while-revalidate=60'
# No caching whatsoever
response['Cache-Control'] = 'no-store, no-cache, must-revalidate'
response['Pragma'] = 'no-cache'
response['Expires'] = '0'
# Set validators
response['ETag'] = '"abc123def456"'
response['Last-Modified'] = Time.current.httpdate
# Vary on specific headers
response['Vary'] = 'Accept-Encoding'
response['Vary'] = 'Accept-Encoding, Accept-Language'