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.
- 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 revalidatessw.js. - Send
Cache-Control: no-store(or a very short max-age) forsw.js; exclude it from CDN and plugin caches. - Use
self.skipWaiting()andclients.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
| Option | Effect |
|---|---|
tag | A new notification with the same tag replaces the old one instead of stacking |
renotify: true | With a tag, makes the replacement alert again (sound/vibration) |
requireInteraction | Keeps it on screen until dismissed (desktop Chrome/Edge); use sparingly |
silent | No sound or vibration |
timestamp | Shows 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.openWindowresolving against the worker path. - Serving
sw.jswith 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.
Keep reading
The Web Push API in JavaScript: a working example
Register a worker, ask on click, subscribe with VAPID, store the subscription and send — complete code.
Rich push notifications: images, icons and buttons
What images, icons, badges and action buttons show in each browser, with recommended sizes.
How to test web push notifications
A live demo, DevTools push buttons, sending to yourself, iPhone checks and a pre-launch checklist.
Declarative Web Push, explained
The JSON payload format, Apple platform support, how it falls back elsewhere and when to adopt it.
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.