demo · v138

Endpoint Migration Tool

Step through the full pushsubscriptionchange event handler flow in Chrome 138: old endpoint is rotated, SW fires the event, server registers the new one, old endpoint is purged.

old endpoint (stale)
https://fcm.googleapis.com/fcm/send/f7Qx…3kA2
active (pre-rotation)
new endpoint (rotated)
not yet assigned
1
Browser rotates push subscription

Chrome detects the subscription is due for rotation (e.g. after permission re-grant) and silently creates a new push endpoint.

// Chrome calls pushsubscriptionchange internally // oldSubscription = previous PushSubscription // newSubscription = newly created PushSubscription
2
Service Worker receives the event

The SW's pushsubscriptionchange handler fires with both subscriptions attached. The SW must send the new endpoint to your server.

self.addEventListener('pushsubscriptionchange', (event) => { event.waitUntil( fetch('/api/push/update', { method: 'POST', body: JSON.stringify({ old: event.oldSubscription?.endpoint, new: event.newSubscription.endpoint, }), }) ); });
3
Server registers new endpoint

Your backend receives the update, stores the new endpoint against the user record, and marks the old endpoint as superseded.

// POST /api/push/update // { old: "https://fcm...3kA2", new: "https://fcm...9pZ7" } db.updateSubscription({ old, new }); // Old endpoint row marked: status = "superseded"
4
Old endpoint purged from server

Server deletes the stale endpoint row. Future pushes go to the new endpoint only — no 410 Gone errors, no silent delivery failures.

// Next push delivery await webpush.sendNotification(newSubscription, payload); // 201 Created — delivered successfully
[00:00] Ready. Press "Next step" to simulate the subscription rotation.

before vs after Chrome 138

Before Chrome 138

Permission revoked then re-granted silently. Browser creates a new subscription but never fires pushsubscriptionchange. Server keeps the old (dead) endpoint. First push attempt returns 410 Gone. Developer must poll or user must log out and in again to resync.

Chrome 138+

Browser fires pushsubscriptionchange with both oldSubscription and newSubscription populated. SW posts the new endpoint to the server before the first push ever arrives. Zero silent failures, zero user action required.

the code

// service-worker.js
self.addEventListener('pushsubscriptionchange', (event) => {
  event.waitUntil(async function() {
    // Chrome 138: event.newSubscription is always set.
    // event.oldSubscription may be null if no prior sub existed.
    const { oldSubscription, newSubscription } = event;

    await fetch('/api/push/rotate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        oldEndpoint: oldSubscription?.endpoint ?? null,
        newEndpoint: newSubscription.endpoint,
        newKeys: {
          p256dh: btoa(String.fromCharCode(
            ...new Uint8Array(newSubscription.getKey('p256dh'))
          )),
          auth: btoa(String.fromCharCode(
            ...new Uint8Array(newSubscription.getKey('auth'))
          )),
        },
      }),
    });
  }());
});

see also