Client-side caching in Rails: conditional GET requests

Jeff Kreeftmeijer

Jeff Kreeftmeijer on

Client-side caching in Rails:
conditional GET requests

Besides Russian doll caching, there are more techniques to speed up performance in Rails apps. This time we'll look at Rails' built-in conditional GET support, which allows you to store rendered pages in the user's browser cache.

👋 And if you like to read more about performance outside of caching, there is a lot more we wrote about Ruby (on Rails) performance, check out our Ruby performance monitoring checklist.

The Etag and Last-Modified headers

When your browser executes an HTTP GET request for a page in your Rails app, the router will link it to one of your controller actions. The controller will then request the required data from the database and render the view. An HTTP response (with 200 OK as the response code) is then sent back to the browser with the rendered HTML from the view in the response's body for your browser to parse and display.

When the resource is requested again, we'll go through the same pipeline. In some situations, this is unnecessary because the page didn't change in the meantime. For that, HTTP offers its ETag and Last-Modified headers. Using these, the browser can store the response body, and use the headers to invalidate them when they become stale.

Etags, or Entity tags, are used for client-side cache validation, so you can think of them as cache keys for your HTTP responses. They're passed back to the browser in the HTTP response header for every request.

~ $ curl -I http://localhost:3000/products/1 HTTP/1.1 200 OK ... ETag: W/"9462d76cc55aeb6249fa990e39231c7c" Last-Modified: Wed, 25 Apr 2018 08:27:04 GMT ...

If the response is repeated later, the browser finds the existing response in its cache and uses the stored Etag from the last request as the If-None-Match header. This header will tell our Rails app that we already have this version in the cache.

If the Etag from the request matches the current one, Rails will send a 304 Not Modified response without a response body. This will tell the browser to use the one from its local cache instead.

~ $ curl -i -H 'If-None-Match: W/"9462d76cc55aeb6249fa990e39231c7c"' http://localhost:3000/products/1 HTTP/1.1 304 Not Modified ... ETag: W/"9462d76cc55aeb6249fa990e39231c7c" Last-Modified: Wed, 25 Apr 2018 08:27:04 GMT ...

Conditional GET requests in Rails

If we request a page from a local Rails application, we can see that Rails adds an Etag for each request automatically. If we request the same page a couple of times in a row, we can see the Etag changes for each request.

While Rails generates an Etag for each request by default, it uses a digest of the whole response body to generate it. This means the <%= csrf_meta_tags %> in the layout throws the Etag off, as the csrf-token meta tag changes for each request. Because that changes the body for each request, the Etag is invalidated and the local cache is marked stale.

Besides that, Rails will never return a 304 Not Modified by default, because the local cache is never explicitly marked as fresh in our controller.

fresh_when and stale?

To use Etags from the request header for conditional GETs, we need to explicitly mark an object in the local cache as "fresh". For example, for a page that shows a product, we can keep the cache fresh as long as the product and the view templates don't change. To make that work, we'll do two things.

  1. We'll explicitly set values that will make up our Etag, as using the whole response body would require us to render the whole body to check if the cached response is valid, which negates the speedup from caching the page locally.
  2. We'll compare the Etag from the request header to one we predict before rendering the view, and we'll omit the rendering if they match.

Rails comes with helpers that does everything for us. We can explicitly base the Etag and Last-Modified date on the product by using fresh_when.

# app/views/products/show.html.erb def show @product = Product.find(params[:id]) fresh_when @product end

If you have an explicit respond_to block, use stale? instead of fresh_when.

# app/views/products/show.html.erb def show @product = Product.find(params[:id]) if stale?(@product) respond_to do |format| format.html end end end

Now, requesting one of the product pages will cache the response locally. Any subsequent requests to the same page will include the Etag to tell Rails we have a cached response, which is then compared to the new Etag. If that matches, Rails will skip rendering the page, and return a 304 Not Modified immediately.

A view with conditional GET requests

Note: Refreshing the page will always request an uncached version of the page. To test if your conditional GETs work, navigate away using a link or using the back button instead.

How did you like this article and previous articles in the AppSignal Academy series? We have some more articles about caching in Rails lined, up, but please don’t hesitate to let us know what you’d like us to write about (caching-related or otherwise) next!

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps