Vincent Spehner

This article was contributed by Vincent Spehner

Vincent Spehner is a technology addict working for Tquila interested in software architecture and development patterns. He is currently writing a book explaining best practices for the integration of Heroku and Salesforce apps.

HTTP Caching in Ruby with Rails

Last Updated: 24 March 2014

http headers performance rails ruby

Table of Contents

Out of the box, Rails 3 provides a simple HTTP caching configuration for static pages and assets. While your application benefits from this setup, specifying appropriate cache headers for all requests, even dynamic ones, can give you an order of magnitude improvement in response times, user experience and resources required to power your application.

This article walks through several use cases where utilizing HTTP cache headers in a Rails 3 application can improve response times with minimal modification.

If you have questions about Ruby on Heroku, consider discussing it in the Ruby on Heroku forums.

Source for this article's reference application is available on GitHub and can be seen running at http://http-caching-rails.herokuapp.com

Default HTTP caching in Rails 3

The default configuration of a Rails 3 app includes the use of HTTP cache headers in the most basic of scenarios. Additionally, it comes configured with the asset pipeline for more efficient delivery of static assets and the Rack::Cache and Rack::ETag middleware which together serve as an un-intrusive caching mechanism.

Asset pipeline

Rails 3.1+ introduced the concept of the Asset Pipeline. Along with the concatenation and compression of JS and CSS assets, Rails added HTTP cache headers to prevent re-fetching identical assets across requests. Requests for assets come with multiple headers defining how that asset should be stored locally:

Asset pipeline response

  • The Age header conveys the estimated age of the resource from the cache.
  • Cache-Control indicates that this asset is public (can be stored on intermediary proxies) and has a max-age value of 31,536,000 seconds (365 days)
  • Etag, computed by rack middleware, based on the response body digest.
  • Last-Modified indicating the most recent modification date, based on information in the file

For most applications these default values will suffice and no modifications to the asset pipeline are necessary.

Rack::Cache

Reverse-proxy caches, such as Rack::Cache, stand between web clients (browsers) and your application and transparently cache publicly cacheable resources.

Rails 3 introduced Rack::Cache as a native proxy-cache. In production mode, all your pages with public cache headers will be stored in Rack::Cache acting as a middle-man proxy. As a result, your Rails stack will be bypassed on these requests for cached resources.

By default Rack::Cache will use in-memory storage. In highly distributed environments such as Heroku a shared cache resource should be used. On Heroku use Rack::Cache with a Memcached add-on for highly-performant HTTP-header based resource caching.

Rack::ETag

A side-effect of the Cache-Control: private header is that these resources will not be stored in reverse-proxy caches (even Rack::Cache).

Rack::ETag provides support for conditional requests by automatically assigning an ETag header and Cache-Control: private on all responses.

Dynamic page headers

It can do this without knowing the specifics of your application by hashing the fully-formed response string after the view has been rendered.

While this approach is transparent to the application it still requires the application to fully process the request to hash the response body. The only savings are the cost of sending the full response over the network back to the end client since an empty response with the 304 Not Modified response status is sent instead.

Setting the cache headers for maximum performance remains your responsibility as an application developer.

Time-based cache headers

Rails provides two controller methods to specify time-based caching of resources via the Expires HTTP headerexpires_in and expires_now.

expires_in

The Cache-Control header’s max-age value is configured using the expires_in controller method (used in the show action of the sample app).

def show
  @company = Company.find(params[:id])
  expires_in 3.minutes, :public => true
  # ...
end

When a request is made for a company resource the Cache-Control header will be set appropriately:

Expires_in logs

A max-age value prevents the resource from being requested by the client for the specified interval. This serves as a course-grained approach to caching and is useful for content that changes infrequently and, when it does, doesn’t require immediate propagation.

When used in conjunction with Rack::Cache requests for these resources hit the controller only once in the specified interval.

Started GET "/companies/2" for 127.0.0.1 at 2012-09-26 14:07:28 +0100
Processing by CompaniesController#show as HTML
  Parameters: {"id"=>"2"}
  Rendered companies/show.html.erb within layouts/application (9.0ms)
Completed 200 OK in 141ms (Views: 63.8ms | ActiveRecord: 14.4ms)

Started GET "/companies/2" for 127.0.0.1 at 2012-09-26 14:11:10 +0100
Processing by CompaniesController#show as HTML
  Parameters: {"id"=>"2"}
Completed 304 Not Modified in 2ms (ActiveRecord: 0.3ms)

Notice that the first request goes through full execution to view rendering whereas the second request immediately returns 304 Not Modified.

expires_now

You can force expiration of a resource using the expires_now controller method. This will set the Cache-Control header to no-cache and prevent caching by the browser or any intermediate caches.

def show
  @person = Person.find(params[:id])

  # Set Cache-Control header no-cache for this one person
  # (just as an example)
  expires_now if params[:id] == '1'
end

The Cache-Control header is zeroed out, forcing resource expiration:

expires_now_logs

expires_now will only be executed on requests that invoke the controller action. Resources with headers previously set via expires_in will not immediately request for an updated resource until the expiration period has passed. Keep this mind when developing/debugging.

Conditional cache headers

Conditional GET requests require the browser to initiate a request but allow the server to respond with a cached response or bypass processing all together based on shared meta-data (the ETag hash or Last-Modified timestamp).

In Rails, specify the appropriate conditional behavior using the stale? and fresh_when methods.

stale?

The stale? controller method sets the appropriate ETag and Last-Modified-Since headers and determines if the current request is stale (needs to be fully processed) or is fresh (the web client can use its cached content).

For public requests specify :public => true for added reverse-proxy caching.

def show
  @company = Company.find(params[:id])
  # ...
  if stale?(etag: @company, last_modified: @company.updated_at)
    respond_to do |format|
      format.html # show.html.erb
      format.json { render json: @company }
    end
  end
end

Nesting respond_to within the stale? block ensures that view rendering, often the most-expensive part of any request, only executes when necessary.

The pattern of invoking stale? with an ActiveRecord domain object and using its updated_at timestamp as the last modified time is common. Rails supports this by allowing the object itself as the sole argument. This example could be implemented as: stale?(@company).

if stale?(@company)
  respond_to do |format|
    # ...
  end
end

With this configuration the first request to Companies#show invokes the full request stack (no performance gain).

header-stale

However, subsequents requests skip view rendering and return a 304 Not modified avoiding the most expensive part of the request.

header-not-stale

The 304 response status is not only faster from a browser loading perspective, but also more efficient server-side as full request processing can be bypassed once it’s known that the core objects backing the response aren’t stale.

fresh_when

While the stale? method returned a boolean value, letting you execute different paths depending on the freshness of the request, fresh_when just sets the ETag and Last-Modified-Since response headers and, if the request is fresh, also sets the 304 Not Modified response status. For controller actions that don’t require custom execution handling, i.e. those with default implementations, fresh_when should be used.

def index
  @people = Person.scoped
  fresh_when last_modified: @people.maximum(:updated_at), public: true
end

Lazy loading of resources

The HTTP header caching approaches described here allow you to bypass the view rendering portion of the request handling. As such, it is beneficial to defer as much processing as possible to the view. In normal execution a controller action call to Person.all will fetch and load all Person records from the database (as well as all child objects depending on the model associations).

Started GET "/people" for 127.0.0.1 at 2012-09-26 15:08:15 +0100
Processing by PeopleController#index as HTML
  Person Load (0.2ms)  SELECT "people".* FROM "people"
  Company Load (0.4ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = 1 LIMIT 1
  Company Load (0.4ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = 2 LIMIT 1
  Rendered people/index.html.erb within layouts/application (2023.8ms)
Completed 200 OK in 2030ms (Views: 2023.7ms | ActiveRecord: 5.2ms)

However, using an ActiveRelation scope in the controller defers loading objects from the database until required by the view.

def index
  @people = Person.scoped
  fresh_when last_modified: @people.maximum(:updated_at)
end

When view processing is avoided with HTTP caching fewer database calls are required, resulting in significant additional gains.

Started GET "/people" for 127.0.0.1 at 2012-09-26 15:09:43 +0100
Processing by PeopleController#index as HTML
 (0.4ms)  SELECT MAX("people"."updated_at") AS max_id FROM "people"
Completed 304 Not Modified in 1ms (ActiveRecord: 0.4ms)

If the controller action isn't already using a named scope or one of the ActiveRecord query methods use the anonymous scope method scoped to create a scope equivalent to the all finder method.

Public requests

Public responses don’t contain sensitive data and can be stored by intermediate proxy caches. Use public: true in caching methods to identify public resources.

def show
  @company = Company.find(params[:id])
  expires_in(3.minutes, public: true)
  if stale?(@company, public: true)
    # …
  end
end

Private content

By default, Cache-Control is set to private for all requests. However, some cache settings can overwrite the default behavior making it advisable to explicitly specify private resources.

expires_in(1000.seconds, public: false)

Non-cacheable content

The global approach to avoid content being cached is to use a before_filter. You can either define this in your controller inheritance tree, or controller by controller with an explicit private setting:

before_filter :set_as_private

def set_as_private
  expires_now
end

Expires now

By default, Rails provides a base level of HTTP caching for static assets. However, for a truly optimized experience HTTP caching headers should be explicitly defined across your application using one of Rails’ many request caching facilities.