When using Ruby on Rails, there are different caching strategies, which are described in the Ruby on Rails Guides. For instance you can set cache control headers which signals the users’ browsers or any network node between the user and your web server to cache content. For instance a CDN can serve the cached response without bothering your web server, which can have great performance benefits.

But unfortunately this would work only, if you have no dynamic data in your page. Most web applications like our poll maker have a dynamic header with content that is customized for the user. For instance we offer a customized user menu, where also the user’s name is shown and additionally we show an activity bubble which shows a count of new activities in your latest polls.

PollUnit Header

So we want the header to be dynamic, but the page content below the header is static and could therefore be cached. The rails answer to this is fragment caching where you can cache only certain parts of your page. The cached parts are typically stored in a memcached or redis cache and when a page is requested, your web server does not need to render the cached part, but can retrieve it from the cache store.

While this relieves your web server to always render the cached part, the request still always needs to do a round trip to your server and cannot benefit from a CDN, which typically serves cached content from edge servers close to your users.

Now how can we benefit from a CDN but have dynamic parts in our page?

Here is how we plan to do this:

  1. Build a static rails layout and a static page that can be cached for all users.
  2. Lazy load the dynamic content after the static page is rendered.

With this approach our web server is only busy with serving the lazy loaded dynamic content. Another benefit is, that our page and the main content is served and rendered really fast when it is retrieved from the CDN cache. Because the dynamic content is lazy loaded, the user does not need to wait for it, before seeing the page content. This also helps to improve your SEO relevant core web vitals.

1. Build a static rails layout and a static page

We are demonstrating this with a new rails app created with rails 7.0.5. So first create a new rails app with:

rails new my_new_app

Then do some modifications / additions:

1.1 Create a new controller with caching headers and static layout

# app/controllers/static_controller.rb
class StaticController < ApplicationController
  layout 'static'

  def index
    request.session_options[:skip] = true
    expires_in 1.year, public: true
  end
end

The line

request.session_options[:skip] = true

makes sure, that the response does not set a session cookie. CDNs usually do not cache server responses that contain a Set-Cookie header.

1.2. Direct the page root to the new controller action

# config/routes.rb
Rails.application.routes.draw do
  root "static#index"
end

1.3. Create static layout

Create a copy of the generated app/views/layouts/application.html.erb and remove dynamic information. Explicitly the csp_meta_tag and csrf_meta_tags have to be removed, because they are unique for each user session and/or request and it would be a security issue to share them among users. We created a copy, so that it is possible to use the standard application layout for pages that should not be cached.

<!-- app/views/layouts/static.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>New Static Rails App</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> 
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

1.4. Create a template for the controller action.

I included a random number, which makes it easy to see if the page is actually cached or not.

<h1>New static page</h1>

<strong><%= rand(1000) %></strong>

Now when you start your server and visit the page root several times you should always see the same number and your network tab should also inform you that the page was served from disk cache. In most browsers it seems the disk cache is omitted if you do a refresh and in chrome I need to navigate somewhere else and enter the URL to allow the browser to take the page from the cache.

Static page result

Network tab

Ok great! Now we have a static page that can be cached in users’ browsers and in any network node between the user and your web server. Now let’s tackle the dynamic part!

2. Lazy load dynamic content

We are going to load the dynamic parts with the Stimulus Content Loader. There are a few steps to get started:

2.1. Add stimulus-content-loader to your importmap

bin/importmap pin stimulus-content-loader

2.2 Modify your app/javascript/application.js

// app/javascript/application.js
import "@hotwired/turbo-rails";
import "controllers";
import { Application } from "@hotwired/stimulus";
import ContentLoader from "stimulus-content-loader";

const application = Application.start();
application.register("content-loader", ContentLoader);

2.3. Then modify your static layout

<!-- app/views/layouts/static.html.erb -->
<!DOCTYPE html>
<html>
  <head data-controller="content-loader" data-content-loader-url-value="/head">
    <title>New Static Rails App</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> 
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <header data-controller="content-loader" data-content-loader-url-value="/header">
      Loading header
    </header>

    <%= yield %>
  </body>
</html>

With this we want to replace the value in head and header tags with dynamic content.

2.4. Now we need to create the corresponding routes, controller and templates

# config/routes.rb
  ...
  get '/head', to: 'headers#head'
  get '/header', to: 'headers#header'
# app/controllers/headers_controller.rb
class HeadersController < ApplicationController
  layout :false

  def header
    @user_name = "User ##{rand(1000)}"
  end

  def head; end
end
<!-- app/views/headers/header.html.erb -->
Hi <%= @user_name %>
<!-- app/views/headers/head.html.erb -->
<title>New Static Rails App</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />

<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> 
<%= javascript_importmap_tags %> 
<%= csp_meta_tag %> 
<%= csrf_meta_tags %>

Ideally it would be nice to only append the content in head, but the stimulus-content-loader does not provide this functionality. So we have to repeat the content which is already there from the initial page render.

Now if you visit your page several times, the information in the header should change, but the page content should stay the same. The network tab also confirms this.

Page Result

Network tab

Conclusion

We separated the dynamic content from our page so that it can be cached from browsers or CDNs. The dynamic content is lazy loaded after the initial page load. If you have a landing page with a lot of content, which would have to be rendered by your web server, this will relieve it from its burden. Also serving content from CDN edge servers, can lead to a better user experience and page performance metrics.