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. |
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.
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.
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.
- Read the permission:
Notification.permission. Adeniedresult ends the investigation — the subscriber revoked you, and no repair is available from your side. - Confirm the worker:
await navigator.serviceWorker.getRegistration(). Anullresult means the registration is gone; look for a site-data clear or a scope change before blaming push. - Fetch the subscription:
const s = await (await navigator.serviceWorker.ready).pushManager.getSubscription(). Anullhere with permission stillgrantedis ghost permission — storage loss, not user intent. - Inspect the expiry:
s.expirationTime. Non-null and in the past means the push service expired it;nullmeans nothing at all. - 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.undefinedmeans the engine does not expose it — check server-side instead. - 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. - Cross-check the server: query the row for
s.endpoint. Present andactiveis agreement; absent is an orphan endpoint; present under a different endpoint for the same device is a stale record.
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
inactiveand let the user re-subscribe silently; mark the secondrevokedand 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 throwsInvalidStateError.getSubscription()is the read-only equivalent, and the rest of the silent checks are covered in silent permission checks & pre-qualification. localStorageis 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.
Related
- Back to Subscription state synchronization — the reconciliation loop these signals feed.
- Handling pushsubscriptionchange on the client — the event that sometimes announces a rotation before you detect it.
- Pruning expired push subscriptions from your database — the server-side sweep that acts on an aged
last_verified. - Detecting denied push permission without prompting — the silent permission read that begins every diagnostic here.
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.