Handling pushsubscriptionchange on the Client
pushsubscriptionchange is the only event the platform gives you when a push endpoint is replaced, and it is the least consistently implemented event in the whole Push API surface.
Quick answer: write one handler that copes with three different event shapes — both oldSubscription and newSubscription present, only one present, or neither — because all three occur in the wild. In every shape the handler must re-subscribe using an applicationServerKey it reads from IndexedDB (the service worker has no access to your page globals), then report the old and the new endpoint in a single request so the server can migrate the row rather than insert a duplicate. Then add a load-time poll, because several engines never fire the event at all.
| Event shape | What you get | What the handler must do |
|---|---|---|
| Full | oldSubscription + newSubscription |
Post both endpoints; no subscribe() call needed. |
| Partial | oldSubscription only, or newSubscription only |
Fill in the missing half — re-subscribe, or read the stored endpoint. |
| Empty | Neither field set | Read getSubscription(), pair it with the last known endpoint from IndexedDB. |
What the Event Actually Delivers
The spec defines PushSubscriptionChangeEvent with two nullable members, newSubscription and oldSubscription. “Nullable” is doing a lot of work in that sentence. Chromium shipped the event for years with neither member populated, then added them; Firefox populates them in current releases; some builds dispatch a bare Event object with no members at all. Any handler that reads event.newSubscription.endpoint without a guard will throw inside a service worker, where the exception is invisible to your page and to most error reporters.
It also helps to know what actually triggers a dispatch, because the causes are more varied than “the subscription expired”. A push service can move a subscription between hosts during maintenance and reissue the endpoint. A browser profile sync can rebuild the registration on a second machine. Storage pressure can evict and recreate parts of the registration. A user can reset site permissions and re-grant them in one motion. And a deliberate VAPID key rotation on your side invalidates the binding between endpoint and key without the browser noticing at all. Only some of those produce an event, which is the structural reason the event can never be your only detection mechanism.
The second constraint is scope. The handler runs in the service worker, so window, your bundle’s config object and anything you injected into the page are all unavailable. If you need to re-subscribe you need the applicationServerKey at hand, and the only durable place to put it is storage the worker can read — IndexedDB, or a value baked into the worker script at build time. Writing it to IndexedDB at first subscribe is the more maintainable of the two, because a rotated key does not then require shipping a new worker script.
A Handler That Survives All Three Shapes
The handler below reads the stored key, normalises whatever the event gave it into an old/new pair, and reports once. It uses event.waitUntil() so the worker is not terminated mid-flight, and it stores the key and the current endpoint in a tiny IndexedDB store shared with the page.
// sw.js
const DB_NAME = 'push-meta';
const STORE = 'meta';
function openMeta() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function metaGet(key) {
return openMeta().then(db => new Promise((resolve, reject) => {
const r = db.transaction(STORE, 'readonly').objectStore(STORE).get(key);
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
}));
}
function metaSet(key, value) {
return openMeta().then(db => new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
}));
}
function urlBase64ToUint8Array(base64) {
const padded = (base64 + '='.repeat((4 - base64.length % 4) % 4))
.replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(padded);
return Uint8Array.from(raw, c => c.charCodeAt(0));
}
self.addEventListener('pushsubscriptionchange', event => {
event.waitUntil(reconcileSubscriptionChange(event));
});
async function reconcileSubscriptionChange(event) {
// 1. Whatever the event told us — possibly nothing.
let oldSub = event.oldSubscription || null;
let newSub = event.newSubscription || null;
// 2. The old endpoint may only exist in our own storage.
const storedEndpoint = await metaGet('endpoint');
const oldEndpoint = oldSub ? oldSub.endpoint : storedEndpoint || null;
// 3. If the browser did not hand us a replacement, look for one, then make one.
if (!newSub) {
newSub = await self.registration.pushManager.getSubscription();
}
if (!newSub) {
const key = await metaGet('applicationServerKey');
if (!key) return; // nothing safe to do; the page will repair on load
newSub = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key)
});
}
// 4. Report both halves in one request so the server can migrate atomically.
const res = await fetch('/api/push/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
oldEndpoint,
subscription: newSub.toJSON(),
source: 'pushsubscriptionchange',
firedAt: new Date().toISOString()
})
});
if (res.ok) await metaSet('endpoint', newSub.endpoint);
}
Four properties make this handler safe. It never dereferences an event member without a guard. It treats getSubscription() as a cheaper source of the new subscription than subscribe(), because in the full and partial shapes the browser has usually already created the replacement. It only calls subscribe() when it has a key to call it with, so a missing key degrades to “do nothing and let the page fix it” rather than throwing. And it writes the new endpoint to IndexedDB only after the server confirms, so a failed request leaves the old value in place for the next attempt — the same invariant the load-time check in subscription state synchronization depends on.
The key itself must be written when you first subscribe, from the page:
// during your normal subscribe flow, in the page
await metaSet('applicationServerKey', VAPID_PUBLIC_KEY_BASE64URL);
await metaSet('endpoint', subscription.endpoint);
Store the base64url string, not the Uint8Array. Structured cloning a typed array into IndexedDB works, but the string round-trips through logs, comparisons and your server payloads without conversion, and it is what you will want when comparing against sub.options.applicationServerKey later. The public key is safe to persist client-side; the matching private key lives only in process.env.VAPID_PRIVATE_KEY on the server and must never appear in a worker script or a bundle.
Migrating the Server Record Atomically
The reason the handler sends both endpoints is that only the server can preserve identity across a rotation. Given just the new endpoint, the server cannot distinguish a rotation from a second device, so it inserts a row — and now one user has two subscriptions, one of which is dead and will keep consuming send quota until a 410 Gone retires it, as covered in handling 410 Gone responses at scale. Given both, the server renames the existing row and every foreign key pointing at it — topic opt-ins, consent records, engagement history — survives untouched.
// POST /api/push/migrate
app.post('/api/push/migrate', async (req, res) => {
const { oldEndpoint, subscription, source, firedAt } = req.body || {};
if (!subscription?.endpoint) return res.status(400).json({ error: 'no endpoint' });
const client = await pool.connect();
try {
await client.query('BEGIN');
let migrated = 0;
if (oldEndpoint && oldEndpoint !== subscription.endpoint) {
const upd = await client.query(
`UPDATE push_subscriptions
SET endpoint = $1, p256dh = $2, auth = $3,
status = 'active', last_verified = now()
WHERE endpoint = $4
AND NOT EXISTS (SELECT 1 FROM push_subscriptions WHERE endpoint = $1)`,
[subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, oldEndpoint]
);
migrated = upd.rowCount;
}
if (migrated === 0) {
await client.query(
`INSERT INTO push_subscriptions (endpoint, p256dh, auth, status, last_verified)
VALUES ($1, $2, $3, 'active', now())
ON CONFLICT (endpoint) DO UPDATE
SET p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth,
status = 'active', last_verified = now()`,
[subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth]
);
}
await client.query(
`INSERT INTO push_subscription_migrations (old_endpoint, new_endpoint, source, fired_at)
VALUES ($1, $2, $3, $4)`,
[oldEndpoint || null, subscription.endpoint, source || 'unknown', firedAt || new Date()]
);
await client.query('COMMIT');
res.json({ ok: true, migrated: migrated > 0 });
} catch (err) {
await client.query('ROLLBACK');
res.status(500).json({ error: 'migrate failed' });
} finally {
client.release();
}
});
The NOT EXISTS guard matters. If the load-time sync already registered the new endpoint before the worker’s request landed — entirely possible, since the two race — the UPDATE would violate the unique constraint on endpoint and abort the transaction. Guarding it turns that race into a no-op followed by the idempotent upsert, so both orderings converge on one row. The migrations table is not optional either: it is the only place you can later look to answer “how often do our endpoints rotate, and did the event or the poll catch it?”
The Fallback Poll for Browsers That Never Fire It
Even a perfect handler covers only the rotations your engine chooses to announce. The event is dispatched inconsistently across engines and, on iOS, the worker only runs while the installed web app is in use — so a rotation that happens between sessions is announced to nobody. The fallback is not another event; it is the load-time comparison described in the parent guide, running the same /api/push/migrate code path with source: 'poll'.
// page-side fallback: catches everything the event missed
async function pollForRotation() {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
const stored = await metaGet('endpoint'); // same IndexedDB store as the worker
if (!sub) return 'no-subscription'; // ghost permission — handled separately
if (stored === sub.endpoint) return 'in-sync';
await fetch('/api/push/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
oldEndpoint: stored || null,
subscription: sub.toJSON(),
source: 'poll',
firedAt: new Date().toISOString()
})
});
await metaSet('endpoint', sub.endpoint);
return 'migrated';
}
Sharing the IndexedDB store between page and worker is what makes the two paths interchangeable: whichever runs first records the new endpoint, and the other one short-circuits. Tagging the request with source lets you measure the split, which is the only honest way to decide how much you should trust the event on your traffic.
That measurement is worth setting up before you tune anything else. Group the migrations table by source over a month and you get a direct read on your engine mix: a high poll share means rotations are being detected late, and the lateness is exactly the window during which you were sending to a dead endpoint. Pair it with the interval between fired_at and the preceding last_verified on the same subscriber and you can quote the average drift duration rather than guessing at it. Teams that instrument this almost always find the poll catching more than they expected — which is an argument for shortening the interval between opportunities to run it, not for abandoning the event.
There is one more failure mode worth pre-empting: duplicate dispatches. Some engines fire the event more than once for a single rotation, and a page load racing the worker can produce two migration requests within a second. Because the server path is idempotent — a guarded update followed by an upsert on the same endpoint — the second request is harmless, but the audit table will show two rows. Either deduplicate on (old_endpoint, new_endpoint) with a unique constraint, or accept the duplicates and count distinct pairs when you report. What you must not do is make the handler non-idempotent by, for example, incrementing a counter or firing an analytics conversion each time it runs.
Verifying the Handler
- In DevTools → Application → Service Workers, confirm the worker is activated and note its script URL.
- Open the worker’s console context and run
await metaGet('applicationServerKey')— an empty result means the page never wrote the key and the empty-shape branch cannot recover. - Trigger the migration path directly by calling
reconcileSubscriptionChange({})in the worker console. An empty object exercises the hardest branch: no members, so the handler must fall back to storage andgetSubscription(). - Watch for
POST /api/push/migratein the network panel and confirm the body contains a non-nulloldEndpoint. - Query the audit table:
SELECT old_endpoint, new_endpoint, source FROM push_subscription_migrations ORDER BY fired_at DESC LIMIT 5;and confirm exactly one row per rotation, not two. - Reload the page and confirm
pollForRotation()returnsin-sync— proof that the worker and the page agree on the stored endpoint.
Gotchas & Edge Cases
- An exception in the handler is invisible. Service worker errors do not surface in the page console and most error reporters do not instrument workers. Wrap the body in a
try/catchthat posts a diagnostic beacon, or you will never learn that the handler has been throwing for a month. subscribe()can reject inside the handler. If permission has been revoked, or the stored key no longer matches the live subscription, you will getNotAllowedErrororInvalidStateError. Both mean “stop and let the page repair on next load” — see pushManager.subscribe fails with NotAllowedError.- A worker update can drop your handler. If a new worker version skips waiting while a rotation is in flight, the
waitUntilpromise is abandoned. Version the worker carefully; the safe update patterns are in how to handle service worker updates without breaking push. - The event is not a permission signal. It fires for endpoint rotation, not for revocation, and it does not fire when the user blocks notifications. Permission changes need their own load-time read.
- Do not unsubscribe the old subscription in the handler. In the full shape the browser has already retired it, and calling
unsubscribe()onnewSubscriptionby mistake is a self-inflicted outage. Only unsubscribe when you are deliberately re-keying, which belongs in VAPID key generation & rotation.
Related
- Back to Subscription state synchronization — the load-time reconciliation this event accelerates but cannot replace.
- Detecting stale push subscriptions in the browser — the client-side signals that a subscription is already dead.
- Service worker registration patterns — worker lifecycle, scope and update mechanics that this handler depends on.
- Handling 410 Gone responses at scale — what happens to the endpoints a missed rotation leaves behind.
FAQ
Why are oldSubscription and newSubscription sometimes null?
Both members are nullable in the specification, and engines populate them differently. Chromium shipped the event for years with neither set before adding them; Firefox populates both in current releases; some builds dispatch a plain event object with no members. Guard every access and reconstruct the missing half from getSubscription() plus the endpoint you stored yourself.
Can I call pushManager.subscribe() inside the service worker?
Yes. subscribe() requires notification permission to already be granted, not a user gesture, so a re-subscribe from inside the handler is legitimate. You must supply the applicationServerKey yourself, which is why it has to be readable from worker storage — the worker cannot see your page globals.
Do I still need a load-time check if I handle the event?
Yes. No engine fires the event for every rotation, and on iOS the worker only runs while the installed web app is open, so a rotation between sessions is never announced. Run the same migration request from the page on load with a different source tag, and measure which path is actually catching your rotations.