Blog · Guides

The service worker in push notifications: what it does and how to set it up

What the service worker does when a push arrives, where it must live, how to add it with one line (Workbox included), which cache headers to use and how to debug it.

Leer en español

The service worker is the piece that lets your site receive push notifications while it's closed: the browser wakes it when a message arrives, it shows the notification, and it opens the right page when the user taps it. You don't need to write that logic yourself, but you do need to place it correctly: on your own domain, over HTTPS, without competing with another worker, and with headers that let updates through.

In short:

  • The service worker receives pushes even with no tab open, shows the notification and handles the click.
  • It must be served from the same origin as your site, over HTTPS and, unless you have a reason not to, from the root, because its scope is the folder it lives in.
  • A one-line /castly-sw.js file is enough, or that same line at the top of the service worker you already have, Workbox included.
  • Serve it with Cache-Control: no-cache and never change its URL between deploys.
  • Chrome, Firefox and Safari let you inspect it; Chrome and Firefox can also simulate a push.

What the service worker does for push notifications

A service worker is a script the browser runs apart from your pages, with no DOM access: it starts it when there's an event to handle and stops it once it goes idle. That's exactly what push needs, because messages don't wait for the user to have your site open. For the full journey of an alert, see what Web Push is and how it works.

For notifications it has three jobs:

  1. Receive the push. The browser wakes your site's worker, even with no tab open, and hands it the already-decrypted content.
  2. Show the notification with the alert's title, body, icon and image. Browsers require every push to end in a visible notification: when none appears, Chrome shows a generic notice and Safari may drop the subscription.
  3. Handle the click: close the notification and open the alert's page, or focus the tab if it's already open.

With castly you don't write any of that. The script you import shows the notification, opens the alert's url on click (reusing a tab of your site when there is one) and confirms delivery and clicks so they show up in your logs.

Where it must live: origin, HTTPS, root and scope

  • Same origin. Browsers only register service workers served from the page's own origin. That's why the file lives on your site and, from inside, loads the code it needs with importScripts().
  • HTTPS. It requires a secure context. localhost is fine for development; your computer's IP over plain HTTP, for testing from a phone, isn't: use an HTTPS tunnel.
  • Scope. By default a service worker controls the folder it sits in and everything below it: /castly-sw.js covers the whole site, while /assets/castly-sw.js would stop at /assets/. Widening it from a subfolder requires the Service-Worker-Allowed header, as MDN explains in ServiceWorkerContainer.register(). The root is the simple choice.
  • A JavaScript MIME type. If your hosting answers every unknown path with your SPA's index.html, registration fails.

Add it with one line, Workbox included

If you have no service worker

Create /castly-sw.js in the folder your project serves as is from the root (public/ in Vite, Next.js or Nuxt) with this line:

importScripts('https://castly.sh/sdk/v1/sw.js');

You don't need to register it: the SDK does that when the user subscribes. The remaining steps are in the quickstart.

If you already have one

Don't add a second worker; put the line at the top of yours:

// sw.js
importScripts('https://castly.sh/sdk/v1/sw.js');

// …the rest of your service worker (caching, Workbox, etc.)

Then tell the SDK where it is:

Castly.init({ publicKey: 'pk_live_…', swPath: '/sw.js' });

swPath is the script's path and swScope its scope. Pass swScope only if you register your worker with a scope other than its folder, such as /app/, and use the same value as your own registration so the SDK reuses that worker. The imported script only handles its own alerts, leaves everything else alone, and never forces activation or takes control of open pages, so it doesn't change how your worker updates.

With Workbox

If you write your worker with Workbox (injectManifest), add the line at the top, as above. If Workbox generates it (generateSW, including through plugins such as vite-plugin-pwa), don't edit the file, since every build overwrites it: use its importScripts option.

// workbox-config.js (or your plugin's workbox option)
module.exports = {
  globDirectory: 'dist/',
  globPatterns: ['**/*.{html,js,css}'],
  swDest: 'dist/sw.js',
  importScripts: ['https://castly.sh/sdk/v1/sw.js'],
};

One catch: importScripts() only exists in classic service workers. If you register yours with type: 'module', the line will fail.

Cache headers so updates get through

Browsers look for a new version of the service worker as the user navigates your site, and when an event such as a push arrives if they haven't checked in the last 24 hours. By default they bypass the HTTP cache for the registered file, but not your CDN's cache. Two rules:

  • No caching. Serve the file with Cache-Control: no-cache, at the CDN too, so any change, like adding the import line, arrives on the next visit.
  • Always the same URL. No hashed names like sw.3f9a1c.js: an old cached page would keep registering the old URL, and you'd need to update the worker in order to update the worker. web.dev covers this in the service worker lifecycle.

On Cloudflare Pages, Workers Static Assets or Netlify, a _headers file does it:

/castly-sw.js
  Cache-Control: no-cache

Debugging it in Chrome, Firefox and Safari

  • Chrome and Edge. In DevTools, Application panel, Service workers section, you see the active script, its status and its scope. Tick "Update on reload" while developing. The Push field fires a push event without a server, and Background services records pushes and notifications.
  • Firefox. In about:debugging, under This Firefox, you'll find your service workers with buttons to inspect them and send them a test push.
  • Safari. Turn on the web developer features in Settings > Advanced and pick your site under Develop > Service Workers. For a site installed on an iPhone, connect the device to your Mac and choose it in that menu. Safari can't simulate a push.

One nuance: the imported script ignores messages that don't match its alert format, so a simulated push from DevTools tells you the worker wakes up, not what the notification looks like. For a full test, subscribe your browser from the dashboard Playground (it becomes a test device) and send it a push, or send to { "type": "test" } from your backend.

Common mistakes

  • The line inside an event. importScripts() belongs at the top level of the file: inside install or push it fails.
  • Two workers at the root. Your PWA registers /sw.js and the SDK registers /castly-sw.js, both with scope /: they replace each other on every visit. Merge them into one and use swPath.
  • Wrong path. If there's no file at swPath, Castly.subscribe() fails with the service_worker code; listen for it with Castly.on('error', …).
  • "Unregister" or "Clear site data" in DevTools. Removing the worker also removes that browser's push subscription, so you'll have to subscribe again.

Frequently asked questions

Can I have two service workers on the same site?

You can register several with different scopes, such as /app/ and /blog/, but not two for the same scope, because the last one replaces the previous one. If a library ships its own service worker, load its code with importScripts() inside yours.

Is the service worker always running?

No. The browser starts it to handle an event and stops it once idle, sometimes within seconds, so anything that must survive between events belongs in IndexedDB or the Cache API, not in global variables.

How does it update when I change the code?

The browser installs the new version once it detects the change, but that version waits until the tabs using the old one are closed, unless your worker calls self.skipWaiting().

Does the service worker work on iPhone?

Yes, but push only reaches sites added to the Home Screen, since iOS 16.4; a regular Safari tab gets no push. Castly.needsInstall() tells you when to guide the user through installing it.

Put it into practice with castlyWeb and PWA push notifications for developers: an SDK under 3 KB, a REST API and delivery logs for every device. Free up to 10,000 pushes a month.
Create a free account →

Send your first push notification today.

Create a project, add the SDK and send with one API call. Free to start.

Create a free account →