A general way to describe caching is storing some data so that we can quickly retrieve it later. Sometimes, this means storing computed data so that it does not need to be re-computed, but it can also refer to storing data locally to avoid having to fetch it again. Your computer does this constantly, as your operating system tries to keep frequently accessed data in RAM so that it doesn’t have to be fetched again from a hard drive or SSD.
For the browser to cache this data, there needs to be some coordination with the server. The browser needs to know what it can cache and for how long; otherwise, it could be showing you old content when the server has a newer version available. In this article, we’ll look at how this client-server caching coordination is carried out and what Rails provides to alter it.
Although the focus is on how Ruby on Rails handles this, the actual mechanism is part of HTTP specifications. In other words, the caching we’re talking about here is baked into the infrastructure of the internet, which makes it a cornerstone of how modern websites and frameworks are developed. Various frameworks, such as Rails, single-page applications (SPAs), and even static sites, can all use these mechanisms to help improve performance.
You’re probably familiar with the request-response lifecycle, at least at a high level: you click a link on a website, your browser sends a request to the server for that content, and the server sends back that content (Note that I’m intentionally glossing over a lot of complexity here).
Let’s dig a little bit into the actual data being sent in this back-and-forth transaction. Each HTTP message has a header and a body (not to be confused with
<body> HTML tags). The request header tells the server which path you are trying to access and which HTTP method to use (e.g., GET/PUT/PATCH/POST). If needed, you can dig into these headers using either your browser’s developer tools or a command-line tool, such as
# curl -v honeybadger.io ... > GET / HTTP/1.1 > Host: honeybadger.io > User-Agent: curl/7.64.1 > Accept: */*
This first portion of the output is the request header. We’re issuing a
honeybadger.io. This is then followed by what the server sent back (the “response header”):
> < HTTP/1.1 301 Moved Permanently < Cache-Control: public, max-age=0, must-revalidate < Content-Length: 39 < Content-Security-Policy: frame-ancestors 'none' ... < Content-Type: text/plain
The response includes the HTTP code (e.g.,
200 for success or
404 for not found). In this example, it is a permanent redirect (301) because curl is trying to contact the
http URL, which redirects to the secure
The response header also includes the content type, which is
text/plain here, but a few other common options are
The response body follows the header. In our case, the body is blank because a
301 redirect does not need a body. If we tried again with
curl -v https://www.honeybadger.io, you’d see the homepage content here, the same as if you were viewing the source in a browser.
If you want to experiment with this yourself here are two tips:
- To show only the response header with curl (e.g., no request headers or response body), use the
-Ioption, as in
curl -I localhost:3000.
- By default, Rails does not cache in a development environment; you may need to run
The Cache-Control HTTP Header
The main header we care about, as far as caching goes, is the
Cache-Control header. This helps determine which machines can cache a response from our Rails server and when that cached data expire. Within the
Cache-Control header, there are several fields, most of which are optional. We’ll go through some of the most relevant entries here, but for more information, you can check the official HTTP spec at w3.org.
Here’s a sample from a basic out-of-the-box Rails response header:
< Content-Type: text/html; charset=utf-8 < Etag: W/"b41ce6c6d4bde17fd61a09e36b1e52ad" < Cache-Control: max-age=0, private, must-revalidate
max-age field is an integer containing the number of seconds the response is valid. By default, a Rails response for a view will have this set to 0 (i.e., the response expires immediately, and the browser should always get a new version).
private in the header sets which servers are allowed to cache the response. If the header includes
private, it is only to be cached by the requesting client (e.g., the browser), not any other servers it may have passed through to get there, such as content delivery networks (CDNs) or proxies. If the header includes
public, then these intermediary servers are allowed to cache the response. Rails sets each header to
private by default.
Rails also sets the
must-revalidate field by default. This means that the client must contact the server to confirm that its cached version is still valid before it is used. To determine whether the cached version is valid, the client and server use ETags.
ETags are an optional HTTP header added by the server when it sends a response to the client. Typically, this is some sort of checksum on the response itself. When the client (i.e., your browser) needs to request this resource again, it includes the Etag it received (assuming it has a previous response cached) in the
If-None-Match HTTP header. The server can then respond with a
304 HTTP code (“Not Modified”) and an empty body. This means the version on the server hasn’t changed, so the client should use its cached version.
There are two types of ETags: strong and weak (a weak tag is denoted with a
W/ prefix). They behave the same way, but a strong ETag means the two copies of the resource (the version on the server and the one in the local cache) are 100% byte-for-byte identical. Weak ETags, however, indicate that the two copies may not be byte-for-byte identical, but the cached version can still be used. A common example of this is Rails’
csrf_meta_tags helper, which creates a token that changes constantly; thus, even if you have a static page in your application, it will not be byte-for-byte identical when it’s refreshed due to the Cross-Site-Request-Forgery (CSRF) token. Rails uses weak ETags by default.
ETags in Rails
Rails handles ETags automatically on views. It includes the ETag in outgoing headers and has middleware to check incoming ETag headers and returns
304 (Not Modified) codes when appropriate. Notably, however, because Rails generates views dynamically, it still has to do all the rendering work before it can figure out the ETag for that view. This means even if the ETags match, you are only saving the time and bandwidth it takes to send the data across the network, as opposed to something like view caching, where you can skip the rendering step completely if there’s a cached version. However, Rails does provide a few ways to tweak the generated ETag.
One way to overcome the ever-changing CSRF token from changing the ETag is with the
stale? helper in
ActionController. This allows you to set the ETag (either strong or weak) directly. However, you can also simply pass it an object, such as an ActiveRecord model, and it will compute the ETag based on the object’s
updated_at timestamp or use the maximum
updated_at if you pass a collection:
class UsersController < ApplicationController def index @users = User.includes(:posts).all render :index if stale?(@users) end end
By hitting the page with curl, we can see the results:
# curl -I localhost:3000 -- first page load ETag: W/"af9ae8f2d66b9b6c4d0513f185638f1a" # curl -I localhost:3000 -- reload (change due to CSRF token) ETag: W/"f06158417f290334f47ea2124e08d89d" -- Add stale? to controller code # curl -I localhost:3000 -- reload ETag: W/"04b9b99835c359f36551720d8e3ca6fe" -- now using `@users` to generate ETag # curl -I localhost:3000 -- reload ETag: W/"04b9b99835c359f36551720d8e3ca6fe" -- no change
This gives us more control over when the client has to download the full payload again, but it still has to check with the server every time to determine whether its cache is still valid. What if we want to skip that check altogether? This is where the
max-age field in the header comes in.
expires_in and http_cache_forever
Rails gives us a couple of helper methods in
ActionController to adjust the
http_cache_forever. They both work how you would expect based on their names:
class UsersController < ApplicationController def index @users = User.includes(:posts).all expires_in 10.minutes end end
# curl -I localhost:3000 Cache-Control: max-age=600, private
Rails has set the
max-age to 600 (10 minutes in seconds) and removed the
must-revalidate field. You can also change the
private field by passing a
public: true named argument.
http_cache_forever is mostly just a wrapper around
expires_in that sets the
max-age to 100 years and takes a block:
class UsersController < ApplicationController def index @users = User.includes(:posts).all http_cache_forever(public: true) do render :index end end end
# curl -I localhost:3000 Cache-Control: max-age=3155695200, public
This kind of extremely-long-term-caching is why Rails assets have a “fingerprint” appended to them, which is a hash of the file’s content and creating filenames, such as
packs/js/application-4028feaf5babc1c1617b.js. The “fingerprint” at the end effectively links the contents of the file with the name of the file. If the content ever changes, the filename will change. This means browsers can safely cache this file forever because if it ever changes, even in a small way, the fingerprint will change; as far as the browser is concerned, it’s a completely separate file that needs to be downloaded.
Spheres of Influence
Now that we’ve covered some caching options, my advice might seem a bit odd, but I suggest that you try to avoid using any of the methods in this article! ETags and HTTP caching are good to know about, and Rails gives us some specific tools for addressing specific problems. The caveat, though, and it’s a big one, is that all of this caching happens outside your application and is, therefore, largely outside your control. If you are using view caching or low-level caching in Rails, as covered in earlier parts of this series, and encounter invalidation issues, you have options; you can
touch models, push updated code, or even reach into
Rails.cache directly from the console if you have to, but not with HTTP caching. Personally, I’d much rather have to run
Rails.cache.clear in production than face an issue where the site is broken for users until they clear their browser cache (your customer service team will love you for it too).
This is the end of the series on caching in Rails; hopefully, it was useful and informative. My advice for caching continues to be as follows: do as little as you can, but as much as you have to. If you experience performance problems, start by looking for methods that are hit often; perhaps, they can be memoized. Need the value to persist across requests? Maybe that heavily-used method could use some low-level caching. Or, perhaps, it’s not any particular method; it’s just crunching through all those nested partials, which are slowing things down; in this case, maybe view level caching can help. Rails gives us the “sharp knives” to target each of these issues, and we just need to know when to use them.