You implement web push. It works for weeks, then your delivery rate quietly drops. Subscriptions that were fresh three months ago return 404 or 410 from the push service when you try to dispatch. New subscriptions still work; the old ones are dying. Nothing in your code changed.
This is the pushsubscriptionchange event firing, and your service worker isn't handling it. Push services rotate endpoint URLs periodically for security and infrastructure reasons; when they do, the browser fires a pushsubscriptionchange event that gives you exactly one chance to grab the new subscription details. Miss it, and the user's subscription is gone forever (unless they re-subscribe manually, which they will not).
This is a step-by-step guide to building a pushsubscriptionchange handler that keeps your subscription pool healthy in production.
Step 1: understand what triggers the event
The pushsubscriptionchange event fires when:
- A push service rotates its endpoint URL (most common).
- A push service's encryption keys change (rarer).
- The browser invalidates a subscription for any other reason (browser bug, internal cleanup, deliberate user action like clearing site data, in some implementations).
The event does NOT fire when:
- A user manually revokes notification permission (you have to detect this separately by checking
Notification.permission). - A user clears all browser data (the subscription is gone, no event, you find out next dispatch).
- An OS-level uninstall of the PWA happens.
The event is documented in the MDN reference for pushsubscriptionchange. It is a ServiceWorker event, so it fires in the service worker context, not in the main page.

Photo by panumas nikhomkhai on Pexels
Step 2: write the basic handler
The minimum viable handler resubscribes the user with the same VAPID public key and sends the new subscription to your server:
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
handleSubscriptionChange(event)
);
});
async function handleSubscriptionChange(event) {
const newSubscription = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey,
});
await fetch('/api/push/resubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oldEndpoint: event.oldSubscription?.endpoint,
newSubscription,
}),
});
}
Two things to notice:
-
event.waitUntil()is required. The handler does async work; withoutwaitUntil, the service worker may terminate before the resubscribe completes. This is the same lifecycle issue that causes push notifications to not render when handlers forgetwaitUntil. -
Pass both endpoints to the server.
event.oldSubscription?.endpointtells the server which subscription is being replaced. Without it, the server has to do a fuzzy match against user agent and last-active timestamps, which is slow and unreliable.
Step 3: handle the vapidPublicKey lookup
The handler above references vapidPublicKey, but a service worker doesn't have access to your main app's environment variables or store. You have three options:
Option A: embed the key in the service worker file. Build the SW from a template at deploy time, replacing a placeholder with the actual key. Works but couples your build pipeline to the SW.
Option B: store the key in IndexedDB during initial subscription. The main app saves the public key when subscribing; the SW reads it from IndexedDB during pushsubscriptionchange.
Option C: fetch the key from your server during the change handler. The SW calls /api/push/vapid-public-key before resubscribing.
Option B is the cleanest in our experience. It avoids build-time templating and doesn't depend on the network during the change handler:
async function handleSubscriptionChange(event) {
const db = await openDb('push-config');
const vapidPublicKey = await db.get('config', 'vapidPublicKey');
const newSubscription = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey,
});
await fetch('/api/push/resubscribe', { /* ... */ });
}
The main app's subscription flow should store the public key:
async function subscribeUser() {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
});
const db = await openDb('push-config');
await db.put('config', VAPID_PUBLIC_KEY, 'vapidPublicKey');
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
});
}
Step 4: handle the server-side update correctly
When the server receives the resubscribe POST, the cleanest approach is to UPDATE the existing subscription row by oldEndpoint, not to DELETE old + CREATE new:
UPDATE push_subscriptions
SET endpoint = ?, p256dh_key = ?, auth_key = ?, updated_at = NOW(), failure_count = 0
WHERE endpoint = ? AND user_id = ?
If the UPDATE affects zero rows (the old subscription wasn't found), fall back to INSERT. This handles edge cases like the user clearing site data and re-subscribing fresh.
The DELETE-then-INSERT approach loses associated metadata (notification preferences, topic subscriptions, custom user data attached to the subscription). UPDATE preserves it.
Step 5: defend against re-subscription failures
pushManager.subscribe() can fail. The user could have revoked notification permission since they originally subscribed; the push service could be unreachable; the browser could have an internal issue. Build the handler to fail gracefully:
async function handleSubscriptionChange(event) {
try {
const db = await openDb('push-config');
const vapidPublicKey = await db.get('config', 'vapidPublicKey');
const newSubscription = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey,
});
await fetch('/api/push/resubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oldEndpoint: event.oldSubscription?.endpoint,
newSubscription,
}),
});
} catch (err) {
// Subscribe failed - report to your server so you can mark the subscription dead
await fetch('/api/push/resubscribe-failed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oldEndpoint: event.oldSubscription?.endpoint,
error: err.message,
}),
});
}
}
The server-side /api/push/resubscribe-failed should mark the subscription as inactive but not immediately delete it. Some failures are transient; the subscription might recover on the next change event.

Photo by Mathias Reding on Pexels
Step 6: monitor the resubscription rate
This is where most teams stop, and where the operational issues start.
You should log:
- How many
pushsubscriptionchangeevents your service worker handles per day. - How many succeed (re-POST to the server returns 200).
- How many fail (re-POST returns non-200 or throws).
- The distribution of "subscription age" at the time of the change event.
Without these numbers, you can't tell whether the system is working. A healthy production push system might see 0.5% to 2% of subscriptions hit pushsubscriptionchange per month. If that number is 10%, something upstream is wrong (push service issue, your VAPID keys changed, browser bug).
The full operational discipline around push, including subscription lifecycle, failure handling, and the analytics that surface these numbers, is in our deeper article on how to add web push notifications to a PWA. At 137Foundry, we've built and maintained this kind of lifecycle handling for clients across e-commerce and B2B SaaS, and the most common failure mode is teams who ship the initial subscription flow but never wire up the change handler.
Step 7: handle the case where the user is offline
The pushsubscriptionchange event can fire while the user is offline. The subscribe call might work (the browser caches the operation), but the fetch to your server will fail.
The pattern is to queue the resubscribe POST in IndexedDB and retry it via background sync:
async function postResubscribe(payload) {
try {
await fetch('/api/push/resubscribe', { /* ... */ });
} catch (err) {
const db = await openDb('push-queue');
await db.add('pending', payload);
await self.registration.sync.register('push-resubscribe');
}
}
self.addEventListener('sync', (event) => {
if (event.tag === 'push-resubscribe') {
event.waitUntil(flushResubscribeQueue());
}
});
This pattern composes with the broader Background Sync API documentation, which is the right tool for any service worker operation that needs to happen "eventually" rather than "right now."
Common mistakes to avoid
-
Forgetting
event.waitUntil(). The handler dies before the resubscribe completes. Same gotcha as the push event handler. -
Not passing
oldEndpointto the server. The server can't match the old subscription to the new one cleanly. - Deleting the old subscription row instead of updating it. Loses metadata.
- No fallback when re-subscription fails. A transient failure becomes a permanent subscription loss.
- No monitoring. You only notice the lifecycle is broken when delivery rates drop, weeks later.
When this matters
A small app with a few hundred push-subscribed users can probably get away without a robust pushsubscriptionchange handler. Some subscriptions die; the team writes them off as natural churn.
An app with tens of thousands of push-subscribed users needs this handler from day one. The churn rate without it can compound to 20% to 30% subscription loss per quarter, which makes push a less reliable channel over time.
Build it once, monitor the resubscription rate, fix bugs that surface in monitoring. The whole thing is maybe a day of work; the operational savings over the life of the product are substantial. If you're shipping push at any reasonable scale, this is not optional.
Top comments (0)