CrackedRuby CrackedRuby

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'