DEV Community

137Foundry
137Foundry

Posted on

How to Handle pushsubscriptionchange Events in Production

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.

data center hallway with rows of servers and status lights
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,
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode

Two things to notice:

  1. event.waitUntil() is required. The handler does async work; without waitUntil, the service worker may terminate before the resubscribe completes. This is the same lifecycle issue that causes push notifications to not render when handlers forget waitUntil.
  2. Pass both endpoints to the server. event.oldSubscription?.endpoint tells 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', { /* ... */ });
}
Enter fullscreen mode Exit fullscreen mode

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),
  });
}
Enter fullscreen mode Exit fullscreen mode

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 = ?
Enter fullscreen mode Exit fullscreen mode

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,
      }),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

laptop on a desk with terminal screens showing code and logs
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 pushsubscriptionchange events 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());
  }
});
Enter fullscreen mode Exit fullscreen mode

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

  1. Forgetting event.waitUntil(). The handler dies before the resubscribe completes. Same gotcha as the push event handler.
  2. Not passing oldEndpoint to the server. The server can't match the old subscription to the new one cleanly.
  3. Deleting the old subscription row instead of updating it. Loses metadata.
  4. No fallback when re-subscription fails. A transient failure becomes a permanent subscription loss.
  5. 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)