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!
.
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 alsocheck
theUpdate 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.
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!