Skip to content
Developers · 9 min read

Service worker push events, done right

The service worker is where a push becomes a notification. Get the push and notificationclick handlers right and everything else — images, buttons, deep links — follows.

Updated By the Pusharoo team
In short
  • Always wrap work in event.waitUntil() so the browser keeps the worker alive.
  • Every push must show a notification — Chrome and Safari require it.
  • In notificationclick, focus an existing tab when possible before opening a new one.
  • Keep sw.js uncached at your CDN so fixes reach browsers quickly.

The push event

self.addEventListener('push', event => { let data = {}; try { data = event.data ? event.data.json() : {}; } catch { data = { title: 'Update', body: event.data && event.data.text() }; } event.waitUntil(self.registration.showNotification(data.title || 'Update', { body: data.body || '', icon: data.icon || '/icon-192.png', image: data.image, // Chrome/Edge on Windows, Android badge: '/badge-96.png', // Android status bar tag: data.tag, // same tag replaces an older notification renotify: !!data.tag, data: { url: data.url || '/' } })); });

If the promise passed to waitUntil rejects or no notification is shown, Chrome may show a generic “site updated in the background” notice and Safari may revoke the subscription.

notificationclick: open or focus the right tab

self.addEventListener('notificationclick', event => { event.notification.close(); const target = new URL(event.notification.data.url, self.location.origin).href; event.waitUntil((async () => { const tabs = await clients.matchAll({ type: 'window', includeUncontrolled: true }); for (const tab of tabs) { if (tab.url === target && 'focus' in tab) return tab.focus(); } return clients.openWindow(target); })()); });

Action buttons

// when showing: actions: [{ action: 'track', title: 'Track order' }, { action: 'help', title: 'Get help' }] // when clicked: self.addEventListener('notificationclick', event => { const urls = { track: '/orders/42', help: '/support' }; const url = urls[event.action] || event.notification.data.url; // '' = main body tap event.notification.close(); event.waitUntil(clients.openWindow(url)); });

Buttons show in Chromium browsers only; see rich push for support.

pushsubscriptionchange

Browsers may rotate or expire a subscription. Some fire pushsubscriptionchange in the worker so you can resubscribe and tell your server. Support is uneven, so also re-check pushManager.getSubscription() on page loads and upsert it server-side. Your sender should delete any subscription that returns 404 or 410 (deliverability).

Updates and caching

  • Register with updateViaCache: 'none' so the browser always revalidates sw.js.
  • Send Cache-Control: no-store (or a very short max-age) for sw.js; exclude it from CDN and plugin caches.
  • Use self.skipWaiting() and clients.claim() only if you understand the effect on open tabs.
  • Only one worker can control a scope. Merge push into an existing worker with importScripts() (docs).

Always show a notification

Subscriptions are created with userVisibleOnly: true, a promise that every push results in a visible notification. If your push handler finishes without showing one, Chrome may display a generic “This site has been updated in the background” message, and repeated silent pushes can count against you. Wrap the work in event.waitUntil() and make sure every code path — including errors and unexpected payloads — ends in showNotification().

self.addEventListener('push', e => { e.waitUntil((async () => { let d = {}; try { d = e.data ? e.data.json() : {}; } catch { d = { title: 'New update', body: e.data?.text() }; } await self.registration.showNotification(d.title || 'New update', { body: d.body || '', icon: '/icon-192.png', data: { url: d.url || '/' } }); })()); });

Collapse, replace and re-alert

OptionEffect
tagA new notification with the same tag replaces the old one instead of stacking
renotify: trueWith a tag, makes the replacement alert again (sound/vibration)
requireInteractionKeeps it on screen until dismissed (desktop Chrome/Edge); use sparingly
silentNo sound or vibration
timestampShows when the event happened, not when it arrived

Tags are ideal for “3 new messages” style updates and live scores.

Measuring clicks from the worker

The worker can report a click before opening the page, which is more reliable than counting landing-page hits. Use fetch() with keepalive, inside waitUntil, and don’t block opening the window on the request:

self.addEventListener('notificationclick', e => { e.notification.close(); const { url, id } = e.notification.data || {}; e.waitUntil(Promise.all([ fetch('/push/click', { method: 'POST', body: JSON.stringify({ id, action: e.action }), keepalive: true }).catch(() => {}), clients.openWindow(url || '/'), ])); });

Common pitfalls

  • Forgetting event.waitUntil — the worker is killed before the notification shows.
  • Fetching a slow API before showing the notification — show first, or keep it fast.
  • Relative URLs in clients.openWindow resolving against the worker path.
  • Serving sw.js with a non-JavaScript MIME type or through an HTML redirect.

Questions

Can a service worker receive push when the browser is closed?

On Android, yes. On desktop, the browser process must be running (it can run in the background). Messages wait at the push service until then, within their TTL.

Can I do a silent push without a notification?

Not on the web. Browsers require userVisibleOnly: true, so each push must show something.

Where should sw.js live?

At the root of your domain (https://yoursite.com/sw.js) so its scope covers every page.

Why does my service worker show the notification twice?

Usually two workers are handling push (two registrations on overlapping scopes) or both your code and an imported library call showNotification. Check DevTools → Application → Service workers and keep one worker per scope.

Can a service worker update the page when a push arrives?

Yes. Use clients.matchAll() in the push handler and postMessage() to open tabs, in addition to showing the notification.

More in Developers: VAPID keys for web push, explained · The Web Push protocol, explained · Web push notifications with Node.js · Web push notifications in PHP · Laravel browser push notifications (no Firebase) · Web push notifications in React and Next.js · Firebase web push vs. standard web push · Web push in Python with pywebpush · Django push notifications to the browser · Web push notifications in Ruby on Rails · Web push in Go with webpush-go · Angular push notifications with SwPush · Push notifications in Vue 3 and Nuxt · Push notifications in SvelteKit · All guides

Free for life. No limits.

Unlimited subscribers, campaigns and automations. No badges. About five minutes to install.