# Push subscription expiry and refresh

> What a subscription expiration time means, what pushsubscriptionchange carries on a refresh or a loss, and the Firefox versions that omit its two members.

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

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](https://github.com/mdn/browser-compat-data/blob/main/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

The [Push API](https://www.w3.org/TR/push-api/#subscription-refreshes) (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](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/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

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

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.

```js
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

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.

```js
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

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.

```js
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

`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.

```js
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

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](https://github.com/mdn/browser-compat-data/blob/main/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](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/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".

:::caution
The specification's own note on firing the event advises considering "a more reliable
synchronization mechanism such as [WEB-BACKGROUND-SYNC] when sending the details of the new push
subscription to your application server", because "the user might be subject to unreliable
network conditions that could cause a fetch to fail". MDN repeats the point on its event page,
noting that `fetch()` will not work if the app is offline and asking authors to synchronise
subscription details by some other means, or to handle a failed exchange.
:::

## See also

- [Push API: subscription refreshes](https://www.w3.org/TR/push-api/#subscription-refreshes) (w3.org)
- [ServiceWorkerGlobalScope: pushsubscriptionchange event](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event) (developer.mozilla.org)
- [Web Push and PushManager.subscribe()](/reference/notifications/web-push/)
- [Web Push on iOS and iPadOS](/reference/notifications/ios-safari-push/)
- [Notification.requestPermission()](/reference/notifications/permissions/)