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.
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
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,
}),
})
);
});
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"
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
before vs after 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.
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'))
)),
},
}),
});
}());
});