# serviceworker-rails gives a Rails service worker a scope and headers it can keep

> serviceworker-rails is a Ruby gem that maps service worker endpoints onto Rails assets and serves them with headers that let a browser update them, working around the asset pipeline's fingerprinting and long cache lifetimes. It generates the service worker script, the registration snippet, a manifest and an offline page, then leaves the caching strategy to you.

**rossta/serviceworker-rails** — Use Service Worker with the Rails asset pipeline

- Repository: https://github.com/rossta/serviceworker-rails
- Stars: 584 · Forks: 51
- Language: Ruby
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/rossta-serviceworker-rails

## Why the asset pipeline is the obstacle, not the platform

The README frames this as a conflict between two sets of assumptions, and the conflict is specific rather than general. The Rails asset pipeline makes a number of assumptions about what's best for deploying JavaScript, including asset digest fingerprints and long-lived cache headers, mostly to increase cacheability. It also assumes a single parent directory, /public/assets, to make it easier to look up the file path for a given asset.

Service worker assets must play by different rules, and the README gives two concrete reasons.

The first is scope. Service workers may only be active from within the scope from which they are served. Register a worker from a pipeline path such as /assets/serviceworker-abcd1234.js and it can only interact with requests and responses within /assets. A worker that cannot see the rest of the origin cannot cache the pages a user actually visits.

The second is updating. The README cites MDN on the point that browsers check for updated service worker scripts in the background every 24 hours, possibly less. A fingerprinting strategy means the asset at a given URL is immutable, so the browser's update check finds the same bytes it already has. The Cache-Control headers Rails sends for static files work against the browser's treatment of service workers in the same direction: a long-lived cache header tells the browser not to look.

Neither of these is a browser bug or a Rails bug. They are two independently reasonable designs that collide, and the gem's job is to route around the collision rather than to argue with either side.

## What the generator writes, and the one step it cannot do for you

Installation starts with a Gemfile line:

```ruby
gem 'serviceworker-rails'
```

Then either bundle the gem in or install it directly:

```bash
    $ bundle
```

```bash
    $ gem install serviceworker-rails
```

The automated setup is a single generator run from the root of the Rails project:

```bash
$ rails g serviceworker:install
```

The generator creates five files. There is config/initializers/serviceworker.rb for configuring the app, app/assets/javascripts/serviceworker.js.erb which the README describes as a blank Service Worker script with some example strategies, app/assets/javascripts/serviceworker-companion.js holding the JavaScript needed to register the worker in the browser, app/assets/javascripts/manifest.json.erb as a starter web app manifest pointing to default app icons provided by the gem, and public/offline.html as a starter offline page.

It also edits two existing files. It adds serviceworker.js and manifest.json to the list of compiled assets in config/initializers/assets.rb, and it injects tags into the head of app/views/layouts/application.html.erb for linking to the web app manifest.

What it cannot do is decide where the worker script lives in your bundle, and the README is explicit that you still have to perform this task manually because of the variety of possible JavaScript implementations. You must require the companion script in your JavaScript entry point. With Sprockets that means adding //= require serviceworker-companion in app/assets/javascripts/application.js. With Importmaps and the app/javascript directory, you move the generated files from app/assets/javascripts to app/javascript and add import 'serviceworker-companion' to app/javascript/application.js.

The same passage carries a warning that is easy to skim past and expensive to ignore. Because a service worker operates in a separate browser thread, outside the context of your web pages, you do not want serviceworker.js included in your application.js. If your entry point has a require_tree . line, you either move serviceworker.js elsewhere or replace require_tree with something more explicit.

## The worker script itself: the asset_path helper and a hand-written cache policy

The manual setup shows what the generated worker looks like, and it is a plain ERB-templated JavaScript file rather than a framework.

```javascript
// app/assets/javascripts/serviceworker.js.erb
console.log('[Service Worker] Hello world!');

var CACHE_NAME = 'v1-cached-assets'

function onInstall(event) {
  event.waitUntil(
    caches.open(CACHE_NAME).then(function prefill(cache) {
      return cache.addAll([
        '<%= asset_path "application.js" %>',
        '<%= asset_path "application.css" %>',
        '/offline.html',
        // you get the idea ...
      ]);
    })
  );
}

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('v1') !== 0;
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    })
  );
}

self.addEventListener('install', onInstall)
self.addEventListener('activate', onActivate)
```

The ERB tag is the point of using a pipeline at all. '<%= asset_path "application.js" %>' resolves to the fingerprinted path at compile time, so the cache is populated with the current digest of the asset without the worker script hard-coding a hash that changes on every deploy. You still write the cache contents yourself; the gem only gives you the asset path resolution.

The activate handler is a cache eviction routine, and it contains a subtlety the comment flags: caches are shared across the whole origin. Deleting by prefix can therefore remove caches that belong to another feature or another worker on the same site. The example deletes any cache name that does not start with v1.

For production, the README says to instruct Sprockets to precompile service worker scripts separately from application.js, and the manual setup section shows an example of that configuration, although the excerpt the README gives ends before the configuration file itself is shown.

## Registering the worker from the page, and the scope argument that does the work

The companion script is what the page uses to register the worker, and it is short enough to read as the whole contract between your application and the worker.

```javascript
// app/assets/javascripts/serviceworker-companion.js

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

Two details carry the weight. The URL is /serviceworker.js, at the root of the origin rather than under /assets, and the scope option is './'.

The root URL is the first half of the fix for the scoping problem. A worker served from /serviceworker.js defaults to a scope of /, which is what lets it see the pages a user visits. The explicit scope option is what the gem's header and endpoint mapping machinery preserves, because a service worker response has to carry the header that lets it control that scope.

The feature detection with if (navigator.serviceWorker) is the conventional way to keep the registration call from throwing in a browser without service worker support. The promise handler then logs that registration succeeded.

One gap worth noting: the README does not document an update path. There is no mention of how to prompt the user that a new worker is waiting, of a skipWaiting call, or of how the client is told to reload once a new worker takes over. The generated worker in the example handles install and activate and nothing else, so a service worker update will wait for every tab using the old worker to close before it activates. That may be exactly what you want, but it is a decision the README leaves to you rather than one it makes.

## A gem that maps endpoints rather than a framework for offline apps

The feature list is three items and worth quoting in full because it sets expectations: it maps service worker endpoints to Rails assets, it adds appropriate response headers to service workers, and it renders compiled source in production and development.

That last clause is the difference between this and a hand-rolled setup. Compiled source is rendered in development as well as production, which means the ERB in serviceworker.js.erb resolves to real asset paths on a local machine rather than to a placeholder. A hand-rolled solution often works in production and silently breaks in development, because the developer environment has no fingerprinted assets to point at.

The scope of the gem is deliberately narrow. It does not provide a caching strategy, a precache manifest builder, a background sync helper, or an update-notification mechanism. What it provides is the plumbing that lets the Rails asset pipeline compile a service worker script at all, and serves it in a way the browser will accept and keep current.

The project layout supports that reading. There is a lib directory, a test directory, a gemfiles directory for alternative dependency sets, a serviceworker-rails.gemspec, a Gemfile and Gemfile.lock, a Rakefile, a Guardfile, and the usual configuration files including .rubocop.yml and .standard.yml for two linters, plus a .ruby-version and a .node-version file. There is a CHANGELOG.md and a CODE_OF_CONDUCT.md, and the repository has no GitHub releases.

The primary language is Ruby, the licence is MIT, the default branch is main, and the last push was on 2026-09-03. The repository topics are javascript, rails, rails-asset-pipeline, service-worker and sprockets, which is an accurate summary of the dependency surface: this gem sits at the intersection of the Rails asset pipeline and a browser API, and it has no opinion about anything else in your application.

## Where this gem is the wrong answer

Three limits, and the first is a hard dependency on Sprockets.

The first is the asset pipeline coupling. Everything the gem does depends on Sprockets compiling the worker and on the Rails pipeline's precompile list, and the README's manual setup explicitly assumes a Rails application using the asset pipeline built on Sprockets. An application that has moved to Propshaft, to jsbundling with a bundler, or to a fully separate front-end build has no use for it, because the two problems the gem solves, asset path resolution and pipeline precompilation, are already solved by something else in that setup.

The second is the manual work the generator cannot avoid. The registration snippet has to be wired into your entry point by hand, and the interaction with require_tree has to be resolved by hand, and the caching strategy is entirely yours. The README is candid that the variety of possible JavaScript implementations is why it cannot automate that step. So the realistic adoption is a hybrid: you take the headers, the endpoint mapping and the generator's file scaffolding, and you write the worker logic yourself. If you were expecting a turnkey offline app, the amount you still write is large.

The third is the update story. The gem fixes the problem of the browser never seeing a new worker because of fingerprinting and long cache lifetimes, and once that is fixed the familiar service worker lifecycle applies: a new worker waits, and it activates when the old one is no longer in use. The README does not document a way to notify open tabs, and the example worker has no fetch handler at all, so the caching behaviour you will eventually ship is code you write from scratch against the Cache API.

The alternative approach is to serve the worker from a route that your own controller or a static file handler owns, outside the asset pipeline entirely, and to write the registration call yourself. The difference in approach is that this gem reuses the pipeline for asset path resolution and precompilation, which saves you from hard-coding fingerprinted URLs and from editing the precompile list, at the cost of a hard Sprockets dependency and a generator that touches several of your files. For a Sprockets app that is a small, contained win. For anything else, serving one static file from a route is less machinery than depending on a gem.

## Conclusion

Adopt serviceworker-rails if you have a Rails app on the Sprockets asset pipeline and want a service worker at the site root rather than trapped under /assets, since the gem exists precisely to defeat the pipeline's fingerprinting and cache headers. Do not adopt it for an app that already uses a different asset pipeline, because the gem's whole mechanism is Sprockets precompilation, and do not expect it to write your caching strategy, since the generated worker contains only example strategies. Verify first that your own worker script is served from a path the browser treats as root-scoped, that the precompile list in config/initializers/assets.rb includes the worker and manifest, that you have removed serviceworker.js from the require_tree in your JavaScript entry point, since a service worker runs outside the page context, and that the gem version you pin still matches your Rails version.

## FAQ

### How do I install serviceworker-rails in a Rails app?

Add gem 'serviceworker-rails' to your Gemfile and run bundle, or install it with gem install serviceworker-rails. Then run rails g serviceworker:install from the root of your Rails project to create the initializer, worker script, companion script, manifest and offline page.

### Why does my service worker not control my whole site?

A service worker may only be active from within the scope it is served from, so a worker registered from an asset pipeline path such as /assets/serviceworker-abcd1234.js can only handle requests within /assets. The gem serves the worker from a root path with a scope option so it can cover the origin.

### Why does the browser never pick up my updated service worker?

Rails asset fingerprints make the file at a given URL immutable, and browsers check for updated service worker scripts in the background roughly every 24 hours, so the fingerprinting and long-lived cache headers work against the update check. serviceworker-rails adds response headers to the worker so the browser can see a change.

### Do I still have to write the caching logic myself with serviceworker-rails?

Yes. The generated app/assets/javascripts/serviceworker.js.erb is described as a blank Service Worker script with some example strategies, and the example shows an install handler that prefills a cache and an activate handler that evicts old caches. The gem handles endpoints, headers and compilation, not the caching policy.

### What should I do about serviceworker.js appearing in application.js?

Remove it. A service worker operates in a separate browser thread outside the context of your web pages, so if your entry point uses require_tree . you should move serviceworker.js to another location or replace require_tree with something more explicit, and require only serviceworker-companion instead.

### Does serviceworker-rails work with Importmaps and the app/javascript directory?

The README describes the manual step: move the generated files from app/assets/javascripts to app/javascript and add import 'serviceworker-companion' to app/javascript/application.js. The generator cannot do this for you because of the variety of possible JavaScript implementations.

## Sources

- [Issues](https://github.com/rossta/serviceworker-rails/issues)
- [License: MIT](https://github.com/rossta/serviceworker-rails/blob/main/LICENSE)
- [README](https://github.com/rossta/serviceworker-rails/blob/main/README.md)
- [rossta/serviceworker-rails on GitHub](https://github.com/rossta/serviceworker-rails)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/rossta-serviceworker-rails
