HTTP Caching for Rails APIs: The Missing Performance Layer
Every Rails developer knows the caching dance. We’ve all implemented fragment caching, played with Rails.cache
, and maybe even ventured into Russian doll caching. But here’s what I’ve seen being completely ignored: HTTP caching.
Here’s the thing - HTTP caching can eliminate up to 90% of your API requests without you writing a single line of caching logic. It’s built into every HTTP client worth using, requires zero infrastructure, and costs nothing to implement.
Yet most Rails APIs serve every request fresh, ignoring decades of HTTP specification designed specifically to solve this problem.
The Hidden Cost of Ignoring HTTP Caching
Let’s start with a typical Rails API endpoint:
1
2
3
4
5
6
class Api::ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
render json: @product
end
end
Every request to this endpoint:
- Hits your Rails server
- Queries your database
- Serializes the response
- Consumes server resources
Even if the product hasn’t changed in months.
Now imagine this endpoint serves a mobile app with 100,000 daily active users, each checking product details multiple times per day. That’s millions of unnecessary requests, database queries, and server cycles.
Rails’ CSRF Problem (And Why APIs Don’t Have It)
Before diving into solutions, let’s address why HTTP caching is rarely discussed in Rails circles. Rails applications typically embed CSRF tokens in every HTML response:
1
<meta name="csrf-token" content="Xc0vf6L7hgb..." />
This token changes on every request, making HTML responses effectively uncacheable. But APIs don’t have this problem - they typically use token-based authentication without CSRF protection.
This makes APIs the perfect candidate for aggressive HTTP caching strategies.
Hello Cache-Control
The Cache-Control
header is where the magic happens. It tells clients and CDNs exactly how to cache your responses. Here’s what a properly cached API response looks like:
1
2
3
4
5
6
7
8
class Api::ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
response.headers['Cache-Control'] = 'public, max-age=3600'
render json: @product
end
end
This simple header tells clients to cache the response for one hour (3600 seconds). During that hour, the client won’t make another request - it’ll serve the cached version instead.
But we can do better.
Conditional Requests: The Smart Way to Cache
What happens after that hour expires? The client makes a new request and we start over? Not quite. This is where conditional requests shine.
Using Last-Modified
1
2
3
4
5
6
7
8
9
class Api::ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
if stale?(last_modified: @product.updated_at)
render json: @product
end
end
end
The stale?
method is Rails magic that checks if the resource has been modified since the client last fetched it. It automatically sets the appropriate headers and returns false
if the content is fresh (triggering a 304 response).
Here’s what happens:
- First request: Client receives the product with a
Last-Modified
header - Subsequent requests: Client sends
If-Modified-Since
header - If product hasn’t changed: Rails returns
304 Not Modified
(no body) - If product has changed: Rails returns the full response
The beauty? When nothing changes, you save:
- JSON serialization time
- Response body bandwidth
- Client parsing time
Using ETags for More Complex Scenarios
Sometimes updated_at
isn’t enough. Maybe your response includes associated data or computed fields:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Api::ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
# ETag based on product and its associations
etag = [
@product,
@product.reviews.maximum(:updated_at),
@product.current_price
]
if stale?(etag: etag)
render json: {
product: @product,
review_count: @product.reviews.count,
average_rating: @product.reviews.average(:rating),
current_price: @product.current_price
}
end
end
end
Rails automatically generates an ETag from the array, creating a unique fingerprint for this exact response state.
Advanced Patterns for Real-World APIs
Pattern 1: Efficient Collection Caching
Caching collections requires thinking about what actually changes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Api::ProductsController < ApplicationController
def index
@products = Product.active.includes(:category)
# Use the most recent update as the collection's last modified time
last_modified = @products.maximum(:updated_at)
# Include collection "fingerprint" in ETag
etag_components = [
last_modified,
@products.count,
params[:page],
params[:per_page]
]
if stale?(last_modified: last_modified, etag: etag_components)
render json: @products
end
end
end
Pattern 2: User-Specific Caching
Private data needs private caching:
1
2
3
4
5
6
7
8
9
10
11
12
class Api::OrdersController < ApplicationController
def index
@orders = current_user.orders.recent
# Private ensures CDNs don't cache user-specific data
response.headers['Cache-Control'] = 'private, max-age=300'
if stale?(last_modified: @orders.maximum(:updated_at))
render json: @orders
end
end
end
Pattern 3: Preventing Unnecessary Queries
The real power comes from avoiding database queries entirely:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Api::TimelineController < ApplicationController
def show
# Only check if we need to regenerate
latest_update = current_user.posts.maximum(:updated_at)
if stale?(last_modified: latest_update)
# Only now do we load the actual data
@posts = current_user.posts
.includes(:comments, :likes)
.order(created_at: :desc)
.limit(50)
render json: @posts
end
end
end
The maximum(:updated_at)
query is lightning fast compared to loading full records.
Cache-Control Directives That Actually Matter
While the HTTP spec defines many cache directives, here are the ones that actually matter for Rails APIs:
max-age=seconds
- How long to cache before checking again
1
'public, max-age=3600' # Cache for 1 hour
private
vs public
- Who can cache this
1
2
'private, max-age=300' # Only browser can cache (user data)
'public, max-age=3600' # CDNs can cache too (public data)
no-store
- Never cache this
1
'no-store' # For sensitive data like payment info
must-revalidate
- Always check when stale
1
'public, max-age=3600, must-revalidate' # Don't serve stale content
Real-World Implementation Strategy
Step 1: Identify Cacheable Endpoints
Start with read-heavy, public endpoints:
- Product catalogs
- Blog posts / articles
- Category listings
- Static configuration
Step 2: Add Conditional Caching
1
2
3
4
5
6
7
8
9
10
class ApplicationController < ActionController::API
# Helper for consistent caching
def cache_publicly(max_age: 1.hour)
response.headers['Cache-Control'] = "public, max-age=#{max_age}"
end
def cache_privately(max_age: 5.minutes)
response.headers['Cache-Control'] = "private, max-age=#{max_age}"
end
end
Step 3: Monitor and Iterate
Track your cache hit rates:
1
2
3
4
5
6
7
8
9
10
11
12
class ApplicationController < ActionController::API
after_action :log_cache_status
private
def log_cache_status
if response.status == 304
Rails.logger.info "[CACHE HIT] #{request.path}"
# Increment your metrics here
end
end
end
The Gotchas
Gotcha 1: Middleware Order Matters
Rails middleware can modify responses after your controller runs. Make sure caching headers aren’t being overwritten:
1
2
# config/application.rb
config.middleware.insert_before Rack::ETag, YourFancyMiddleware
Gotcha 2: Serializer Caching
If you’re using ActiveModel::Serializers
or similar, ensure they respect caching:
1
2
3
4
5
6
7
8
9
10
11
class ProductSerializer < ActiveModel::Serializer
cache key: 'product', expires_in: 1.hour
attributes :id, :name, :price
# This computed attribute could break caching
attribute :current_discount do
# Make sure this is deterministic!
object.calculate_discount
end
end
Gotcha 3: Time Zones and Timestamps
Always use UTC for Last-Modified headers:
1
2
3
if stale?(last_modified: @product.updated_at.utc)
render json: @product
end
Measuring Success
How do you know if your HTTP caching strategy is working? Look for:
- Reduced average response times - 304 responses are typically 10x faster
- Lower database load - Fewer queries hitting your database
- Improved mobile app performance - Users see instant responses for cached data
- Reduced bandwidth costs - 304 responses have no body
A well-cached API can handle 10x the traffic with the same infrastructure.
Your Next Steps
HTTP caching isn’t a silver bullet, but it’s the closest thing we have in API performance. Start small:
- Pick your most-requested endpoint
- Add simple Last-Modified caching
- Measure the impact
- Iterate from there
Remember: the fastest API request is the one that never hits your server. HTTP caching makes that possible without complex infrastructure or code changes.
The best part? Your mobile developers will love you for it. Their apps will feel instantly responsive, work better offline, and consume less battery and data.
That’s a win for everyone.
References