justin․searls․co

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! 💚


Got a taste for hot, fresh takes?

Then you're in luck, because you can subscribe to this site via RSS or Mastodon! And if that ain't enough, then sign up for my newsletter and I'll send you a usually-pretty-good essay once a month. I also have a solo podcast, because of course I do.