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.
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:
- Build a static rails layout and a static page that can be cached for all users.
- 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.

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.

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.