Subscription State Synchronization
Web push has two independent sources of truth and no protocol that keeps them honest. The browser owns the real PushSubscription — it can create, replace or destroy one without telling you. Your database owns the row you actually send to. When those two disagree, you either send to an endpoint that no longer exists or you fail to send to a user who is, as far as their browser is concerned, subscribed. Neither failure raises an exception; both just quietly reduce your reach.
This guide covers detecting the disagreement from the client, resolving it deterministically, and doing so without hammering your API on every page load. It sits under Frontend Permission UX & Subscription Flows, which handles everything up to the moment an endpoint first exists. Everything here is about the years after that.
Prerequisites
The Four States That Can Disagree
There are five possible relationships between what the browser holds and what your server holds. One is correct; four are drift. Naming them explicitly matters, because each has a different repair and a different cost if you ignore it.
| State | Notification.permission |
getSubscription() |
Server record | Meaning |
|---|---|---|---|---|
| In sync | granted |
returns a subscription | same endpoint, same key | Nothing to do. |
| Ghost permission | granted |
null |
none, or a dead row | Permission survived, the subscription did not. |
| Orphan endpoint | granted |
returns a subscription | none | The browser subscribed; the server never heard about it. |
| Stale record | granted |
returns endpoint B | holds endpoint A | The endpoint rotated. Every send to A is wasted. |
| Silent revocation | denied |
null or a leftover object |
active | The user switched you off in browser settings. No event fired. |
Ghost permission is the most common and the most invisible. A user clears site data, or the browser evicts storage under pressure, and the service worker registration goes with it — but the notification permission is stored separately, at the origin level, and survives. On the next visit Notification.permission still reads granted, so any UI keyed only on permission shows “you’re subscribed” while getSubscription() returns null. The user thinks they are subscribed. You think they are subscribed. Nobody is.
Orphan endpoint comes from a failed write. pushManager.subscribe() resolved, the browser committed the subscription, and then your POST /api/push/register hit a network error, a 500, or a page unload. The browser will keep handing you the same subscription object forever and will never fire another event about it, because from its perspective nothing changed.
Stale record is endpoint rotation. Push services move subscriptions between hosts, expire them, or reissue them after a profile change. The browser may fire pushsubscriptionchange — see handling pushsubscriptionchange on the client — but the event is inconsistently implemented, so you cannot treat it as your only signal.
Silent revocation has no client-side event at all in most browsers. The user opens site settings, flips notifications to Block, and nothing is dispatched to your service worker. The next time your page runs, Notification.permission reads denied and any existing subscription is either gone or inert. Detecting it costs one synchronous property read, and skipping that read means you keep a live-looking row for someone who explicitly opted out — a consent problem, not just a delivery one.
Reading the Browser’s Real State
Everything starts with an honest read. Three facts describe the browser side completely: the permission string, whether a subscription object exists, and whether that object was created with the application server key you are currently using. The third is the one teams skip, and it is the one that catches a key rotation.
// push-state.js — no side effects, safe to call on every load.
const VAPID_PUBLIC_KEY = window.__VAPID_PUBLIC_KEY__;
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(/=+$/, '');
}
function serverKeyMatches(sub, expectedBase64Url) {
const raw = sub.options && sub.options.applicationServerKey;
if (!raw) return null; // not exposed by this engine — unknown, not false
return toBase64Url(raw) === expectedBase64Url;
}
async function readBrowserState() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return { kind: 'unsupported' };
}
const permission = Notification.permission;
const reg = await navigator.serviceWorker.getRegistration();
if (!reg) return { kind: 'no-registration', permission };
const sub = await reg.pushManager.getSubscription();
if (!sub) return { kind: 'no-subscription', permission };
return {
kind: 'subscribed',
permission,
endpoint: sub.endpoint,
expirationTime: sub.expirationTime,
keyMatches: serverKeyMatches(sub, VAPID_PUBLIC_KEY),
subscription: sub
};
}
Two details in that snippet earn their keep. serverKeyMatches() returns null rather than false when sub.options.applicationServerKey is unavailable, because “I could not check” and “the key is wrong” demand opposite responses — the first should be ignored, the second should force a re-subscribe. And getRegistration() is called with no argument so it resolves against the current document’s scope; passing a hardcoded script URL is a frequent cause of a false no-registration on sites that moved their worker.
getSubscription() is silent. It never opens a dialog and never changes permission, which makes it safe to run before any UI decision — the same discipline described in detecting denied push permission without prompting.
Step 1 — Run the Sync Check on Load
Do the check after the page is interactive, not during it. Subscription reconciliation is never urgent enough to compete with first paint, and running it at load time on a cold service worker start can add hundreds of milliseconds of contention. Wait for navigator.serviceWorker.ready, then schedule the check in an idle callback.
function scheduleSync() {
const run = () => syncSubscriptionState().catch(err => {
console.warn('[push-sync] deferred', err);
});
if ('requestIdleCallback' in window) {
requestIdleCallback(run, { timeout: 4000 });
} else {
setTimeout(run, 1500);
}
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(scheduleSync);
}
Step 2 — Classify and Decide
The classifier turns the raw read into one of the five named states, then dispatches. Keep the classification pure and the side effects in the dispatcher: it makes the logic testable and stops a repair from firing twice when two tabs load at once.
async function syncSubscriptionState() {
const state = await readBrowserState();
if (state.kind === 'unsupported') return 'unsupported';
// Silent revocation — permission was flipped off outside your UI.
if (state.permission === 'denied') {
await reportState({ action: 'revoke', reason: 'permission-denied' });
localStorage.removeItem('push:endpoint');
return 'revoked';
}
if (state.permission !== 'granted') return 'not-subscribed';
// Ghost permission — granted, but nothing to send to.
if (state.kind === 'no-subscription' || state.kind === 'no-registration') {
await reportState({ action: 'revoke', reason: 'subscription-missing' });
localStorage.removeItem('push:endpoint');
return 'ghost-permission';
}
// Key rotated under us: the old endpoint cannot be signed for any more.
if (state.keyMatches === false) {
const fresh = await resubscribe(state.subscription);
await queueRegistration({ oldEndpoint: state.endpoint, subscription: fresh });
return 'rekeyed';
}
const known = localStorage.getItem('push:endpoint');
if (known !== state.endpoint) {
// Orphan endpoint or stale record — the server may not have this one.
await queueRegistration({ oldEndpoint: known, subscription: state.subscription });
return known ? 'stale-record' : 'orphan-endpoint';
}
return 'in-sync';
}
async function resubscribe(oldSub) {
const reg = await navigator.serviceWorker.ready;
try { await oldSub.unsubscribe(); } catch (_) { /* already gone */ }
return reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
}
The localStorage value is a cheap local mirror, not the authority. It exists so the common case — endpoint unchanged since last load — costs zero network. It can be wrong (cleared storage, a second device, a shared machine), which is exactly why a mismatch triggers a server call rather than a local repair.
Note that resubscribe() calls unsubscribe() first. Chromium refuses subscribe() with a different applicationServerKey while an existing subscription is live and throws InvalidStateError; unsubscribing clears the way. If your key rotation is planned rather than accidental, run it gradually as described in rotating VAPID keys without losing subscribers.
Step 3 — Debounce the Re-Registration Endpoint
A naive implementation posts the subscription on every load. With five open tabs and a user who refreshes, that is a write storm against a row that has not changed. Debounce on the client and make the server idempotent, so the worst case is wasted bytes rather than a corrupted record.
let registerTimer = null;
let pending = null;
function queueRegistration(payload) {
pending = payload;
clearTimeout(registerTimer);
return new Promise(resolve => {
registerTimer = setTimeout(async () => {
const body = pending;
pending = null;
resolve(await flushRegistration(body));
}, 2000);
});
}
async function flushRegistration({ oldEndpoint, subscription }) {
const body = {
oldEndpoint: oldEndpoint || null,
subscription: subscription.toJSON(),
permission: Notification.permission,
verifiedAt: new Date().toISOString()
};
try {
const res = await fetch('/api/push/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body)
});
if (!res.ok) throw new Error('register failed: ' + res.status);
localStorage.setItem('push:endpoint', subscription.endpoint);
localStorage.setItem('push:verifiedAt', body.verifiedAt);
return 'registered';
} catch (err) {
await enqueueOffline(body); // see the offline section below
return 'queued';
}
}
A cross-tab lock is worth adding if your traffic justifies it: write a timestamp to localStorage before flushing and skip the flush if another tab wrote one within the debounce window. BroadcastChannel does the same job more precisely where it is available.
Step 4 — Reconcile on the Server, Atomically
The server has one job: make the row match the endpoint the client just proved it holds, and do it in a single transaction so a concurrent send never reads a half-migrated record. Sending oldEndpoint alongside the new one is what lets the server migrate rather than insert-and-orphan — it preserves the subscriber id, and with it the topic opt-ins stored against that subscriber by subscriber segmentation & targeting.
CREATE TABLE push_subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id),
endpoint TEXT UNIQUE NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
server_key_id TEXT NOT NULL, -- which VAPID public key created it
status TEXT NOT NULL DEFAULT 'active', -- active | revoked | expired
last_verified TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_push_sub_status ON push_subscriptions (status, last_verified);
// POST /api/push/register
app.post('/api/push/register', async (req, res) => {
const { oldEndpoint, subscription, permission, verifiedAt } = req.body || {};
if (!subscription || !subscription.endpoint) return res.status(400).json({ error: 'bad payload' });
if (permission !== 'granted') return res.status(409).json({ error: 'permission not granted' });
const { endpoint, keys } = subscription;
const client = await pool.connect();
try {
await client.query('BEGIN');
if (oldEndpoint && oldEndpoint !== endpoint) {
// Migrate in place: keeps id, user_id and every topic opt-in attached to it.
await client.query(
`UPDATE push_subscriptions
SET endpoint = $1, p256dh = $2, auth = $3,
server_key_id = $4, status = 'active', last_verified = $5
WHERE endpoint = $6`,
[endpoint, keys.p256dh, keys.auth, process.env.VAPID_KEY_ID, verifiedAt, oldEndpoint]
);
}
await client.query(
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, server_key_id, last_verified)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (endpoint) DO UPDATE
SET p256dh = EXCLUDED.p256dh,
auth = EXCLUDED.auth,
server_key_id = EXCLUDED.server_key_id,
status = 'active',
last_verified = EXCLUDED.last_verified`,
[req.user?.id ?? null, endpoint, keys.p256dh, keys.auth, process.env.VAPID_KEY_ID, verifiedAt]
);
await client.query('COMMIT');
res.json({ ok: true });
} catch (err) {
await client.query('ROLLBACK');
res.status(500).json({ error: 'reconcile failed' });
} finally {
client.release();
}
});
The UPDATE then INSERT ... ON CONFLICT pair is deliberate. If the old row exists, the update renames it and the insert becomes a no-op update on the same row. If the old row was already pruned by a 410 Gone sweep — the mechanism described in handling 410 Gone responses at scale — the update touches nothing and the insert creates a fresh row. Both orders converge on the same final state, which is the property you want when the same request can arrive twice from a retried fetch.
The companion probe is even simpler and should be a cheap read:
// POST /api/push/sync → { known, current }
app.post('/api/push/sync', async (req, res) => {
const { endpointHash } = req.body || {};
const { rows } = await pool.query(
`SELECT status, server_key_id FROM push_subscriptions
WHERE encode(digest(endpoint, 'sha256'), 'hex') = $1 LIMIT 1`,
[endpointHash]
);
const row = rows[0];
res.json({
known: Boolean(row) && row.status === 'active',
current: Boolean(row) && row.server_key_id === process.env.VAPID_KEY_ID
});
});
Hashing the endpoint before it crosses the wire on a read path is a small privacy win — the probe never needs the raw value, and the raw endpoint is a capability token that can push to the user’s device.
Step 5 — Optimistic or Authoritative Reconciliation
There are two coherent policies and one incoherent middle ground. Optimistic reconciliation treats the browser as the truth: on every load the client posts its state and the server accepts it. Authoritative reconciliation treats the server as the arbiter: the client sends a cheap probe, the server compares, and only a disagreement triggers a write. Pick one per deployment and write it down, because mixing them produces rows that flap between two devices’ idea of the current endpoint.
Authoritative wins for any account that can hold more than one subscription, and one row per device is the normal case — see deduplicating push subscriptions across devices for the storage shape that supports it. Under an optimistic policy, a phone and a laptop each posting “this is the subscription for user 42” will fight over the row unless the schema already keys on endpoint rather than user, which is the fix in both policies. Authoritative reconciliation also gives you a natural place to stamp last_verified, and that timestamp is the input to every hygiene job you will later want to run.
Step 6 — Handle the Offline Case
The check runs on load, and a meaningful share of loads happen with no usable connection. If a repair fails because the network is down, dropping it means the drift persists until the next visit — which for a re-engagement audience may be weeks. Queue it instead.
const QUEUE_KEY = 'push:sync-queue';
async function enqueueOffline(body) {
const queue = JSON.parse(localStorage.getItem(QUEUE_KEY) || '[]');
queue.push(body);
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue.slice(-5)));
const reg = await navigator.serviceWorker.ready;
if ('sync' in reg) {
try { await reg.sync.register('push-sync-flush'); return; } catch (_) { /* fall through */ }
}
window.addEventListener('online', flushQueue, { once: true });
}
async function flushQueue() {
const queue = JSON.parse(localStorage.getItem(QUEUE_KEY) || '[]');
if (!queue.length) return;
const latest = queue[queue.length - 1]; // only the newest state matters
const res = await fetch('/api/push/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(latest)
});
if (res.ok) localStorage.removeItem(QUEUE_KEY);
}
Only the newest queued state is ever replayed. Subscription state is not an event log — replaying three stale endpoints in order just makes the server do three writes to arrive at the same row. Cap the queue at a handful of entries so a long offline session cannot grow it without bound, and remember that Background Sync is Chromium-only: the online listener is the portable fallback, and both together still leave iOS Safari relying on the next page load.
One more property is worth designing in deliberately: the sync path should be observable. Record which of the five states each check resolved to, as a counter keyed by state, and you get a dashboard that answers questions no send-side metric can. A rising ghost-permission rate usually means a browser release changed its storage-eviction behaviour, or that a recent deploy moved the service worker’s scope. A rising orphan-endpoint rate points at your own registration endpoint failing — a timeout, a 500, or a CSP change that broke the fetch. A rising stale-record rate is the push service rotating more aggressively than it used to. None of those show up as delivery failures until much later, and by then the cause is weeks in the past.
The counterpart metric is drift duration: the interval between the browser acquiring a new endpoint and your server learning about it. You can only approximate it, because the browser does not timestamp the rotation, but the gap between a row’s previous last_verified and the moment a migration lands is a serviceable proxy. Watch the p95 rather than the mean — the slowest repairs belong to subscribers who visit rarely, and it is precisely those subscribers a re-engagement campaign is trying to reach.
Configuration Reference
| Param | Type | Default | Notes |
|---|---|---|---|
syncIdleTimeout |
ms | 4000 |
Upper bound on the idle callback before the check runs anyway. |
registerDebounceMs |
ms | 2000 |
Collapses multi-tab and rapid-refresh writes into one. |
reconciliation |
optimistic | authoritative |
authoritative |
Must be the same across all clients of one backend. |
verifyMaxAgeDays |
integer | 7 |
How stale last_verified may get before the client re-probes. |
queueMaxEntries |
integer | 5 |
Offline queue cap; only the newest entry is replayed. |
serverKeyId |
string | — | Identifies which VAPID public key created a subscription. |
endpointHash |
sha256 hex | — | Sent on the read path so the raw endpoint stays on the device. |
Verification
- Open DevTools → Application → Service Workers and confirm the worker is activated, then check Push Messaging shows a subscription for the origin.
- In the console, run
await (await navigator.serviceWorker.ready).pushManager.getSubscription()and note theendpoint. - Compare it with the stored row:
SELECT endpoint, status, last_verified FROM push_subscriptions WHERE endpoint = '<paste>';— a miss means orphan endpoint or stale record. - Simulate ghost permission: Application → Storage → Clear site data with “unregister service workers” ticked, but leave the notification permission alone. Reload;
Notification.permissionstill readsgrantedwhilegetSubscription()returnsnull. - Simulate silent revocation: set notifications to Block in the site settings panel, reload, and confirm the sync check posts a revoke and the row moves to
status = 'revoked'. - Simulate the offline path: tick Network → Offline, force a re-registration, confirm the entry lands in
localStorage, then go back online and watch the flush return200.
Error & Edge-Case Matrix
| Condition | Cause | Fix |
|---|---|---|
InvalidStateError from subscribe() |
An existing subscription uses a different applicationServerKey |
Call unsubscribe() on the old subscription first, then re-subscribe. |
keyMatches is always null |
Engine does not expose sub.options.applicationServerKey |
Treat as unknown; fall back to a server-side server_key_id comparison. |
| Duplicate rows for one device | oldEndpoint not sent, so the server inserted instead of migrating |
Always send the previous endpoint; migrate inside one transaction. |
| Sync storm on every load | No debounce and no local endpoint mirror | Debounce the write and short-circuit when the mirror matches. |
| Row flaps between two endpoints | Optimistic policy with a schema keyed on user_id |
Key on endpoint; allow many rows per user. |
410 Gone on an endpoint the client says is live |
Server pruned before the client re-registered | Let the re-registration re-insert; never hard-delete during a sweep, mark status. |
| Revocation never recorded | Only pushsubscriptionchange was watched |
Read Notification.permission on every load; revocation fires no event. |
| Repair lost on a flaky connection | Failed fetch with no retry |
Queue the newest state and flush on online or a Background Sync tag. |
Cross-Browser Notes
getSubscription() behaves consistently everywhere, but the metadata around it does not. Chromium exposes sub.options.applicationServerKey as an ArrayBuffer, which makes the key comparison a local operation. Firefox exposes it in current releases but older builds return undefined, and WebKit has historically omitted options entirely — hence the three-valued keyMatches. Where you cannot compare locally, store a server_key_id server-side at registration and compare there instead.
expirationTime is null in every major browser today; no shipping push service sets it. Treat a non-null value as a bonus signal, never as your expiry mechanism, and read detecting stale push subscriptions in the browser for the signals that do work.
Safari on iOS 16.4+ only has push inside a Home-Screen-installed web app, and the subscription dies when the app is removed from the Home Screen with no client-side notice whatsoever — the row simply starts returning 410 Gone. Because there is no background execution outside the installed app, an iOS subscriber’s drift window is exactly the gap between visits. Chromium fires pushsubscriptionchange for some rotations and not others; Firefox fires it more reliably but with a different payload shape. None of that changes the design here: the sync-on-load check is the floor, and events are an optimisation on top of it.
Related
- Back to Frontend Permission UX & Subscription Flows — the parent guide covering capability detection, consent and the first subscription.
- Handling pushsubscriptionchange on the client — the event-driven half of this problem, including the browsers that never fire it.
- Detecting stale push subscriptions in the browser — client-side signals that a subscription is dead before the server sees a 410.
- Subscriber segmentation & targeting — the topic opt-ins that must survive an endpoint migration intact.
- Handling 410 Gone responses at scale — the server-side counterpart that retires endpoints the push service has dropped.
FAQ
Why is Notification.permission granted when getSubscription() returns null?
Because they are stored in different places. Notification permission is an origin-level browser setting; the subscription lives in the service worker registration’s storage. Clearing site data, storage eviction under disk pressure, or unregistering the worker destroys the subscription while leaving the permission intact. Always check both — permission alone is not proof that you have somewhere to send.
How often should the client re-check its subscription state?
Once per page load, deferred into an idle callback, is the right default. The read itself is local and costs nothing; only a detected mismatch triggers a network call. If you want to reduce even the probe, stamp a last_verified timestamp locally and skip the probe when it is less than about seven days old.
Should the browser or the server win when they disagree?
The browser wins on what the subscription is — it is the only party that can create one. The server wins on what to do about it, because only the server can see the other devices on the same account. In practice that means authoritative reconciliation: the client reports, the server decides and returns a verdict, and the client acts on that verdict.
Do I need to send the old endpoint when re-registering?
Yes, whenever you have it. Without the previous endpoint the server cannot tell a rotation from a brand-new device, so it inserts a second row and the original becomes an orphan that keeps receiving sends until a 410 retires it. Sending both lets the server migrate the existing row in one transaction and keep the subscriber id, and with it every topic opt-in and consent record attached to it.