Conditional GETs are a great way to speed up a web application. They're a way of telling a web browser, "Hey, this request is identical to how it was the last time you made the same request. I don't need to send you any body back at all, just reuse the one you already have."
fresh_when method is a solid method for doing this. For any action that's just working with a single record, it provides a pretty easy way to implement conditional GETs in your app, bypassing the need to render views and send them down the line to a client.
For handling conditional GETs on an action with multiple records, though, there's a big pitfall to watch out for. Consider this scenario, which I've seen in reality before:
- Record 3 [updated_at: 2012-02-14] - Record 2 [updated_at: 2012-02-13] - Record 1 [updated_at: 2012-02-10]
We have three records, sorted by their
updated_at attribute in descending order. The worst way to handle would be to say caching and conditional GETs are too hard and to just throw in the towel. This is the next worst thing to do:
def index @records = Record.all fresh_when last_modified: @records.map(&:updated_at).max end
updated_at attribute is changed every time a record is updated, this approach should do a pretty good job of determining request freshness, right? Well, think about what happens if record 2 above is destroyed. The most recent
updated_at is still that of record 3, and so our index action is still going to think that requests from before record 2 was destroyed are still fresh.
I've also seen solutions like the following, where a hash of the
updated_at for each record is used to generate an ETag:
def index @records = Record.all fresh_when etag: @records.map(&:updated_at).join end
I don't particularly like this approach, as it still requires initializing every single record instance needed for the request, which I'd rather not do unless I'm actually going to be using those records. Recently, I've been using the paranoia gem to make it much easier to do conditional GETs. Since paranoia ensures that records are never actually destroyed but just marked as deleted, I simply need to use the most recent
deleted_at timestamp for the conditional GET:
def index if stale? Record.with_deleted.select("GREATEST(MAX(updated_at), MAX(deleted_at))").first.greatest @records = Record.all end end
That slightly ugly-looking SQL basically says to find the maximum value of
updated_at and the maximum value of
deleted_at, and return the greatest of the two. You might clean it up a little by providing a class method on your model:
# app/models/record.rb class Record < ActiveRecord::Base acts_as_paranoid # Use paranoia on this model # `reorder(nil)` ensures that ActiveRecord won't attempt to sort the results def self.most_recent_timestamp with_deleted.select("GREATEST(MAX(updated_at), MAX(deleted_at))").reorder(nil).first.greatest end end # app/controllers/records_controller.rb class RecordsController < ApplicationController def index if stale? Record.most_recent_timestamp @records = Record.all end end end
Now, you've got a method on your model that makes it really simple to handle conditional GETs (note that the
.most_recent_timestamp method will also work with scoped collections). As long as the relevant timestamp columns (
deleted_at) are each indexed, you're swapping out initializing every single ActiveRecord object for each request for a very quick SQL query getting the most recent updated or deleted timestamp.
Quick side note on busting these headers between deploys. The downside of conditional GETs is that deploying new view or stylesheets code to your app won't bust these headers, and users can get stale views. I've been solving this problem by using the ettu gem, which adds assets and views digests into ETag generation (i.e. it digests the file contents, not the rendered view). It's still much quicker than actually rendering the views and generating ETags based off of that.