Skip to content

Instantly share code, notes, and snippets.

@taranda
Last active July 7, 2021 19:53
Show Gist options
  • Save taranda/1597e97ccf24c978b59aef9249666c77 to your computer and use it in GitHub Desktop.
Save taranda/1597e97ccf24c978b59aef9249666c77 to your computer and use it in GitHub Desktop.
Dynamically Add Critical CSS to Your Rails App

Dynamically Add Critical CSS to Your Rails App

Optimizing the delivery of CSS is one way to improve user experience, load speed and SEO of your web app. This involves determining the "critical path CSS" and embeding it into the html of your page. The rest of the CSS for the site is loaded asynchronously so it does not block the rendering of your "above the fold" content. This Gist will show you how to dynamically generate critical path CSS for your Rails app.

In this example we will use the mudbugmedia/critical-path-css gem.

Prerequisites

You will need to set up caching and Active Job in your Rails app. I recommend using a thread-safe background job manager like resque.

You will also need to install the critical-path-css-rails gem by adding the following to your gemfile.

gem 'critical-path-css-rails', :git => '[email protected]:mudbugmedia/critical-path-css-rails.git'

Setup Dynamic CSS Generation

First, add the following method to your application controller:

# app/controllers/application_controller.rb
def fetch_critical_css
  @critical_css = (request.get?) ? CriticalPathCss.fetch(request.path) : ''
  GenerateCriticalCssJob.perform_later(request.path) if @critical_css.empty?
  return @critical_css
end

This method will attempt to load the critical path CSS from the cache. If there is a cache miss, then a background job will be enqueued to generate the critical path CSS. You may notice that CSS generation only takes place if the controller is processing a GET request. Right now, only GET requests are supported.

Second, create a background job for generating the CSS:

# app/jobs/generate_critical_css.job
class GenerateCriticalCssJob < ApplicationJob
  queue_as :default

  CSS_SEMAPHOR_NAMESPACE = 'critical-path-css-semaphor'.freeze

  def perform(route)
    return if route.nil?
    return if Rails.cache.exist?(route, namespace: CSS_SEMAPHOR_NAMESPACE)
    return unless CriticalPathCss.fetch(route).empty?
    Rails.cache.write(route,'generating', { namespace: CSS_SEMAPHOR_NAMESPACE, expires_in: 10.minutes })
    CriticalPathCss.generate route
    Rails.cache.delete(route, namespace: CSS_SEMAPHOR_NAMESPACE)
  end
end

This job does several things. First, it makes sure the route is not nil. Second, it uses a cache-entry as a semaphore to make sure another job is not already generating the CSS for the given route. The JavaScript program that generates the critical CSS sends a GET request to the route and uses the response to calculate the critical path CSS. This in turn triggers the enqueuing of another worker to generate the CSS. Without the semaphore, the new worker would send another GET request to the route thus enqueuing another worker and so forth until one of the workers completes the critical path CSS calculation and adds it to the cache or a resource limitation is reached. Since we do not want to risk crashing the app, we use the semaphore to ensure that only one worker generates the critical path CSS for a given route at a time.

If the semaphore is not set, then the worker checks to see if the critical CSS has already been generated by another worker. We do not want to spend computing resources generating CSS that has already been generated. If the CSS has not been generated, then the worker will set the semaphore in the cache and proceed to generate the CSS. Upon completion of the CSS generation, the semaphore is cleared allowing future updates to the critical path CSS to take place. We also set an expiration of 10 minutes on the sempahore to prevent future CSS generation from being blocked in case the worker crashes during CSS generation.

NOTE: Consideration was given to setting the semaphore in the controller. That way we would not enqueue workers that are simply going to exit after checking the semaphore. While this makes sense from an efficiency perspective, in the interest of keeping the code modular and useable, we decided to put the semaphore logic in the worker itself. This way other parts of the app can enqueue a generate CSS job without having the burden of checking and setting a semaphore.

Third, add a critical CSS helper to your application helper:

# app/helpers/application_helper.rb
def critical_css
  raw @critical_css
end

This helper returns the critical CSS and should be enclosed in a <style> tag in your HTML. The raw method is necesary to prevent Rails from HTML-encoding the CSS.

Fourth, use something like the following ERB snipet in your application layout to make use of your critical CSS. This assumes that your main CSS file is called application and uses loadCSS load your stylesheet ansynchronously. Any method of asynchronous loading will work as long as stylesheet loading does not block page rendering.

<% if critical_css.present? %>
  <style><%= critical_css %></style>
  <%= tag :link, as: 'style', href: stylesheet_path('application'), onload: raw("this.rel='stylesheet'"), rel: 'preload' %>
  <noscript>
    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
  </noscript>
<% else %>
  <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
<% end %>

The above will load critical CSS from the cache if it is available along with asynchronously loading the stylesheet. If critical CSS is not available, it will load the stylesheet synchronously blocking page rendering until the CSS is loaded. This ensures that the website user does not experience a flash of unstyled content (FOUC).

Fifth, you can use before_action in your app's controllers to determine which pages will use critical CSS. The following example loads the critical path CSS for the index and how-it-works methods of some controller:

# app/controllers/some_controller.rb
class SomeController < ApplicationController
  before_action :fetch_critical_css, only: [:index, :how_it_works]
  ...
end

You can generate critical CSS for almost all of your application by adding before_action :fetch_critical_css to your application_controller.rb. Please note: CSS will only be generated for routes that use GET requests. At this time, other request verbs are not supported.

If you need to regenerate the critical CSS due to change in dynamic content, you can clear the critical CSS cache and refresh it by using something like the following:

def after_something_has_changed_on_the_page
  CriticalPathCss.clear(some_path)
  GenerateCriticalCssJob.perform_later(some_path)
end

Clearing the cache will prevent a FOUC while waiting for the background worker to refresh the CSS cache.

Deployment Considerations

In order to avoid having a stale CSS cache, you should clear your CSS cache after any of your stylesheets have changed. One way to ensure your cached CSS is up to date is to clear the cache after compiling your assets, but before generating any new critical CSS. Fortunately we have a nice rake task for this. Simply add rake critical_path_css:clear_all to your deployment script after your assets:precompile step. After the cache is cleared, you could use rake critical_path_css:generate to warm your CSS cache, but this is not necessary.

Background Workers and Environment

It is important to use a thread-safe background worker implemention for this to function properly. Using Rails' default Active Job processor caused my Rails server (Puma) to hang in my development environment. I had to use resque in my development environment to get this to work.

Conclusion

That is all there is to it. You now have the tools necesary to deliver optimized CSS on any of your Rails app pages.

I welcome your feedback on this approach.

Written by Tom Aranda, owner of Aranda Cyber Solutions, LLC.

@marcelkalveram
Copy link

Love this idea. Unfortunately it's only working on localhost for me, when using file_store. No success with memory_store. I guess it's because the two Ruby processes (app and worker) are not sharing the same memory? I think a Redis implementation of the plugin would be much preferred. I'll look into this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment