Updated September 2026
A service worker is a script the browser runs in the background, separately from your pages. It sits between your app and the network, so it can answer requests itself: serve files from a cache, show an offline page when the connection drops, or receive push notifications. It has no access to the DOM, and it isn't the same thing as a web worker, which runs heavy JavaScript off the main thread for a single page.
We first published this in 2020, when adding a service worker to Rails meant writing your own controller. Since Rails 7.2, new apps come with the pieces already in place. This version covers both.
How a service worker works
Four events do most of the work. Your page registers the worker, the worker installs and caches what it needs, it activates and cleans up after older versions, and from then on it sees every request the page makes.
Two rules shape everything else. Service workers only run in a secure context, so you need HTTPS in production (localhost is allowed for development). And a worker can only control pages inside its scope, which by default is the folder the script is served from. Serving it from the site root, as /service-worker.js, lets it cover the whole app.
Rails 7.2 and later: use the built-in PWA files
New Rails apps generate app/views/pwa/service-worker.js and app/views/pwa/manifest.json.erb, plus a Rails::PwaController that renders them. Because they're views, you can use ERB in them. In Rails 8 the routes ship commented out in config/routes.rb; uncomment them:
get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
If you want the app to be installable, also uncomment the manifest link in app/views/layouts/application.html.erb:
<%= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
If you're upgrading an older app, you can add the two view files and routes yourself: the controller is part of Rails.
Write the worker
The generated worker is only commented-out examples. Here's a small one that caches an offline page on install, clears old caches on activate, and serves the offline page when a page load fails:
// app/views/pwa/service-worker.js
const CACHE = "offline-v1";
const OFFLINE_URL = "/offline.html";
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.add(OFFLINE_URL))
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))
)
)
);
});
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(
fetch(event.request).catch(() => caches.match(OFFLINE_URL))
);
});
Put a plain, self-contained page at public/offline.html, with its styles inline so it doesn't depend on anything else being cached. When you change what the worker caches, bump the cache name (offline-v2) so the activate step clears the old one.
Register it
Rails doesn't register the worker for you. Add this to app/javascript/application.js:
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/service-worker.js", { scope: "/" });
});
}
Older Rails apps: a small controller
Before 7.2, the approach from our original post still works: a controller action that renders a JavaScript view at the root path.
# app/controllers/service_worker_controller.rb
class ServiceWorkerController < ApplicationController
skip_forgery_protection
def show
render template: "service_worker/show", layout: false,
content_type: "application/javascript"
end
end
# config/routes.rb
get "/service-worker.js" => "service_worker#show"
Put the worker code above in app/views/service_worker/show.js.erb. Because it's ERB, you can list fingerprinted assets to precache with helpers such as asset_path. The original version of this post was missing a fetch handler, so it cached the offline page but never served it. The example above fixes that.
Updates and development
-
How updates arrive. The browser checks for a new version of the script when pages load. If the file has changed by even a byte, the new worker installs, then waits until every tab using the old one is closed. Call
self.skipWaiting()in the install handler if you want it to take over sooner. - Turn it off while you work. In Chrome DevTools, open Application → Service workers and tick Update on reload or Bypass for network, or click Unregister. Otherwise you'll be debugging yesterday's cached code.
- Test offline properly. Use DevTools' offline mode, then reload. If you use Turbo, test a link click as well as a full reload, because Turbo loads pages with fetch requests rather than browser navigations.
Common questions
Do I need a gem for this?
Not on Rails 7.2 or later. The built-in PWA controller serves the files. Older apps can use a small controller like the one above.
Is a service worker the same as a background job?
No. Service workers run in the visitor's browser. Background jobs, such as those run by Active Job, run on your server.
Why isn't my service worker registering?
Usually it's served over plain HTTP, from a path that gives it too narrow a scope, or with the wrong content type. Check the Console and the Application panel in DevTools for the error.
Building a Shopify app or a store integration in Rails and want a second opinion on performance? Read why a Shopify app can slow down a store, or see when to use an app, custom code, or something in between. Mozilla's guide to using service workers goes deeper on caching strategies.