Overview
Browser caching stores copies of web resources locally in the client's browser, eliminating the need to re-fetch unchanged resources from the server on subsequent requests. When a user visits a web page, the browser downloads HTML, CSS, JavaScript, images, and other assets. Without caching, every page visit would require downloading all these resources again, wasting bandwidth and time. Browser caching solves this by storing resources locally and checking with the server whether stored versions remain valid.
The caching system operates through HTTP headers exchanged between client and server. The server includes headers in its responses that instruct the browser how to cache the resource. These headers specify caching duration, validation requirements, and whether the resource can be stored in shared caches. The browser reads these headers and stores the resource according to the instructions.
HTTP caching involves two main models: expiration and validation. Expiration-based caching uses timestamps or age limits to determine when a cached resource becomes stale. The browser serves the cached version without contacting the server until the expiration time passes. Validation-based caching stores resources but checks with the server before using them, typically sending a validator like an ETag or Last-Modified timestamp. The server responds with either a full resource or a 304 Not Modified status indicating the cached version remains current.
Browser caches exist at multiple levels. The private browser cache stores resources for a single user, while shared caches like proxy servers and CDNs serve multiple users. This distinction affects which resources can be cached where—public resources suitable for shared caching versus private resources containing user-specific data.
# Basic cache header setting in Rack application
class CachedStaticAssets
def call(env)
status, headers, body = @app.call(env)
if env['PATH_INFO'] =~ /\.(css|js|png|jpg)$/
headers['Cache-Control'] = 'public, max-age=31536000'
headers['Expires'] = (Time.now + 31536000).httpdate
end
[status, headers, body]
end
end
Understanding browser caching requires knowledge of HTTP protocol semantics, web server configuration, and client behavior across different browsers and network conditions. Proper caching implementation significantly impacts application performance, user experience, and infrastructure costs.
Key Principles
Browser caching operates through several fundamental mechanisms defined in HTTP specifications. The Cache-Control header provides the primary directives controlling cache behavior. This header accepts multiple directives that specify whether responses can be cached, who can cache them, and for how long they remain valid. The public directive allows caching in shared caches, while private restricts caching to the user's browser. The max-age directive sets a time-to-live in seconds, and no-cache forces validation with the origin server before using cached content.
Cache validation determines whether stored resources remain current. Strong validators like ETags provide a unique identifier for each version of a resource. When the browser requests a resource it has cached, it includes the ETag in an If-None-Match header. The server compares this ETag with the current version's ETag. If they match, the server responds with 304 Not Modified and no body, saving bandwidth. If they differ, the server sends the updated resource with a new ETag.
Weak validators like Last-Modified timestamps provide less precise validation. The browser sends the cached timestamp in an If-Modified-Since header. The server compares this timestamp with the resource's modification time. If unchanged, it returns 304 Not Modified. Weak validators suffice when resources change infrequently or exact byte-for-byte equality matters less than semantic equivalence.
# ETag generation and validation
require 'digest'
class ETagMiddleware
def call(env)
status, headers, body = @app.call(env)
# Generate ETag from response body
body_content = body.respond_to?(:join) ? body.join : body.to_s
etag = Digest::MD5.hexdigest(body_content)
headers['ETag'] = %("#{etag}")
# Check If-None-Match header
if env['HTTP_IF_NONE_MATCH'] == %("#{etag}")
[304, {'ETag' => %("#{etag}")}, []]
else
[status, headers, [body_content]]
end
end
end
Freshness defines the period during which a cached resource can be served without validation. Explicit freshness comes from Cache-Control max-age or Expires headers. Implicit freshness calculations occur when neither is present, using heuristics based on Last-Modified age. Browsers typically cache such responses for 10% of the time since last modification, though this varies by implementation.
Staleness occurs when the freshness lifetime expires. Stale responses should not be served without validation, though stale-while-revalidate and stale-if-error directives allow serving stale content under specific circumstances. These directives improve resilience when origin servers become unavailable.
Cache storage mechanisms vary across browsers. Memory caches hold recently accessed resources in RAM for immediate retrieval. Disk caches persist resources across browser sessions. Service Workers provide programmable caching through the Cache API, allowing fine-grained control over what gets cached and when. Each storage mechanism has size limits that vary by browser and user settings.
# Implementing conditional requests with Last-Modified
class ConditionalGetMiddleware
def call(env)
status, headers, body = @app.call(env)
# Set Last-Modified header
last_modified = Time.now.httpdate
headers['Last-Modified'] = last_modified
# Check If-Modified-Since header
if_modified_since = env['HTTP_IF_MODIFIED_SINCE']
if if_modified_since && if_modified_since == last_modified
[304, {'Last-Modified' => last_modified}, []]
else
[status, headers, body]
end
end
end
Vary headers control cache key generation based on request headers. When a response includes Vary: Accept-Encoding, caches maintain separate entries for gzipped and uncompressed versions. This prevents serving compressed content to clients that cannot decompress it. The Vary header significantly impacts cache efficiency—varying on many headers reduces hit rates.
Ruby Implementation
Ruby web applications control browser caching through HTTP headers set in responses. The Rack middleware layer provides the foundation for all Ruby web frameworks, making Rack the natural place to implement caching logic. Rails, Sinatra, and other frameworks build on these primitives while adding convenience methods.
Rails provides declarative cache control through controller methods. The expires_in method sets Cache-Control and Expires headers with a single call. The fresh_when method implements conditional GET support using ETags and Last-Modified timestamps. Rails automatically generates ETags from response bodies or specified attributes, handling the If-None-Match header comparison and 304 response generation.
# Rails controller with cache control
class ArticlesController < ApplicationController
def show
@article = Article.find(params[:id])
# Set cache headers for 1 hour
expires_in 1.hour, public: true
# Conditional GET support using ETag
fresh_when(@article)
end
def index
@articles = Article.recent
# Generate ETag from collection and modification time
fresh_when([@articles, @articles.maximum(:updated_at)])
end
end
Rack middleware offers lower-level control over caching behavior. Rack::Cache provides a full HTTP cache implementation that can store responses in memory, on disk, or in memcached. This middleware intercepts requests, checks for cached responses, and serves them when valid. It handles validation, expiration, and cache key generation according to HTTP specifications.
# Rack::Cache configuration
require 'rack/cache'
use Rack::Cache,
metastore: 'file:/var/cache/rack/meta',
entitystore: 'file:/var/cache/rack/body',
verbose: true
# Custom cache configuration
use Rack::Cache do
set :verbose, true
set :metastore, 'memcached://localhost:11211/meta'
set :entitystore, 'memcached://localhost:11211/body'
end
Sinatra applications set cache headers directly through response headers or using helper methods. The cache_control helper provides a clean interface for setting Cache-Control directives. The last_modified and etag helpers implement conditional GET support.
# Sinatra caching implementation
require 'sinatra'
get '/article/:id' do
@article = Article.find(params[:id])
# Set cache control
cache_control :public, :max_age => 3600
# Conditional GET with ETag
etag @article.cache_key
# Conditional GET with Last-Modified
last_modified @article.updated_at
erb :article
end
get '/feed.xml' do
@articles = Article.recent
# Private cache with shorter lifetime
cache_control :private, :max_age => 300
content_type 'application/xml'
builder :feed
end
Asset pipeline integration in Rails automatically handles static asset caching through fingerprinting. Rails generates unique filenames for CSS, JavaScript, and image files by appending content hashes. This allows aggressive caching with long expiration times since changed content receives a new filename. The asset pipeline sets appropriate cache headers automatically.
# Rails asset caching configuration
# config/environments/production.rb
Rails.application.configure do
# Enable asset fingerprinting
config.assets.digest = true
# Set far-future expires headers
config.static_cache_control = "public, max-age=31536000, immutable"
# Configure CDN for assets
config.asset_host = 'https://cdn.example.com'
end
# Asset helpers generate fingerprinted URLs
# <%= stylesheet_link_tag 'application' %>
# Generates: <link href="/assets/application-abc123.css" />
Custom middleware enables application-specific caching strategies. Fragment caching, Russian Doll caching, and other patterns can be implemented through middleware that analyzes requests and responses, generates cache keys, and stores rendered content.
# Custom fragment cache middleware
class FragmentCache
def initialize(app, store)
@app = app
@store = store
end
def call(env)
request = Rack::Request.new(env)
cache_key = generate_key(request)
# Check cache
if cached = @store.read(cache_key)
return [200, cached[:headers], [cached[:body]]]
end
# Generate response
status, headers, body = @app.call(env)
body_content = body.map(&:to_s).join
# Store in cache if cacheable
if cacheable?(status, headers)
@store.write(cache_key, {
headers: headers,
body: body_content
}, expires_in: extract_ttl(headers))
end
[status, headers, [body_content]]
end
private
def generate_key(request)
"#{request.request_method}:#{request.path}:#{request.query_string}"
end
def cacheable?(status, headers)
status == 200 && headers['Cache-Control'] !~ /no-cache|no-store|private/
end
def extract_ttl(headers)
if headers['Cache-Control'] =~ /max-age=(\d+)/
$1.to_i
else
300 # Default 5 minutes
end
end
end
Practical Examples
Static asset caching represents the most straightforward caching scenario. Images, stylesheets, and JavaScript files rarely change, making them ideal candidates for aggressive caching. Setting a one-year max-age for these assets dramatically reduces server load and improves page load times for returning visitors.
# Static asset caching with Rack middleware
class StaticAssetCache
ASSET_EXTENSIONS = %w[.jpg .jpeg .png .gif .svg .css .js .woff .woff2 .ttf .eot]
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
path = env['PATH_INFO']
extension = File.extname(path).downcase
if ASSET_EXTENSIONS.include?(extension)
# Aggressive caching for static assets
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
headers['Expires'] = (Time.now + 31536000).httpdate
# Generate ETag from file content
body_content = body.respond_to?(:join) ? body.join : body.to_s
etag = Digest::MD5.hexdigest(body_content)
headers['ETag'] = %("#{etag}")
end
[status, headers, body]
end
end
API response caching requires more nuance. API endpoints often return data that changes frequently but not on every request. Implementing short cache lifetimes with validation ensures clients receive updated data without unnecessary network round trips.
# API response caching in Rails
class Api::ArticlesController < ApplicationController
def index
@articles = Article.includes(:author).recent.limit(20)
# Cache for 5 minutes
expires_in 5.minutes, public: true
# Add ETag for validation
fresh_when(@articles)
render json: @articles
end
def show
@article = Article.includes(:author, :comments).find(params[:id])
# Conditional GET with both validators
response.headers['Last-Modified'] = @article.updated_at.httpdate
if stale?(etag: @article, last_modified: @article.updated_at)
render json: @article
end
end
def create
@article = Article.create(article_params)
# Never cache POST responses
expires_now
render json: @article, status: :created
end
end
User-specific content caching demonstrates private cache usage. Dashboard data, user preferences, and personalized content should not be shared between users but can be cached in the individual user's browser.
# Private cache for user-specific content
class DashboardController < ApplicationController
before_action :authenticate_user!
def show
@dashboard = Dashboard.new(current_user)
# Private cache, 2 minute lifetime
expires_in 2.minutes, public: false
# ETag includes user ID to prevent cross-user caching
cache_key = [@dashboard, current_user.id]
fresh_when(cache_key)
end
def activity
@activities = current_user.activities.recent
# No caching for real-time activity
response.headers['Cache-Control'] = 'no-store'
render json: @activities
end
end
Conditional request handling shows validation-based caching in action. The server generates an ETag from resource content and compares it with the client's If-None-Match header, responding with 304 when they match.
# Detailed conditional request handling
class DocumentController < ApplicationController
def show
@document = Document.find(params[:id])
# Generate strong ETag from content and metadata
etag_source = [@document.content, @document.updated_at, @document.version]
etag = Digest::SHA256.hexdigest(etag_source.join(':'))
response.headers['ETag'] = %("#{etag}")
response.headers['Last-Modified'] = @document.updated_at.httpdate
response.headers['Cache-Control'] = 'private, max-age=300'
# Check both validators
if_none_match = request.headers['If-None-Match']
if_modified_since = request.headers['If-Modified-Since']
if if_none_match == %("#{etag}")
head :not_modified
return
end
if if_modified_since
client_time = Time.httpdate(if_modified_since)
if @document.updated_at <= client_time
head :not_modified
return
end
end
render json: @document
end
end
Common Patterns
Cache-Control directives provide precise control over caching behavior. The public directive allows shared caches like CDNs to store responses, while private restricts caching to the user's browser. The max-age directive specifies freshness lifetime in seconds. The must-revalidate directive forces validation when content becomes stale rather than allowing browsers to serve stale content during network issues.
# Cache-Control directive patterns
class CacheControlPatterns
# Pattern 1: Long-lived public assets
def static_asset_headers
{
'Cache-Control' => 'public, max-age=31536000, immutable'
}
end
# Pattern 2: Short-lived API responses
def api_response_headers
{
'Cache-Control' => 'public, max-age=300, must-revalidate'
}
end
# Pattern 3: Private user data
def private_data_headers
{
'Cache-Control' => 'private, max-age=600'
}
end
# Pattern 4: Always revalidate
def revalidation_required_headers
{
'Cache-Control' => 'no-cache'
}
end
# Pattern 5: Never cache
def no_cache_headers
{
'Cache-Control' => 'no-store, no-cache, must-revalidate',
'Pragma' => 'no-cache',
'Expires' => '0'
}
end
end
Asset fingerprinting solves cache invalidation for static resources. Appending a content hash to filenames creates unique URLs for each version. Browsers cache these aggressively since changed content receives a new URL. This pattern applies to CSS, JavaScript, images, and other static files.
# Asset fingerprinting implementation
class AssetFingerprinter
def initialize(asset_path)
@asset_path = asset_path
end
def fingerprinted_path
content = File.read(@asset_path)
digest = Digest::MD5.hexdigest(content)[0..7]
ext = File.extname(@asset_path)
base = File.basename(@asset_path, ext)
dir = File.dirname(@asset_path)
"#{dir}/#{base}-#{digest}#{ext}"
end
def self.generate_manifest(asset_dir)
manifest = {}
Dir.glob("#{asset_dir}/**/*").each do |path|
next if File.directory?(path)
fingerprinter = new(path)
original = path.sub(asset_dir, '')
fingerprinted = fingerprinter.fingerprinted_path.sub(asset_dir, '')
manifest[original] = fingerprinted
end
manifest
end
end
Stale-while-revalidate improves resilience by serving stale content while fetching fresh content in the background. This pattern reduces perceived latency and maintains availability during origin server slowness.
# Stale-while-revalidate pattern
class StaleWhileRevalidateCache
def initialize(app, store)
@app = app
@store = store
end
def call(env)
cache_key = generate_key(env)
cached = @store.read(cache_key)
if cached
age = Time.now - cached[:timestamp]
# Fresh content - serve immediately
if age < cached[:max_age]
return cached[:response]
end
# Stale but within revalidation window
if age < cached[:max_age] + cached[:stale_while_revalidate]
# Serve stale content immediately
Thread.new { revalidate(env, cache_key) }
return cached[:response]
end
end
# No cache or too stale - fetch fresh
response = @app.call(env)
store_response(cache_key, response, env)
response
end
private
def revalidate(env, cache_key)
response = @app.call(env)
store_response(cache_key, response, env)
end
def store_response(cache_key, response, env)
status, headers, body = response
if headers['Cache-Control'] =~ /max-age=(\d+)/
max_age = $1.to_i
swr = headers['Cache-Control'][/stale-while-revalidate=(\d+)/, 1].to_i
@store.write(cache_key, {
response: response,
timestamp: Time.now,
max_age: max_age,
stale_while_revalidate: swr
})
end
end
end
CDN integration patterns distribute cached content geographically. Setting appropriate Cache-Control headers ensures CDN edge servers cache content effectively. The s-maxage directive specifies cache lifetime for shared caches separately from browser caches.
# CDN cache configuration
class CDNCacheHeaders
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
path = env['PATH_INFO']
case path
when /\.(css|js|jpg|png|gif|svg|woff2?)$/
# Static assets: Long CDN cache, long browser cache
headers['Cache-Control'] = 'public, max-age=31536000, s-maxage=31536000, immutable'
when /^\/api\//
# API responses: Short CDN cache, validation required
headers['Cache-Control'] = 'public, max-age=60, s-maxage=300, must-revalidate'
when /^\/user\//
# User pages: CDN cache with validation, shorter browser cache
headers['Cache-Control'] = 'public, max-age=300, s-maxage=900, must-revalidate'
headers['Vary'] = 'Cookie'
else
# Default: Short cache with validation
headers['Cache-Control'] = 'public, max-age=60, must-revalidate'
end
[status, headers, body]
end
end
Performance Considerations
Bandwidth reduction represents the most direct performance benefit. Cached resources eliminate network transfer for repeat requests. A website with 2MB of assets on first visit might transfer only 10KB on subsequent visits if assets are cached. This reduction multiplies across millions of users, significantly decreasing bandwidth costs and improving load times.
Latency improvements occur when browsers serve resources from local cache rather than requesting them over the network. Network round trips typically take 50-200ms depending on distance and connection quality. Cache hits eliminate this latency entirely. Pages with dozens of resources benefit dramatically—a page loading 30 cached resources saves 1.5-6 seconds of network time.
# Measuring cache performance
class CacheMetrics
def initialize(app)
@app = app
@hits = 0
@misses = 0
end
def call(env)
start_time = Time.now
status, headers, body = @app.call(env)
duration = Time.now - start_time
# Log cache hit or miss
if status == 304
@hits += 1
log_metric('cache.hit', duration)
else
@misses += 1
log_metric('cache.miss', duration)
end
# Add cache metrics to headers for monitoring
headers['X-Cache-Hits'] = @hits.to_s
headers['X-Cache-Misses'] = @misses.to_s
headers['X-Cache-Ratio'] = (@hits.to_f / (@hits + @misses)).round(3).to_s
[status, headers, body]
end
private
def log_metric(name, duration)
Rails.logger.info("#{name}: #{duration}s")
end
end
Server load reduction follows from fewer requests reaching application servers. A well-cached application might serve 80-90% of requests from browser or CDN caches. This reduction allows fewer servers to handle the same user base, lowering infrastructure costs. CPU and memory usage decrease proportionally to the reduction in dynamic request processing.
Cache hit ratio measures caching effectiveness as the percentage of requests served from cache. A 90% hit ratio means only 10% of requests reach origin servers. Optimizing hit ratio involves balancing cache lifetime against content freshness requirements. Longer cache lifetimes increase hit ratios but risk serving stale content.
# Cache hit ratio optimization
class CacheOptimizer
def initialize(store)
@store = store
@metrics = Hash.new { |h, k| h[k] = { hits: 0, misses: 0 } }
end
def record_hit(resource_type)
@metrics[resource_type][:hits] += 1
end
def record_miss(resource_type)
@metrics[resource_type][:misses] += 1
end
def hit_ratio(resource_type)
metrics = @metrics[resource_type]
total = metrics[:hits] + metrics[:misses]
return 0 if total.zero?
(metrics[:hits].to_f / total * 100).round(2)
end
def report
@metrics.map do |type, data|
{
type: type,
hits: data[:hits],
misses: data[:misses],
ratio: hit_ratio(type),
recommendation: recommend_ttl(type, hit_ratio(type))
}
end
end
private
def recommend_ttl(type, ratio)
case ratio
when 0..50
"Increase cache lifetime - hit ratio too low"
when 50..75
"Consider increasing cache lifetime"
when 75..90
"Cache lifetime acceptable"
else
"Cache lifetime optimal"
end
end
end
Compression interacts with caching to amplify performance gains. Gzipped responses reduce bandwidth significantly, and caching compressed responses avoids repeated compression overhead. The Vary: Accept-Encoding header ensures browsers receive appropriately compressed content.
Network condition adaptation affects cache strategy. Mobile networks have higher latency and lower bandwidth than fixed connections. Aggressive caching benefits mobile users disproportionately. Service Workers enable sophisticated offline-first strategies that pre-cache critical resources and serve them during poor connectivity.
# Adaptive caching based on network conditions
class AdaptiveCacheControl
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
# Detect connection type from headers
connection_type = env['HTTP_DOWNLINK'] || env['HTTP_ECT']
save_data = env['HTTP_SAVE_DATA'] == 'on'
path = env['PATH_INFO']
if save_data || slow_connection?(connection_type)
# Aggressive caching for slow connections
if path =~ /\.(css|js)$/
headers['Cache-Control'] = 'public, max-age=86400, immutable'
elsif path =~ /\.(jpg|png)$/
headers['Cache-Control'] = 'public, max-age=604800, immutable'
end
else
# Standard caching for fast connections
if path =~ /\.(css|js)$/
headers['Cache-Control'] = 'public, max-age=3600'
end
end
[status, headers, body]
end
private
def slow_connection?(type)
['slow-2g', '2g', 'slow-3g'].include?(type)
end
end
Security Implications
Sensitive data caching creates privacy and security risks. Personal information, authentication tokens, or confidential content must not be cached in shared caches. The private directive restricts caching to the user's browser, preventing CDN or proxy storage. The no-store directive prevents all caching, appropriate for highly sensitive content.
# Secure caching for sensitive data
class SecureCacheControl
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
path = env['PATH_INFO']
case path
when /^\/api\/user\/profile/
# Personal data - private cache only
headers['Cache-Control'] = 'private, max-age=300'
headers['Vary'] = 'Authorization'
when /^\/api\/admin/
# Admin data - no caching
headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
headers['Pragma'] = 'no-cache'
when /^\/api\/auth/
# Authentication endpoints - never cache
headers['Cache-Control'] = 'no-store'
when /^\/api\/payment/
# Payment data - no caching
headers['Cache-Control'] = 'no-store, no-cache'
headers['Clear-Site-Data'] = '"cache"'
end
[status, headers, body]
end
end
Cache poisoning attacks inject malicious content into shared caches. If an attacker can control cache keys or response content, they might store malicious responses that serve to other users. Defense requires validating cache key inputs, sanitizing response content, and using secure cache key generation algorithms.
HTTPS and caching interaction requires careful consideration. Browsers often refuse to cache HTTPS responses without explicit caching headers. The Cache-Control header must be present even when defaults would cache HTTP responses. Mixed content warnings occur when HTTPS pages include cached HTTP resources.
# HTTPS-aware caching
class HTTPSCacheMiddleware
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
if env['HTTPS'] == 'on' || env['HTTP_X_FORWARDED_PROTO'] == 'https'
# HTTPS requires explicit cache headers
if headers['Cache-Control'].nil?
headers['Cache-Control'] = 'private, max-age=0, must-revalidate'
end
# Prevent mixed content
headers['Content-Security-Policy'] ||= "upgrade-insecure-requests"
# HSTS header prevents HTTPS downgrade
headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
end
[status, headers, body]
end
end
Authentication and caching present challenges. Authenticated API responses contain user-specific data but might benefit from private caching. The Vary: Authorization header ensures different authentication credentials produce separate cache entries. Session cookies require similar treatment through Vary: Cookie.
# Authentication-aware caching
class AuthCacheMiddleware
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
auth_header = env['HTTP_AUTHORIZATION']
cookie_header = env['HTTP_COOKIE']
if auth_header || cookie_header
# Authenticated request - private cache with validation
headers['Cache-Control'] = 'private, max-age=60, must-revalidate'
# Vary on authentication to prevent cross-user caching
vary_headers = []
vary_headers << 'Authorization' if auth_header
vary_headers << 'Cookie' if cookie_header
headers['Vary'] = vary_headers.join(', ')
# Add cache tags for invalidation
if user_id = extract_user_id(auth_header, cookie_header)
headers['Cache-Tag'] = "user:#{user_id}"
end
end
[status, headers, body]
end
private
def extract_user_id(auth_header, cookie_header)
# Extract user ID from authentication
# Implementation depends on auth mechanism
end
end
Common Pitfalls
Over-caching dynamic content causes users to see outdated information. Setting long cache lifetimes for frequently updated content creates staleness problems. A news site caching articles for hours shows readers old headlines. Finding the right balance between performance and freshness requires understanding content update frequency and user expectations.
# Dynamic content cache management
class DynamicContentCache
CACHE_RULES = {
'/articles/latest' => { max_age: 60, must_revalidate: true },
'/articles/popular' => { max_age: 300, stale_while_revalidate: 60 },
'/articles/archive' => { max_age: 3600, immutable: false },
'/api/realtime' => { no_cache: true }
}
def cache_headers(path)
rule = CACHE_RULES[path] || default_rule
directives = []
directives << 'public'
if rule[:no_cache]
return 'no-store, no-cache, must-revalidate'
end
directives << "max-age=#{rule[:max_age]}" if rule[:max_age]
directives << 'must-revalidate' if rule[:must_revalidate]
directives << "stale-while-revalidate=#{rule[:stale_while_revalidate]}" if rule[:stale_while_revalidate]
directives << 'immutable' if rule[:immutable]
directives.join(', ')
end
def default_rule
{ max_age: 300, must_revalidate: true }
end
end
Cache invalidation complexity increases with distributed caches. When content changes, all cached copies must be invalidated or revalidated. CDN edge servers, browser caches, and intermediate proxies might all hold stale versions. Surrogate-Control headers and cache purge APIs address distributed invalidation, but coordination remains challenging.
Inconsistent cache headers across different resources cause unpredictable behavior. An HTML page cached for 1 hour that references a CSS file cached for 1 day creates version mismatches. Users might see old styles with new markup. Coordinating cache lifetimes across dependent resources requires careful planning.
# Coordinated cache management for dependent resources
class DependentResourceCache
def initialize(app)
@app = app
@version = load_asset_version
end
def call(env)
status, headers, body = @app.call(env)
path = env['PATH_INFO']
# HTML pages reference versioned assets
if path =~ /\.html$/ || path == '/'
headers['Cache-Control'] = 'public, max-age=300, must-revalidate'
headers['X-Asset-Version'] = @version
# Assets include version in cache headers
elsif path =~ /\.(css|js)$/
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
headers['X-Asset-Version'] = @version
# Verify version matches
if env['HTTP_X_ASSET_VERSION'] && env['HTTP_X_ASSET_VERSION'] != @version
# Version mismatch - force refresh
headers['Cache-Control'] = 'no-cache'
end
end
[status, headers, body]
end
private
def load_asset_version
File.read('ASSET_VERSION').strip
rescue
Time.now.to_i.to_s
end
end
Mobile and desktop cache behavior differs. Mobile browsers often have smaller cache sizes and more aggressive cache eviction. Service Workers enable sophisticated mobile caching strategies but require additional code. Testing cache behavior across devices reveals inconsistencies that desktop-only testing misses.
Debugging cache issues proves difficult. Browsers cache responses silently, making it unclear whether content comes from cache or server. Response headers might be cached incorrectly. Browser developer tools show cache status, but reproducing issues requires understanding cache state. Hard refresh bypasses cache, masking problems that regular users encounter.
# Cache debugging helper
class CacheDebugger
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
# Add debug headers in development
if ENV['RACK_ENV'] == 'development'
headers['X-Cache-Debug'] = cache_debug_info(env, headers)
end
# Log cache information
log_cache_request(env, status, headers)
[status, headers, body]
end
private
def cache_debug_info(env, headers)
info = []
info << "path:#{env['PATH_INFO']}"
info << "control:#{headers['Cache-Control']}"
info << "etag:#{headers['ETag']}"
info << "modified:#{headers['Last-Modified']}"
info << "if-none-match:#{env['HTTP_IF_NONE_MATCH']}"
info << "if-modified-since:#{env['HTTP_IF_MODIFIED_SINCE']}"
info.join(' | ')
end
def log_cache_request(env, status, headers)
Rails.logger.info({
path: env['PATH_INFO'],
method: env['REQUEST_METHOD'],
status: status,
cache_control: headers['Cache-Control'],
etag_sent: headers['ETag'],
etag_received: env['HTTP_IF_NONE_MATCH'],
modified_sent: headers['Last-Modified'],
modified_received: env['HTTP_IF_MODIFIED_SINCE']
}.to_json)
end
end
Reference
Cache-Control Directives
| Directive | Scope | Description |
|---|---|---|
| public | Response | Allows caching in shared caches |
| private | Response | Restricts caching to browser only |
| no-cache | Request/Response | Must revalidate before using cached response |
| no-store | Request/Response | Must not store response in any cache |
| max-age | Response | Seconds until response becomes stale |
| s-maxage | Response | Max age for shared caches, overrides max-age |
| must-revalidate | Response | Must revalidate once stale, no stale serving |
| proxy-revalidate | Response | Like must-revalidate but only for shared caches |
| no-transform | Response | Intermediaries must not transform content |
| immutable | Response | Indicates content will never change |
| stale-while-revalidate | Response | Seconds to serve stale while revalidating |
| stale-if-error | Response | Seconds to serve stale if origin errors |
HTTP Headers Reference
| Header | Direction | Purpose |
|---|---|---|
| Cache-Control | Response | Primary cache control mechanism |
| Expires | Response | HTTP/1.0 expiration date, superseded by max-age |
| ETag | Response | Unique identifier for resource version |
| Last-Modified | Response | Timestamp of last modification |
| If-None-Match | Request | ETag value for conditional request |
| If-Modified-Since | Request | Timestamp for conditional request |
| Vary | Response | Headers to include in cache key |
| Age | Response | Seconds response has been in cache |
| Pragma | Request/Response | HTTP/1.0 cache control, use Cache-Control instead |
Status Codes
| Code | Name | Description |
|---|---|---|
| 200 | OK | Full response with content |
| 304 | Not Modified | Cached version still valid |
| 412 | Precondition Failed | Conditional request precondition not met |
Rails Cache Methods
| Method | Purpose | Example |
|---|---|---|
| expires_in | Set cache duration | expires_in 1.hour, public: true |
| expires_now | Disable caching | expires_now |
| fresh_when | Conditional GET with ETag | fresh_when(@article) |
| stale? | Check if cached version stale | if stale?(@article) |
| http_cache_forever | Permanent caching | http_cache_forever(public: true) |
Rack::Cache Configuration
| Option | Type | Purpose |
|---|---|---|
| metastore | String | Location for cache metadata |
| entitystore | String | Location for cached responses |
| verbose | Boolean | Enable detailed logging |
| allow_reload | Boolean | Honor Cache-Control: max-age=0 |
| allow_revalidate | Boolean | Honor Cache-Control: no-cache |
Asset Cache Configuration
| Setting | Rails Config | Purpose |
|---|---|---|
| Digest | config.assets.digest | Enable fingerprinting |
| Static Cache Control | config.static_cache_control | Headers for static files |
| Asset Host | config.asset_host | CDN URL for assets |
| Compile | config.assets.compile | Runtime compilation |
Common Cache Lifetime Values
| Content Type | Recommended Duration | Cache-Control Example |
|---|---|---|
| Static assets with fingerprinting | 1 year | public, max-age=31536000, immutable |
| Static assets without fingerprinting | 1 day | public, max-age=86400 |
| API responses | 5 minutes | public, max-age=300, must-revalidate |
| HTML pages | 5 minutes | public, max-age=300 |
| User-specific data | 2 minutes | private, max-age=120 |
| Real-time data | No cache | no-store, no-cache |
| Authentication responses | No cache | no-store |