Skip to content

Blogging On Rails

Everything on Rails!

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.

Feel free to leave a comment or question below.

Want To Learn More?

Try out some more of my Stimulus.js Tutorials.

Make Interactivity Default 

Make your web app interactive now with easy to use and simple Stimulus.js controllers. 

Enter your email and get a free sample of my Stimulus Tutorials ebook.

We won’t send you spam. Unsubscribe at any time.

24 comments on “Easy PWAs the Rails way”

  1. 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?

    1. 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. 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?

    1. 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.onrails.blog, but since it’s more of a news app, it isn’t really optimized for offline mode.

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

  4. 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

  5. The service workers work in my localhost but seem to fail when deployed to heroku. The console errors display

    `A bad HTTP response code (500) was received when fetching the script.

    1. You may have to tweak the http server settings.

      .js files are usually treated like static files, when in this case, it’s dynamically being created, so you need to configure something so that the request hits your rails app.

      One thing I had to do in Nginx was turn off all caching for the service worker and manifest.json files

      location = /service-worker.js {
      expires -1;
      }

      location = /manifest.json {
      expires -1;
      }

  6. Just an update: I managed to fix the issue. I had to go to the webpacker.yml file and set extract_css to false. Somehow that work. Not sure why though

    1. What I meant was: It should be stylesheet_path ‘application.css’ and not asset_pack_path ‘application.css’

  7. Great tutorial, super simple and easy to understand.
    Only one thing, I’m getting “Web app manifest does not meet the installability requirementsFailures: Manifest does not have `short_name`, Manifest does not have `name`.” even though the manifest has them (I copied from the tutorial and changed the content.
    If I go to /manifest.json its all there.
    What could have gone wrong?
    Thanks!

    1. I can’t edit it, but it was a stupid problem. the favicon gem has its own manifest that was loading instead. Fixed it by combining the manifests.

      But another problem came up that I just couldn’t solve. Whenever I login (using devise, rails 6), it would redirect me to the service worker file itself, instead of the root path.
      Any idea what might be causing it?

        1. After I commend out the service worker route, when I login, i get redirected to the home page as expected, but the service worker fails to start.
          I get this in the console:

          A bad HTTP response code (404) was received when fetching the script.
          (index):1 Uncaught (in promise) TypeError: Failed to register a ServiceWorker for scope (‘http://localhost:3000/’) with script (‘http://localhost:3000/service-worker.js’): A bad HTTP response code (404) was received when fetching the script.

          I can’t seem to understand what makes login redirect to the service worker file itself…

  8. After I commend out the service worker route, when I login, i get redirected to the home page as expected, but the service worker fails to start.
    I get this in the console:

    A bad HTTP response code (404) was received when fetching the script.
    (index):1 Uncaught (in promise) TypeError: Failed to register a ServiceWorker for scope (‘http://localhost:3000/’) with script (‘http://localhost:3000/service-worker.js’): A bad HTTP response code (404) was received when fetching the script.

    I can’t seem to understand what makes login redirect to the service worker file itself…

  9. Are you able to run this on SSL? I think localhost works for PWAs, but Service Workers are by design only able to run over an https connection.

    I would also look at the logs in your app. Once you’ve logged in, what are the redirects you’re seeing in log/development.log? i.e.

    Started POST “/login” for 127.0.0.1 at 2020-04-11 11:58:07 -0400
    Redirected to …/login
    Completed 302 Found in 77ms (ActiveRecord: 0.4ms)

    Started GET “/login” for 127.0.0.1

    1. It happens both in development and in production, where its https.

      Started POST “/users/sign_in” for ::1 at 2020-04-13 10:55:51 +0300
      Processing by Users::SessionsController#create as HTML
      Parameters: {“authenticity_token”=>
      “ZQDmKo1vmsW8gouQeNkmFI7hzrSLbxv8kB75vVV3KaRmTS29T/5a/+ii8BXGKVjea7+VXmBSoPF3+gf1BEcgrQ==”,
      “user”=>{“login”=>”456”, “password”=>”[FILTERED]”, “remember_me”=>”0”}, “commit”=>”כניסה”}
      User Load (0.9ms) SELECT “users”.* FROM “users” WHERE (lower(username) = ‘456’ OR lower(email) = ‘456’) ORDER BY “users”.”id” ASC LIMIT $1 [[“LIMIT”, 1]]
      ↳ app/models/user.rb:35:in `find_first_by_auth_conditions’
      Redirected to http://localhost:3000/service-worker.js
      Completed 302 Found in 154ms (ActiveRecord: 0.9ms | Allocations: 4700)

        1. I had the same problem, and it’s coming from the fact that he is treating this service_worker.js.erb file as an html file. Just add a respond_to in your controller action and it will solve it (for me it did at lease).

          The ServiceWorkerController will look like this :

          class ServiceWorkerController < ApplicationController
          protect_from_forgery except: :service_worker

          def service_worker
          respond_to do |format|
          format.js
          end

          def manifest
          end
          end

          1. I think this is not correct. The second end on manifest should be closing the respond_to inside service_worker:

            def service_worker
            respond_to do |format|
            format.js
            end
            end

            def manifest
            end

Leave a Reply

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

Copyright © 2024 John Beatty. All rights reserved.