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 June 19, 2023
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.
Source for this article’s reference application is available on GitHub and can be seen running at https://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:
- The
Age
header conveys the estimated age of the resource from the cache. Cache-Control
indicates that this asset ispublic
(can be stored on intermediary proxies) and has amax-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
Do not use Rack::Cache, instead use a CDN
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.
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 header – expires_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:
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
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 in 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).
However, subsequents requests skip view rendering and return a 304 Not modified
avoiding the most expensive part of the request.
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
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.