HTML fragment caching really works!
I have somehow been using Ruby on Rails since 2005 and have never worked on an app that needed to think seriously about web request caching, probably because of my proclivity to reach for static site generators and simple asset hosting whenever anything I make will be public-facing. But the current app I'm working on is actually mostly accessible without requiring users be logged in, which means it will both (1) run the risk of having bursts of hard-to-anticipate traffic to certain pages and (2) render pretty much the exact same markup for everyone.
I'll start with the results. Here's a mostly-empty, public-facing page my basic Heroku dyno without caching:
Completed 200 OK in 281ms (Views: 201.9ms | ActiveRecord: 47.5ms | Allocations: 37082)
And now with a few lines of caching setup:
Completed 200 OK in 9ms (Views: 3.5ms | ActiveRecord: 1.6ms | Allocations: 2736)
So over 30 times faster. And that's on a very basic page. Once the site is primed with content it'll probably be even more dramatic.
Here's how to do it.
Background
If you start with the Rails Guide on caching, you might notice that the first approach described actually points you to "the old way" of caching entire pages, which has since been spun off into the infrequently-updated actionpack-page_caching gem. Worse yet, the next approach detailed points you to another deprecated caching method, which has also been spun off as actionpack-action_caching. So if you've ever looked into caching with Rails and gotten distracted or derailed, I wouldn't blame you.
The real sauce is fragment caching which David made a great case for on his blog way back in 2012. One's intuition would tell you that simply caching an entire unauthenticated page would obviously be superior to merely caching small fragments, and you would be correct so long as cache invalidation is trivially easy—and that's an assumption that rarely holds for very long. Additionally, all it would take for the approach to fall over is for some future feature to augment a public-facing page with a privileged UI element like, say, an admin toolbar.
Fragment caching won't generally spare you a ton of database queries (though with some creativity it can help you unwind eager-loading when you know you can rely on the cache). Instead, where it really saves you is in the surprisingly time-consuming process of assembling the dozens or hundreds of partial snippets that might need to be rendered to construct a single HTML page.
Install
On Heroku, I went with Memcached Cloud due to its generous free tier (30MB is comparable to Heroku's MemCachier, but that one seems to have stricter connection limits until you start paying).
To install the addon:
$ heroku addons:create memcachedcloud:30
Then add Dalli to your Gemfile:
gem "dalli", group: "production"
And add this little snippet to your config/environments/production.rb
file,
perhaps in place of the default comment about cache_store
# See: https://devcenter.heroku.com/articles/memcachedcloud#using-memcached-from-ruby
if ENV["MEMCACHEDCLOUD_SERVERS"]
config.cache_store = :mem_cache_store, ENV["MEMCACHEDCLOUD_SERVERS"].split(","), {username: ENV["MEMCACHEDCLOUD_USERNAME"], password: ENV["MEMCACHEDCLOUD_PASSWORD"]}
end
That's it!
Simple usage
To actually direct Action View to cache stuff, I'd refer you back to the Rails Guide, which shows the cache
method off well.
In my case, I'm showing a page resembles an Instagram user view (with profile details on top and a collection of thumbnails underneath). Caching the profile partial was easy enough, I just wrapped its HTML in:
<% cache profile do %>
<% end %>
As long as the only data the partial is reading from is on profile
and as long
as profile
is an Active Model that responds to cache_key
, it should be
safe to cache it (meaning: a cache hit will return the correct page content).
Caching collections
More complicated is looping over a collection or dealing with associations. In this case, I am iterating over many Post
models, so I used the collection shorthand syntax for a post
partial:
<%= render partial: "feeds/post", collection: @posts, cached: true %>
The cached: true
is all that was needed to cache each post's partial.
Dealing with nested associations
When it came to the show
action of the Post
itself, each post has many Visual
elements, and because
the Post
and its Visual
records could be updated independently, I wrapped the entire view in a single
cache key that encompassed both of them:
<% cache([@post, @post.visuals.map(&:updated_at).max]) do %>
<% end %>
Because I'm eager-loading the visuals
association with includes
in my
controller, mapping over their updated_at
values to construct a cache key
doesn't cost me anything. However, once you have caching in place such that only
1 in a 100 renders might actually need to render the partial, eager-loading the
universe in the controller to avoid N+1 queries is no longer a slam dunk win.
Eager-loading a bunch of nested assocations that you won't even need in the
event of a cache hit would be wasteful, so if I really wanted to speed things
up I could instead include a custom select()
to compute a value named
something like latest_visual_updated_at
in SQL when loading the @post
and
then reference that instead. Then, inside the cached section of the view, I
could call some method that would, uhh, lazily eager-load everything in the
event of a cache miss.
Pages with user-specific elements
In the event there exists an entire role of users (admins in my case) for whom the HTML should be identical, you can just tack that onto the cache key:
<% cache([@post, @post.visuals.map(&:updated_at).max, current_admin.present?]) do %>
<% end %>
Non-admin users will resolve to one cache key and admin users will resolve to another. In my case, that means both can benefit from cache hits, but only admin users will see an "Edit" button next to each post. Cool!
Caching is cool
The fact all of this was so easy—especially as someone as late to the party as I am with respect to even thinking about caching—is all thanks to having adopted a mature, batteries-included framework like Rails. Thanks to everyone who built this stuff and shared it! 💚