Skip to content →

Easy PWAs the Rails way

Progressive Web Apps, or PWAs, have a home in Rails. There are a few pieces to add to any app to make it a PWA. With a little configuration, an app will be installable in a browser, accessible offline, and in line with any other front end framework.

PWAs?

Progressive Web Apps are a type of web app that can provide richer experiences online. Google has written up a guide on what they consider necessary for a delightful web experience here. Rails provides a lot of the necessary tools for page speed, such as Turbolinks and Russian Doll Caching. In order to be a PWA, an app needs to supply two specific files at specific paths: an app manifest, available at /manifest.json and a service worker, available at /service-worker.js. Here are two ways to provide these files currently in Rails: either in the public folder, or preferably, through Rails itself.

Providing the components

The first problem to consider is that the manifest file and the service worker file cannot be inside the asset pipeline path or the webpacker path. There is some discussion on Github about this, but the key part of the specification is that the service worker scope is dictated by the path of the file. If the service worker is served from /packs, then it can only be used for web pages that are in /packs. That’s not very useful. It’s therefore necessary to set up manifest.json and service-worker.js outside the asset pipeline or webpack.

You need to add some general configuration to your site. First, point to the manifest file in your application.html.erb head tag:

<!-- Lighthouse Details -->
<link rel="manifest" href="/manifest.json">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#C50001"/>

In your javascript, you need to register the service worker. This can go in application.js, either in webpack or in the asset pipeline location:

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/service-worker.js', { scope: './' })
    .then(function(reg) {
      console.log('[Companion]', 'Service worker registered!');
      console.log(reg);
    });
}

Then add the manifest.json and service-worker.js files as you need.

First Method: Public Folder

Write out the manifest.json file and the service-worker.js file by hand, and place them in the public folder in the Rails app. Files in the public folder path are served as is. One downside is that the asset paths are not available in the public folder. This means that those assets cannot be cached by the browser, and the app would lose some offline mode functionality.

Second Method: Service Worker Controller

By adding a controller that manages the /service-worker.js and /manifest.json requests, the asset paths are now available for all assets that need caching.

Add the following lines to you routes.rb file:

# Service Worker Routes
get '/service-worker.js' => "service_worker#service_worker"
get '/manifest.json' => "service_worker#manifest"

Make a controller called service_worker.rb and add two methods, like so:

class ServiceWorkerController < ApplicationController
  protect_from_forgery except: :service_worker

  def service_worker
  end

  def manifest
  end
end

The protect from forgery for the service_worker method allows the javascript to be served without any forgery request problems in rails.

In the views folder, make a folder called service_worker and add a manifest.json.erb file that looks something like this:

{
  "short_name": "My App",
  "name": "My Very Progressive Web App",
  "icons": [
    {
      "src": "<%= asset_path('icon_192.png') %>",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "<%= asset_path('icon_512.png') %>",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": "<%= root_path %>",
  "background_color": "#fff",
  "display": "standalone",
  "scope": "<%= root_path %>",
  "theme_color": "#000"
}

Asset paths are available, with means that all the Rails asset pipeline or webpack features are available. Those icons referenced above also need to be provided.

Now, add a service_worker.js.erb file that looks something like this:

var CACHE_VERSION = 'v1';
var CACHE_NAME = CACHE_VERSION + ':sw-cache-';

function onInstall(event) {
  console.log('[Serviceworker]', "Installing!", event);
  event.waitUntil(
    caches.open(CACHE_NAME).then(function prefill(cache) {
      return cache.addAll([
        '<%= asset_pack_path 'application.js' %>',
        '<%= asset_pack_path 'application.css' %>',
      ]);
    })
  );
}

function onActivate(event) {
  console.log('[Serviceworker]', "Activating!", event);
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          // Return true if you want to remove this cache,
          // but remember that caches are shared across
          // the whole origin
          return cacheName.indexOf(CACHE_VERSION) !== 0;
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    })
  );
}

// Borrowed from https://github.com/TalAter/UpUp
function onFetch(event) {
  event.respondWith(
    // try to return untouched request from network first
    fetch(event.request).catch(function() {
      // if it fails, try to return request from the cache
      return caches.match(event.request).then(function(response) {
        if (response) {
          return response;
        }
        // if not found in cache, return default offline content for navigate requests
        if (event.request.mode === 'navigate' ||
          (event.request.method === 'GET' && event.request.headers.get('accept').includes('text/html'))) {
          console.log('[Serviceworker]', "Fetching offline content", event);
          return caches.match('/offline.html');
        }
      })
    })
  );
}

self.addEventListener('install', onInstall);
self.addEventListener('activate', onActivate);
self.addEventListener('fetch', onFetch);

This service worker skeleton came from a Rails service worker gem that wasn’t working with webpack.

Whatever files need caching are added in the onInstall function. This template only has the webpack pack files, but any images, or asset pipeline files that should be cached can be added too.

Testing?

Google’s Lighthouse is the best way to test how an app matches the specification. Lighthouse will give recommendations, and list what’s missing. It can even test accessibility. Progressive Web Apps need HTTPS when not being served from localhost, so testing in Google Chrome during development is the easiest option.

Easy PWAs

PWAs with Rails are very easy to setup. They don’t necessarily get the webpacker/asset pipeline benefits of compilation and minimization, but the ability to serve the service worker and app manifest with just a little bit of configuration means there is no need to rewrite an existing app in order to make a web page sleek and modern.

More PWA goodness: HNPWA!

You can see a full fledged example of a Rails based PWA here.

Comments or Questions? Find me on twitter @jpbeatty

Want To Learn More?

Try out some more of my Stimulus.js Tutorials.

Published in ruby on rails

9 Comments

  1. Pedro Vinícius L. da Silva Pedro Vinícius L. da Silva

    Great article! I’m following it to implement offline capabilities in a legacy system. I have just one question. How do you proceed regarding cache invalidation? You know, javascripts, stylesheets, and event images can change. When this happen, how could I remove the changed assets from the cache?

    • johnbeatty johnbeatty

      Every time the browser loads the service-worker.js file, it will cache the files you want cached. If you want to invalidate the cache, change the CACHE_NAME value in the example, and then on activation, any caches with a different name will be cleared.

  2. Eric Eric

    My small understanding of React and similar, is that it allows to have the whole application offline, having the ability to cache some part of the data as well (through the local storage). How do you deal with that in a Rails application? I understand that most of the part it’s possible to do, but my missing piece is the offline component of a PWA where you are able to load all the application offline, having the pages rendered server side I find that hard to understand how to get them into the PWA/manifest.

    Do you have any good example of a slightly complex rails app (not a simple todo app) that has PWA/offline support?

    • johnbeatty johnbeatty

      You can cache whatever pages you’d like, so that’s a per application decision. But if your React application is contingent on an API being available, that’s no different than a rails app that loads html pages. But I suppose if it’s a tip calculator, having all the logic loaded client side would be an advantage. I’d argue that you’re under the same constraints regarding what files/resources you need should cache no matter what framework you’re working with.

      https://serviceworke.rs/strategy-cache-update-and-refresh.html is a good resource for deciding how you can cache information.

      I have an example of a pwa at https://hnpwa.johnbeatty.co, but since it’s more of a news app, it isn’t really optimized for offline mode.

  3. Thanks! This article made it easy to understand how a PWA works

  4. Mike Holford Mike Holford

    Hey John thanks for the great article. Have you got any examples of the service-worker.js file you used for this tutorial?

  5. Tony D Tony D

    Thanks for writing these up. I’d love to see a tutorial on setting up the Add to Home Screen A2HS part of PWA functionality. It seems to me that this would be a big plus for a lot of web apps, but it’s proving hard to get working in Rails (at least for me as a newbie) 🙂 Thanks again for taking the time to write these up

Leave a Reply

Your email address will not be published. Required fields are marked *