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:

  1. First request: Client receives the product with a Last-Modified header
  2. Subsequent requests: Client sends If-Modified-Since header
  3. If product hasn’t changed: Rails returns 304 Not Modified (no body)
  4. 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:

  1. Reduced average response times - 304 responses are typically 10x faster
  2. Lower database load - Fewer queries hitting your database
  3. Improved mobile app performance - Users see instant responses for cached data
  4. 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:

  1. Pick your most-requested endpoint
  2. Add simple Last-Modified caching
  3. Measure the impact
  4. 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

Prateek Choudhary
Prateek Choudhary
Technology Leader