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.
Support position
Section titled “Support position”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.
How it works
Section titled “How it works”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.
Examples
Section titled “Examples”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.
Reading the expiration time on the page
Section titled “Reading the expiration time on the page”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.
Observed behaviour
Section titled “Observed behaviour”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”.
See also
Section titled “See also”- Push API: subscription refreshes (w3.org)
- ServiceWorkerGlobalScope: pushsubscriptionchange event (developer.mozilla.org)
- Web Push and PushManager.subscribe()
- Web Push on iOS and iPadOS
- Notification.requestPermission()
Specifications
| Specification | Status |
|---|---|
| Push API: subscription refreshes | W3C |
| Push API: PushSubscription.expirationTime | W3C |
| Push API: the pushsubscriptionchange event | W3C |