Lesson 4: Planning HTTP/Proxy Caching for the Spottiest Internet.

Jonathan Greenberg
By Jonathan Greenberg
November 15, 2022

This is the fourth post in a series on things we explored while “Revamping the Ruby Central Conference Websites”.

One of the critical requirements for our redesign of the website process for Ruby Central Conferences was to ensure that the final website pages would be delivered quickly and efficiently. As any conference goer can attest to, the quality of internet at events can be surprisingly spotty and there is nothing more disappointing than waiting for a schedule to load while you are anxiously trying to figure out where to go for the next talk.

Often the ultimate speed solution is caching and with Rails there are so many layers of caching to consider. Since bandwidth is the ultimate bottleneck at developer conferences, caching at the view or database layer would ultimately not make a significant impact on performance. Page caching might have been a good option but that was not really an option for us since it is not possible when deploying to Heroku because of the ephemeral file system. In the end, we settled on a solution that involved a combination of HTTP caching backed by a CDN using Fastly as a reverse proxy

HTTP caching is a powerful tool that has been available since the early days of the asset pipeline with Rails 3. I won’t go into all the details here but essentially HTTP caching involves passing response and request headers between the server and browser to determine whether an asset is stale or not. This introduction from Thoughbot is a good primer or refresher on how some of this works.

This was the perfect solution for our use case because the content for a website event does not change much once an event begins which is also when traffic is highest and speed is most important. HTTP caching relies on the browser, optionally backed by a reverse CDN proxy, as the primary cache which of course is the most performant since it isn’t dependent on having a good connection. Let’s look at the code and then we will try to break it down:

  def set_cache_headers
    return unless Rails.configuration.action_controller.perform_caching

    server_cache_age =
      current_website.caching_off? ? 0 : ENV.fetch('CACHE_CONTROL_S_MAXAGE', 1.week)

    expires_in(
      ENV.fetch('CACHE_CONTROL_MAX_AGE', 0).to_i,
      public: !current_website.caching_off?,
      's-maxage': server_cache_age.to_i
    )
    response.headers['Surrogate-Key'] = current_website.event.slug if FastlyService.service
    fresh_when(
      current_website,
      last_modified: current_website.purged_at || current_website.updated_at
    ) unless current_website.caching_off?
  end

fresh_when is a convenient Rails helper method that sets a Last-Modified response header based on the Website#purged_at timestamp. Then, when a client browser sends a request with a If-Modified-Since header with that timestamp fresh_when will compare dates and respond with a 304 Not Modified when appropriate so that the client can use its cached version of the request saving both the server and client from doing a full request cycle.

We are also adding a Cache-Control header using the expires_in helper. This header is a bit more heavy handed so the ENV variable defaults to 0 since it essentially instructs the browser to use its cached version based on a max-age using that value. This can be potentially quite helpful during a conference if the internet turns out to be very unreliable though it has the risk of locking people out of getting updates if there are important changes to a page or the program.

On the other hand, for our Fastly configuration we can afford to be much more generous when setting the custom s-maxage value that it looks at. Fastly serves as a shared public cache so that as soon as any browser requests a new version of the page for the first time, Fastly’s CDN will then deliver the cached version of the page to all requests less than that s-maxage value.

Aside from the increased speed of delivery that Fastly provides and the resultant decrease in load for our servers we also get a mechanism for purging the Fastly public cache for the website. This is why setting a long cache expiration time for Fastly is risk free. Notice that we add an additional Surrogate-Key custom Fastly header using the associated event slug for the website. Then in the model we can purge the website cache with a simple Fastly api call either with a manual request by an admin or through an automatic callback when some important change to the website occurs:

class Website < ApplicationRecord
  ...
  around_update :purge_cache, if: :caching_automatic?
  ...
  def manual_purge
    purge_cache { save }
  end

  def purge_cache
    self.purged_at = Time.current
    yield
    FastlyService.service&.purge_by_key(event.slug)
  end
end

Another benefit we get from using a Fastly Surrogate Key is that we can independently cache each website. For example, if someone happens to be actively working on designing the website for RubyConf while the RailsConf event is happening we don’t need to worry about the Fastly cache for RailsConf getting unintentionally busted.

How this all works out in practice remains to be seen and we look forward to hopefully trying it out at the next Ruby Central conference. If you have any thoughts or experiences to share about HTTP caching with Fastly or some other service please send them our way

Oh yeah, did we mention that this CFP-app is essentially a multi-tenant application that supports an indefinite number of independent events? Not only does that add some trickiness for caching but also some complications for handling routing, especially since conferences in successive years need to share the same domain and both rubyconf.org and railsconf.org need to resolve to the same server. In our next blog post we will share how we tackled this little challenge using some clever routing and a custom constraint.

If you’re looking for a team to help you discover the right thing to build and help you build it, get in touch.