Everything You Need to Ace PWAs in Rails

Turn your Rails app into an engaging native-like application in a couple of minutes

This article starts a series about Progressive Web Applications (PWAs) in Rails. In this series, I will show you how to turn your Rails application into a native-like app that works offline and handles background operations, and push notifications.

With PWAs and HTML APIs, you can build very powerful applications that can be installed on the user’s device. This is a killer feature for user engagement, especially when talking about mobile devices.

You can deliver your app without those gatekeepers blocking it from being published because they want to force you to use their expensive services like in-app purchases, or even deny your app because they don’t like an image you used.

Alpha filter is not allowed. I deny you

They say.

PWAs bring freedom, power, and control back to the developers. And Rails is a great framework to build PWAs. In this article, I will give you an introduction to PWAs and show you how to set up a PWA in a Rails application quickly.

A Brand-New Rails Application

A new rails project created since version 7.2 comes with files that make it easy to turn it into a PWA.

For this series, you will need a Rails app. The unique pre-condition is to have your own views under some layout. The default Rails controller does not work with the PWA setup.

For this article, I created a simple controller using rails g controller home. And defined the root route to point to home#index. The view content is just a simple Hello, PWA!.

Rails basic app running with a message "Hello, PWA!"

The most basic action to turn your application installable is enabling the manifest.json. In two steps you can do that:

enable the routes for the manifest.json

Rails.application.routes.draw do
  get "up" => "rails/health#show", as: :rails_health_check

  # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
  # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest <--- Uncomment this line
  # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker

  # Defines the root path route ("/")
  # root "posts#index"
end

add the manifest.json to the application.html.erb file

<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<!-- Uncomment the tag below -->
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> 

Voilà! Your application is now installable. Open it in the browser and you will see a button to install it.

Clicking to install will prompt the user to install the app.

The information in this dialog comes from the manifest.json file. You can customize it to show your app’s name, icon, and other information.

Try changing some information.

In DevTools you should be able to see Manifest information under Application tab.

Running the app

After installation, you can open it as a standalone app. You can see it in the app drawer and do whatever you do with other applications.

From now on you can do a bunch of cool things. Things I will bring in the next articles. One of these cool things is this badge in the app drawer.

We can even have badges.

It’s great to see a smooth and nice integration with simple steps. Badges can be set using Badging API. The code looks like this:

navigator.setAppBadge(12);

Keep the server running, otherwise you will see a page like this:

This application is a PWA but not yet a good one; it does not work offline.

An offline-capable PWA

One of the main goals with PWAs is to have the application working offline at some level. For a better experience, even for features that require an internet connection, it’s good to have some level of offline support. Instead of showing a blank page or something like that, we can let the user know that the app is offline. Depending on the feature we can show a cached version of the content.

Service Worker

Roughly speaking, a Service Worker is a proxy. It exists between your application and the outside world. Every request and response goes through it.

Your brand new Rails app has a service-worker.js file under app/views/pwa/ folder. It’s not functional yet.

First, it must be exposed in the routes – the same as you did for manifest.json.

# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker <-- Uncomment this line

NOTE: The file is commented out but we will handle that soon.

The service worker is almost operational. You just need to register it.

// file: app/javascript/application.js
// Kept in application.js for simplicity. You can move it to a separate file if you want.
function registerServiceWorker() {
  console.log('Registering service worker...');

  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js').then((registration) => {
      console.log('Service worker registered:', registration);
    }).catch((error) => {
      console.error('Service worker registration failed:', error);
    });
  });
}

if ('serviceWorker' in navigator) {
  registerServiceWorker();
}

If your browser supports service workers — and it probably does — you will see a message in the console that says the service worker was registered. You can also see it in the DevTools under the Application tab.

NOTE: No matter how many times navigator.serviceWorker.register is called, it will only register the service worker once. Subsequent calls are no-ops.

Intercepting Requests

To intercept requests and responses you can use the fetch event. Delete the commented-out code, add the following to the service-worker.js file, and see what happens.

// file: app/views/pwa/service-worker.js

self.addEventListener('fetch', function(event) {
  console.log('Fetch event for ', event.request.url);

  return fetch(event.request);
});

You may notice it won’t work right away. You need to refresh the page to see the service worker in action. This happens because we already have a service worker registered. This update we just made will be in a state called waiting.

A service worker can have three states: installing, waiting, and active. The installing state is when the service worker is being installed. The waiting state is when a new service worker is waiting to be activated. The active state is when the service worker is ready to intercept requests.

To get the service worker transitioning from waiting to active you can:

  • close all tabs and reopen;
  • go to DevTools, Application > Service Workers, and click on Skip waiting – you can also check the Update on reload option;
  • or you can add a button to do that.

The last option is the most user-friendly. You can add a button to do that. This is a good practice because you can let the user decide when to update the service worker. We will see how to do that in the next article.

Whatever option you choose, once the service worker with the ‘fetch’ event is active you can see the console log in the DevTools.

If you look into the Network tab you will see the requests being intercepted by the service worker.

respondWith

The fetch event can be used to intercept requests and responses. You can use the respondWith method to respond with a custom response.

For example, you can respond with a straight Response.

self.addEventListener('fetch', function(event) {
  console.log('Fetch event for ', event.request.url);

  event.respondWith(
    new Response('Hello, PWA!')
  );
});

Try it and see what happens.

Caching

The Cache API provides a persistent storage for network requests. This key-value storage can be used to cache requests and responses.

Putting something in the cache

Caches live under a key. The following code creates a cache storage under ‘my-cache’ key.

caches.open('my-cache').then(async (cache) => {
  const request = new Request("https://d604h6pkko9r0.cloudfront.net/wp-content/uploads/2023/06/09093550/Codeminer42_VERTICAL-IMAGOTYPE_Negative.png");

  await cache.put(request.clone(), new Response('Hello, PWA!')); // Put in cache

  console.log('Cached!');

  const response = await cache.match(request); // Read from cache

  console.log('Response from cache:', response);
});

Execute the above code in the console to put a response in the cache.

In the DevTools you can see the cache under the Application tab.

It has other methods for cache management like add, addAll, delete, keys, match, and matchAll. For this
article’s purpose, we will use only put, match, and delete.

Deleting a cache is as simple as creating it.

caches.delete('my-cache').then(() => {
  console.log('Cache deleted!');
});

Responding with a cached version

You can now combine the fetch event with the cache to respond with a cached version of the request. Of course, there are a couple of ways to do that. I will show you that cache-first strategy.

In the cache-first strategy, you first try to get the response from the cache. If it’s not there you fetch it from the network.

const VERSION = 'v1'; // Version will be the key

async function cacheFirst(request) {
  const cache = await caches.open(VERSION);
  const cachedResponse = await caches.match(request);

  if (cachedResponse) {
    return cachedResponse;
  }

  try {
    const responseFromNetwork = await fetch(request.clone());

    cache.put(request, responseFromNetwork.clone());

    return responseFromNetwork;
  } catch (error) {
    return new Response('Network error happened', {
      status: 408,
      headers: { 'Content-Type': 'text/plain' },
    });
  }
}

self.addEventListener('fetch', function(event) {
    event.respondWith(cacheFirst(event.request))
});

This will cache every request in the v1 cache. If the request is not there, it will fetch it from the network.
Run this once and you will see entries in the cache.

Show cached requests

If you examine the Network tab you will see the requests being intercepted by the service worker.

This caching example was pretty simple. It aggregates all requests in a single cache. Of course, you can have multiple caches; YouTube, for example, has three caches holding assets.

Invalidating the cache

Bugs come, new features are requested, and you must update your application. This application though is not good at being updated. To confirm it, change the text in app/views/home/index.html.erb to something like Hello, PWA! v2.

No matter how much you refresh the page or skip waiting, the service worker will still serve the old content. This happens because the cache is still holding this previous content.

To fix this you must invalidate the cache. You can do that by changing the cache key in your service worker.

const VERSION = 'v2'; // Version will be the key

Now the service worker will create a new cache and serve the new content. In DevTools you can see the new cache but also the old one.

If you want to delete the old cache you can do it in the service worker doing something like this:

self.addEventListener('activate', function(event) {
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          if (cacheName !== VERSION) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

The activate event is triggered when the service worker is activated. At this point, you can delete the old caches.

The code above walks through all caches and deletes the ones that are not the current version.

The waitUntil method keeps the service worker alive until the promise is resolved.

Add this to your service worker and notice that the only cache left is the current one.

BONUS: Letting the user know the app is offline

This whole discovery on fetch started because we realized that if the user goes offline the app would show the browser’s offline page. This is not a good experience.

It’s no longer a problem now. The service worker you just created, following this article, caches pages. Even if the user is offline the app will show the cached page.

As a user, it’s good to know that the app is offline. We will add a simple banner to let them know when it happens.

In app/views/layouts/application.html.erb add the following code:

<header class="bg-zinc-500 hidden" data-controller="offline">
    <p class="text-white text-center py-2">You are offline - Content may be outdated</p>
</header>

In app/javascript/controllers/offline_controller.js add the following code:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.updateStatus()

    window.addEventListener('online', this.updateStatus.bind(this))
    window.addEventListener('offline', this.updateStatus.bind(this))
  }

  disconnect() {
    window.removeEventListener('online', this.updateStatus.bind(this))
    window.removeEventListener('offline', this.updateStatus.bind(this))
  }

  updateStatus() {
    this.element.classList.toggle('hidden', navigator.onLine)
  }
}

That’s it. You have a Stimulus controller that toggles the banner visibility based on the online and offline events.

Testing this is very simple. Just turn off your network and the banner will show up. Turn it back on and the banner will disappear. You can also use DevTools to simulate offline mode.

Keep in mind that navigator.onLine is not a reliable way to check if the browser is connected to the internet. It only checks if the browser is connected to a local area network (LAN) or a router. You should develop additional ways to check the online status.

Conclusion

We covered a lot of ground in this article. You learned how to quickly turn your Rails application into a PWA that works offline by handling caches and some service worker lifecycle events.

There’s more to come. In the next articles, I will show more great APIs you can use to build a better PWA. Stay tuned!

References

We want to work with you. Check out our "What We Do" section!

Edigleysson Silva

I own a computer

View all posts by Edigleysson Silva →