Skip to content

Concept in Notifications

Push subscription expiry and refresh

Published

A push subscription may carry an expiration time, after which the browser deactivates it, and the browser or the push service may replace a subscription with a fresh one at any point. The specification routes both outcomes to one place: a pushsubscriptionchange event in the service worker, carrying the subscription that should no longer be used and the one that replaced it.

BCD api.PushSubscription.expirationTime records support from Chrome 60, Firefox 96, Safari 16, and Safari on iOS 16.4, mirrored into the Chromium-based browsers the record lists, and no support in Android WebView. MDN marks the property as Baseline “Widely available”, available across browsers since March 2023, and restricted to secure contexts.

The event is in a weaker position. MDN marks pushsubscriptionchange as “Limited availability”, stating that it “is not Baseline because it does not work in some of the most widely-used browsers”. BCD api.ServiceWorkerGlobalScope.pushsubscriptionchange_event records it from Chrome 138; from Firefox 44 and Firefox for Android 48 as partial implementations; and from Safari 16, with a note that notifications are supported on macOS Ventura and later. It records no support in Safari on iOS. The two event members are tracked separately in api/PushSubscriptionChangeEvent.json (github.com), which records oldSubscription and newSubscription from Chrome 138, Firefox 137, Safari 16, and Safari on iOS 16.4.

The Push API (w3.org) states that a push subscription “MAY have an associated subscription expiration time”, and that when the time is set it is “the time, in milliseconds since 00:00:00 UTC on 1 January 1970, at which the subscription will be deactivated”. The same paragraph tells the user agent it “SHOULD attempt to refresh the push subscription before the subscription expires”, so expiry is a deadline the browser is expected to beat, not an event the application is expected to schedule around.

PushSubscription.expirationTime exposes that value. The specification declares it as a readonly attribute EpochTimeStamp? whose getter returns the expiration time if there is one and null otherwise, and toJSON() copies it into a PushSubscriptionJSON dictionary whose expirationTime member defaults to null. MDN’s expirationTime page (developer.mozilla.org) describes the same value as a DOMHighResTimeStamp or null.

A refresh is separate from expiry and is not tied to it. The specification allows a user agent or push service to refresh any subscription whose associated service worker registration is non-null “at any time, for example because it has reached a certain age”, and it requires a refresh whenever the user agent has to change a subscription’s keys and that registration is non-null. A refresh creates a new subscription from the original PushSubscriptionOptions, keeps the original scope, and must produce a key pair different from the original’s. The user agent then fires pushsubscriptionchange with the initial subscription as oldSubscription and the new one as newSubscription.

The specification allows a grace period around that swap: a user agent “MAY continue to accept messages for an old push subscription for a brief time after a refresh”, and once messages have been received for the refreshed subscription the old ones must be deactivated. Deactivation deletes both the user agent’s and the push service’s stored copies, stops delivery of subsequent messages, and forbids reuse of the endpoint for a new subscription.

A null newSubscription is the other shape the event takes. Where the subscription can no longer be used, for example because it has expired, and the user agent cannot refresh it, the specification requires the event with the deactivating subscription as oldSubscription and null as newSubscription. Where a permission is revoked, the user agent may fire the event the same way and must deactivate the affected subscriptions. The PushSubscriptionChangeEventInit definition gives the member its general meaning across those paths: newSubscription “will be null when no new push subscription could be established, for example because the web application has lost express permission”. The mirror case is also defined: oldSubscription “will be null when the user agent is not able to provide the full set of details, for example because of partial database corruption”.

PushSubscriptionChangeEvent is declared [Exposed=ServiceWorker, SecureContext] and extends ExtendableEvent, so the handler has waitUntil(), and ServiceWorkerGlobalScope also exposes an onpushsubscriptionchange handler attribute. MDN adds that the event is not cancelable and does not bubble.

The handler runs in the service worker, where there is no page to report to, so each example decides what to persist and what to retry rather than what to display.

Resubscribing and reporting the new endpoint

Section titled “Resubscribing and reporting the new endpoint”

Pass the old subscription’s options straight back into subscribe(): that is the dictionary the specification reuses on a browser-initiated refresh, and it carries the original applicationServerKey. Wrap the whole chain in waitUntil() so the worker is not terminated mid-flight.

self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
(async () => {
const options = event.oldSubscription?.options ?? { userVisibleOnly: true };
const next = await self.registration.pushManager.subscribe(options);
await fetch('/push/rotate', {
method: 'POST',
headers: { 'Content-type': 'application/json' },
body: JSON.stringify({
old: event.oldSubscription?.endpoint ?? null,
new: next.toJSON(),
}),
});
})(),
);
});

toJSON() is the convenient payload here because the specification defines it to carry endpoint, expirationTime, and the base64url p256dh and auth keys, which is exactly what an application server needs to encrypt the next message.

Handling the browsers that omit both members

Section titled “Handling the browsers that omit both members”

BCD records Firefox 44 and Firefox for Android 48 as partial implementations whose note states that the event “does not have the oldSubscription and newSubscription properties”, with the members arriving in Firefox 137. A handler that reads event.oldSubscription on those versions has nothing to compare against, so detect the member and fall back to the registration’s current subscription.

self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
(async () => {
if (!('oldSubscription' in event)) {
// Firefox before 137: the event fires with neither member.
const current = await self.registration.pushManager.getSubscription();
await fetch('/push/resync', {
method: 'POST',
body: JSON.stringify({ current: current ? current.toJSON() : null }),
});
return;
}
await rotate(event.oldSubscription, event.newSubscription);
})(),
);
});

The /push/resync route has to match an existing record by something other than the old endpoint, because the handler cannot name it. Keying push records by a stable per-installation identifier rather than by endpoint alone is what makes that route possible.

Detecting the event and reconciling where it is absent

Section titled “Detecting the event and reconciling where it is absent”

BCD records no support for pushsubscriptionchange in Safari on iOS. A handler registered for it in that environment receives no event, so the refresh and deactivation paths above have to be covered another way. ServiceWorkerGlobalScope exposes an onpushsubscriptionchange handler attribute, which makes 'onpushsubscriptionchange' in self a test the worker can run before it relies on the event firing. Where the test fails, compare getSubscription() against the server’s record instead: the activate event gives the worker a point to do that on each version it installs.

if ('onpushsubscriptionchange' in self) {
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(rotate(event.oldSubscription, event.newSubscription));
});
} else {
// No pushsubscriptionchange in this worker: reconcile on activation instead.
self.addEventListener('activate', (event) => {
event.waitUntil(reconcileSubscription());
});
}
async function reconcileSubscription() {
const current = await self.registration.pushManager.getSubscription();
await fetch('/push/reconcile', {
method: 'POST',
headers: { 'Content-type': 'application/json' },
body: JSON.stringify({
installationId: await readInstallationId(), // the stable key the records are indexed by
current: current ? current.toJSON() : null, // null: nothing to send to
}),
});
}

What the specification settles about the server side is narrow: a deactivated subscription receives no subsequent messages, and its endpoint is not reused for another subscription. It does not define how an application server learns that a subscription was deactivated, so nothing in the Push API licenses treating a particular delivery response as that signal. The reconciliation call above is the part this page can vouch for, and it covers both the environments where the event exists and the ones where it does not.

expirationTime is readable from the page as well as from the worker, which lets a client send the deadline to the server alongside the endpoint. Treat null as “no deadline recorded”, which is what the getter returns when the subscription has no expiration time.

async function describeSubscription(registration) {
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return { subscribed: false };
const { endpoint, expirationTime } = subscription;
return {
subscribed: true,
endpoint,
expiresAt: expirationTime === null ? null : new Date(expirationTime).toISOString(),
};
}

A recorded deadline lets a server stop sending to an endpoint it knows the browser will deactivate, which matters because the specification stops delivery for a deactivated subscription and forbids reuse of its endpoint.

Exact strings, each from the source named. BCD’s Firefox note on api.ServiceWorkerGlobalScope.pushsubscriptionchange_event reads “The event does not have the oldSubscription and newSubscription properties. See bug 1497429.” (api/ServiceWorkerGlobalScope.json, github.com), and the companion note on api.PushSubscriptionChangeEvent for Firefox 137 reads “Before Firefox 137, The pushsubscriptionchange event is fired, but does not have the oldSubscription and newSubscription properties. See bug 1635524.”

MDN’s event page carries a note that reads “In earlier drafts of the specification, this event was defined to be sent when a PushSubscription has expired” (pushsubscriptionchange event, developer.mozilla.org). The published specification states the wider scope: the event “indicates a change in a push subscription that was triggered outside of the application’s control, for example because it has been refreshed, revoked or lost”.

Specifications

SpecificationStatus
Push API: subscription refreshesW3C
Push API: PushSubscription.expirationTimeW3C
Push API: the pushsubscriptionchange eventW3C