Detecting Stale Push Subscriptions in the Browser

By the time your server learns a subscription is dead — a 410 Gone on the send path — you have already wasted a message and, more importantly, spent days or weeks believing you could reach a user you could not.

Quick answer: the browser exposes four usable signals and one that is mostly theoretical. getSubscription() returning null and Notification.permission reading denied are conclusive: the subscription is gone. A mismatch between sub.options.applicationServerKey and the key you are currently signing with means the endpoint is unusable even though it exists. A last_verified timestamp you maintain yourself is the only signal that catches slow decay. expirationTime is specified but effectively never populated, so treat it as a bonus, not a mechanism.

Signal What it proves Cost to check
getSubscription()null Nothing to send to. Definitive. One local async read.
Notification.permission === 'denied' The user switched you off. Definitive. One synchronous property read.
options.applicationServerKey mismatch The endpoint cannot be signed for. Local, where the engine exposes it.
last_verified age Probable decay; needs a threshold. Local read plus an occasional probe.
expirationTime in the past Definitive, but almost never set. Free, and almost always null.
How conclusive each staleness signal is Five client-side signals ranked by evidentiary strength, from a null getSubscription result which proves the subscription is dead down to expirationTime which is rarely populated at all. Client-side signal Evidentiary strength getSubscription() returns null proves it is dead Notification.permission is denied proves it is dead stored server key no longer matches unusable endpoint last-verified timestamp is old needs a threshold expirationTime is in the past rarely set
Two signals are proof, two are strong evidence, one is a specification promise no shipping push service keeps.

What the Browser Actually Lets You Inspect

A PushSubscription is a deliberately thin object. It carries an endpoint, a pair of encryption keys, the options it was created with, and a nullable expirationTime. What it does not carry is any notion of health: there is no status, no lastUsed, no delivery counter. Nothing in the object changes when the push service quietly retires the endpoint on its side. That asymmetry is the whole problem — the object looks identical on day one and on day four hundred, whether it still works or not.

Which means every freshness signal beyond “does an object exist at all” has to be manufactured by you. That is not a workaround; it is the intended design. The push service is the authority on whether an endpoint is live, and RFC 8030 gives it exactly one way to tell you: the HTTP status on a send.

Anatomy of a PushSubscription The fields of a PushSubscription object with callouts marking the endpoint as the only identity the server has, the application server key as the field to compare, and the absence of any freshness field. PushSubscription — everything the browser hands you endpoint: https://push.example/send/cX9k… expirationTime: null options.applicationServerKey: ArrayBuffer(65) options.userVisibleOnly: true keys.p256dh, keys.auth no status, no lastUsed, no counters the only identity your server has hash it before you log it compare with the key in use absent on some engines no freshness field exists you must record it yourself
Three fields are worth inspecting; the fourth annotation marks the field the platform never gave you.

Signal 1 — getSubscription() Returns Null

This is the highest-value check because it is both conclusive and cheap. registration.pushManager.getSubscription() resolves with null whenever the registration holds no subscription, and the most common cause is not the user: it is storage loss. A site-data clear, a “clear cookies and site data on close” browser setting, an aggressive privacy extension, or eviction under disk pressure all destroy the service worker registration and the subscription with it.

The trap is that notification permission is stored separately — as an origin-scoped browser setting — and survives all of those. So Notification.permission keeps reading granted while there is nothing to send to. Any UI or analytics event keyed on permission alone will report a healthy subscriber indefinitely.

async function inspectSubscription(expectedKeyBase64Url) {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    return { verdict: 'unsupported' };
  }
  if (Notification.permission === 'denied') {
    return { verdict: 'revoked', reason: 'permission-denied' };
  }

  const reg = await navigator.serviceWorker.getRegistration();
  if (!reg) return { verdict: 'stale', reason: 'no-registration' };

  const sub = await reg.pushManager.getSubscription();
  if (!sub) {
    return {
      verdict: 'stale',
      reason: Notification.permission === 'granted'
        ? 'ghost-permission'      // granted, but storage was cleared
        : 'never-subscribed'
    };
  }

  if (sub.expirationTime !== null && sub.expirationTime <= Date.now()) {
    return { verdict: 'stale', reason: 'expired', endpoint: sub.endpoint };
  }

  const keyState = compareServerKey(sub, expectedKeyBase64Url);
  if (keyState === false) {
    return { verdict: 'stale', reason: 'key-mismatch', endpoint: sub.endpoint };
  }

  return {
    verdict: 'live',
    endpoint: sub.endpoint,
    keyChecked: keyState === true,
    expirationTime: sub.expirationTime
  };
}

Note the ordering. Permission is read first because it is synchronous and definitive; the registration lookup comes before the subscription lookup because a missing registration explains a missing subscription and produces a different repair. Reporting the specific reason rather than a bare boolean is what lets you distinguish storage loss from an explicit opt-out later — a distinction that matters for consent records, not just for delivery.

Signal 2 — expirationTime, Where It Exists

The Push API defines expirationTime as a DOMHighResTimeStamp or null, and the specification anticipates push services that issue time-limited subscriptions. In practice, no major push service sets it: Chromium’s FCM endpoints, Mozilla Autopush and Apple’s push service all return null. Writing an expiry policy around it produces code that never executes.

Check it anyway, because the check costs nothing and a non-null value is unambiguous. What you must not do is infer anything from null — it means “this service does not use expiry”, not “this subscription is fresh”. Treating null as a health signal is how teams end up with a monitoring dashboard that reports one hundred per cent healthy subscriptions while a third of them are dead.

Signal 3 — The Stored Server Key No Longer Matches

A subscription is bound to the application server key it was created with. If you rotate your VAPID keypair, every existing subscription still exists in the browser and still returns a perfectly valid-looking endpoint — but a JWT signed with the new private key will be rejected by the push service with 401 or 403, because the endpoint was issued against the old public key. The subscription is stale in the only sense that matters: you cannot send to it.

function toBase64Url(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

// true = matches, false = mismatch, null = engine did not expose it
function compareServerKey(sub, expectedBase64Url) {
  const raw = sub.options && sub.options.applicationServerKey;
  if (!raw || !expectedBase64Url) return null;
  return toBase64Url(raw) === expectedBase64Url;
}

The three-valued return is not fussiness. Chromium exposes options.applicationServerKey as an ArrayBuffer; Firefox exposes it in current releases; WebKit has historically omitted options entirely. Collapsing “cannot check” into false would make every Safari subscriber look stale and trigger a re-subscribe storm. Where the local comparison is unavailable, record which key created each subscription server-side at registration time and compare there instead — the approach described in rotating VAPID keys without losing subscribers.

Signal 4 — A Last-Verified Heartbeat

The three signals above are all binary and all catch abrupt failures. What they miss is slow decay: an endpoint that the push service retired months ago, on a device that has not opened your site since. The subscription object is still there, the key still matches, permission is still granted, and every local check reports healthy.

The only defence is a timestamp you own. Write last_verified every time the client proves the subscription is live, and treat its age as the confidence you have in the record. Keep the same value in two places — client storage for the local decision, and the server row for hygiene jobs — so neither side has to guess.

const FRESH_DAYS = 7;
const STALE_DAYS = 30;

function ageInDays(iso) {
  if (!iso) return Infinity;
  return (Date.now() - Date.parse(iso)) / 86400000;
}

function freshness(iso) {
  const age = ageInDays(iso);
  if (age <= FRESH_DAYS) return 'fresh';      // trust the local record, do nothing
  if (age <= STALE_DAYS) return 'suspect';    // re-verify against the server
  return 'stale';                             // probe before counting this subscriber
}

async function heartbeat(endpoint) {
  const res = await fetch('/api/push/heartbeat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'same-origin',
    body: JSON.stringify({ endpoint, at: new Date().toISOString() })
  });
  if (!res.ok) return false;
  localStorage.setItem('push:verifiedAt', new Date().toISOString());
  return true;
}

Thirty days is a defensible default, not a law. Set the stale threshold to roughly the point where your own data shows send failures climbing, and remember that the server-side counterpart — a job that sweeps rows whose last_verified has aged out — is what actually removes the dead weight from your database, as covered in pruning expired push subscriptions from your database.

Last-verified freshness zones An age axis divided into a fresh band up to seven days, a suspect band up to thirty days and a stale band beyond, with a marker showing a device last verified twelve days ago. Age of the last-verified timestamp fresh trust the local record suspect re-verify with a heartbeat stale exclude from reach counts day 0 day 7 day 30 day 90+ this device: verified 12 days ago send a heartbeat on this load, then trust it again
The thresholds are yours to tune; what matters is that an unverified subscription loses confidence over time rather than staying green forever.

A Numbered Diagnostic Procedure

Run this in the browser console on a page where you expect a live subscription. It takes under a minute and distinguishes all five staleness causes.

  1. Read the permission: Notification.permission. A denied result ends the investigation — the subscriber revoked you, and no repair is available from your side.
  2. Confirm the worker: await navigator.serviceWorker.getRegistration(). A null result means the registration is gone; look for a site-data clear or a scope change before blaming push.
  3. Fetch the subscription: const s = await (await navigator.serviceWorker.ready).pushManager.getSubscription(). A null here with permission still granted is ghost permission — storage loss, not user intent.
  4. Inspect the expiry: s.expirationTime. Non-null and in the past means the push service expired it; null means nothing at all.
  5. Compare the key: toBase64Url(s.options.applicationServerKey) === YOUR_VAPID_PUBLIC_KEY. A mismatch means you rotated keys and this endpoint can no longer be signed for. undefined means the engine does not expose it — check server-side instead.
  6. Check the heartbeat: localStorage.getItem('push:verifiedAt'). Older than your stale threshold, or missing entirely, means the record has never been proven and should not be counted as reach.
  7. Cross-check the server: query the row for s.endpoint. Present and active is agreement; absent is an orphan endpoint; present under a different endpoint for the same device is a stale record.
Staleness decision flow A vertical decision flow starting at page load, branching on whether permission is granted, whether a subscription exists, and whether the key and last-verified age are fresh, ending in either a repair action or an in-sync verdict. page load permission granted? revoked — mark inactive subscription exists? ghost — re-subscribe key + age fresh? re-register the endpoint live — nothing to do no yes no yes no
Three questions, four outcomes. Every branch that is not live has a specific repair rather than a generic re-subscribe.

Gotchas & Edge Cases

  • A null subscription is not consent withdrawal. Storage loss and an explicit block produce different states and demand different records. Mark the first inactive and let the user re-subscribe silently; mark the second revoked and never prompt again. Conflating them either loses subscribers or ignores an opt-out.
  • Do not call subscribe() just to test liveness. It has side effects: it can rotate the endpoint, and with a different key it throws InvalidStateError. getSubscription() is the read-only equivalent, and the rest of the silent checks are covered in silent permission checks & pre-qualification.
  • localStorage is not a reliable heartbeat store on its own. It is cleared by the same data wipes that destroy the subscription and is unavailable in the service worker. Mirror the timestamp into IndexedDB so the pushsubscriptionchange handler can read and write it too.
  • A private-browsing session always looks stale. Each session starts with empty storage, so every check reports ghost permission and your repair path fires on every visit. Detect the ephemeral case with a storage estimate or a session flag and skip the repair rather than writing throwaway rows.
  • The client can never prove liveness, only absence. A subscription that passes all four checks may still be dead at the push service. Only a send tells you for certain, which is why client detection reduces the window rather than closing it — the server-side half is handling 410 Gone responses at scale.

FAQ

Why is expirationTime always null?

Because no major push service issues time-limited subscriptions. The Push API defines expirationTime for services that do, but FCM, Mozilla Autopush and Apple’s push service all return null. Read it and act on a non-null value in the past, but never treat null as evidence that a subscription is healthy.

Can I tell from the browser whether the push service has retired my endpoint?

No. Nothing about the PushSubscription object changes when the service drops the endpoint on its side; only an HTTP 410 Gone on a send reveals it. Client-side detection narrows the window between the subscription dying and you noticing — it cannot eliminate it.

How old should a last-verified timestamp be before I stop trusting it?

Seven days is a reasonable “fresh” window and thirty days a reasonable “stale” threshold, but tune both against your own failure data: pick the age at which send failures for that cohort start climbing. The important property is that confidence decays with age instead of a record staying green forever.