Deduplicating Push Subscriptions Across Devices
A single person can hold six valid push subscriptions at once, and none of them is a duplicate. Knowing which multiplicity is a data bug and which is simply how the Push API works is the whole problem.
Quick Answer
Enforce uniqueness on the endpoint URL — specifically on a hash of it — and on nothing else. Two rows sharing an endpoint are a bug; two rows sharing a user_id are normal and expected. Never attempt to merge subscriptions by IP address, user agent string, screen dimensions or any other device fingerprint: those signals collide across profiles on one machine and diverge across sessions on one browser, so they produce both false merges and false splits. Solve the “user got buzzed five times” complaint at the fan-out layer, not the storage layer.
| Situation | Rows | Correct? |
|---|---|---|
| Same endpoint, two rows | 2 | No — unique-constraint failure |
| Same user, Chrome and Firefox | 2 | Yes |
| Same user, two Chrome profiles on one laptop | 2 | Yes |
| Same user, normal window and incognito | 2 | Yes, and the second is short-lived |
| Same browser after a service worker update rotated the endpoint | 2 | Only until the old one is revoked |
The Endpoint Is the Subscriber
The Push API scopes a subscription to a service worker registration, which is scoped to an origin inside a single storage partition. Two Chrome profiles have separate partitions, so pushManager.subscribe() in each returns a different endpoint from the same push service, on the same physical machine, for the same signed-in human. There is no API that lets you observe that relationship from the browser, and there is no header in the push protocol that carries it.
That is not an accident of implementation, it is the privacy model. The endpoint is deliberately opaque and unlinkable. Anything you build that tries to reconstruct device identity — canvas fingerprints, IP plus user-agent tuples, a localStorage device ID that survives a profile copy — is both fragile and adversarial to the design, and it will be wrong in exactly the cases users notice: shared laptops merge two people, and a browser update splits one person in two.
So the storage rule is simple. UNIQUE (endpoint_hash) and nothing else, as laid out in the subscription storage and lifecycle guide. The user_id column is a nullable attribute of the endpoint, not part of its identity.
Where Duplicate Rows Actually Come From
Genuine duplicates — two rows with the same endpoint — have a small number of causes, and all of them are your code.
The normalisation bug deserves attention because it survives code review. Endpoint URLs are opaque and case-sensitive; running one through new URL(endpoint).href can append a trailing slash to a path-less URL, and running it through a lowercasing helper destroys the base64url registration ID entirely. If your web tier hashes the raw string and your worker hashes a normalised one, you get two digests, two rows, and two notifications per send. Hash the exact bytes the browser gave you, in one shared helper, imported everywhere.
Incognito is the case people forget. A private window creates a separate storage partition with its own service worker registration, so a subscription taken there is genuinely distinct — and it is destroyed when the last private window closes, without any pushsubscriptionchange event and often without a 410 for hours. Those rows age out through the staleness sweep rather than through revocation, which is one reason the pruning job needs a time-based path and not just a status-code path.
Designing the Constraint
-- Identity: the endpoint, and only the endpoint.
ALTER TABLE push_subscription
ADD CONSTRAINT push_subscription_hash_uniq UNIQUE (endpoint_hash);
-- Multiplicity is expected, but cap it so a loop cannot run away.
CREATE INDEX push_subscription_user_live
ON push_subscription (user_id, last_success_at DESC)
WHERE state IN ('pending', 'active') AND deleted_at IS NULL;
-- Find true duplicates left behind by an older schema before adding the constraint.
SELECT encode(endpoint_hash, 'hex') AS hash, count(*), array_agg(id ORDER BY created_at)
FROM push_subscription
GROUP BY endpoint_hash
HAVING count(*) > 1
ORDER BY count(*) DESC
LIMIT 100;
Merge legacy duplicates by keeping the oldest row — it holds the true created_at — while copying forward the newest p256dh, auth_sealed and user_id. Adding the unique constraint before that merge will fail, and adding it as NOT VALID will not help because unique constraints cannot be deferred that way in PostgreSQL; build a UNIQUE INDEX CONCURRENTLY first, then attach it with ALTER TABLE ... ADD CONSTRAINT ... USING INDEX.
On the write path, the anonymous-to-signed-in transition is the case that produces duplicates under a composite key. A visitor subscribes while logged out, so the row has user_id IS NULL. They sign in, the client re-posts the same subscription, and a composite unique key on (user_id, endpoint_hash) sees a different tuple and inserts. With the constraint on the hash alone, the upsert simply fills in user_id. Guard the reverse direction too: a shared machine where a second person signs in should reassign the endpoint, not create a row, so user_id = EXCLUDED.user_id on an explicit re-association and COALESCE only for the anonymous case.
Endpoint rotation after a service worker update is handled by the pushsubscriptionchange handshake rather than by the constraint. The client reports the old endpoint alongside the new one, you copy the user_id and preference rows across and revoke the old row; the client-side contract for that is described in handling pushsubscriptionchange on the client. The same pattern applies when you rotate VAPID keys without losing subscribers.
Controlling Fan-Out
Once storage is correct you still have the user-facing problem: a person with five endpoints gets five buzzes. This is a delivery policy, and it has three defensible answers depending on the message.
Send to all endpoints. Correct for anything the user might act on from any device — a security alert, a two-factor prompt, a breaking-news item. Pair it with a tag in showNotification() options so a device that receives two related messages replaces rather than stacks them.
Send to the primary endpoint only. Correct for marketing and digest sends. “Primary” is the endpoint with the most recent last_success_at, which approximates the device the person actually uses. If it fails, fall back to the next most recent within the same job.
Send to all, suppress duplicates client-side. Send to every endpoint but include a shared dedupeKey, and have the service worker check a small IndexedDB ledger before calling showNotification(). This handles the case where the same person has two browsers open on one screen. It only works within a browser profile, so it complements rather than replaces server-side collapsing.
The selection query and the collapse guard are small:
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const TARGETS = `
SELECT id, endpoint, p256dh, auth_sealed, auth_key_version, push_service
FROM push_subscription
WHERE user_id = $1
AND state IN ('pending', 'active')
AND deleted_at IS NULL
ORDER BY last_success_at DESC NULLS LAST, created_at DESC
LIMIT $2;
`;
/**
* @param mode 'all' for actionable alerts, 'primary' for marketing sends.
*/
export async function resolveTargets(userId, { mode = 'all', maxEndpoints = 8 } = {}) {
const { rows } = await pool.query(TARGETS, [userId, maxEndpoints]);
return mode === 'primary' ? rows.slice(0, 1) : rows;
}
/** Collapse repeats of one logical event for one user inside a window. */
export async function claimCollapseSlot(redis, userId, topic, windowSec = 60) {
const key = `push:collapse:${userId}:${topic}`;
const ok = await redis.set(key, '1', { NX: true, EX: windowSec });
return ok === 'OK';
}
maxEndpoints is a safety valve, not a policy. Eight live endpoints for one account is already unusual; a hundred means a registration loop is inserting rows, and capping the fan-out stops that bug from becoming a message-volume incident. Log whenever the cap trims a result.
At the protocol level, RFC 8030 also offers a Topic header. A push service replaces any undelivered message carrying the same topic for the same endpoint, which collapses a backlog on a device that has been offline. It deduplicates within one endpoint, not across a user’s endpoints, so it complements the server-side collapse rather than replacing it. Combine both with the per-user limits described in notification frequency & fatigue management so the collapse window and the frequency cap are not fighting each other.
Gotchas & Edge Cases
getSubscription()returning a cached object is not proof of a live endpoint. The browser can hand back a subscription the push service has already discarded. Treat the client’s report as alast_seen_atrefresh, never as a resurrection of a revoked row.- Signing out should not delete endpoints. Set
user_id = NULLand keep the row: the browser still holds the subscription, and deleting it means the next sign-in creates a “new subscriber” that inflates acquisition metrics. - A shared kiosk browser accumulates user reassignments. Record reassignments in an audit table rather than overwriting
user_idsilently, or churn analysis will show phantom subscriber movement. - Batching per user, not per endpoint, changes throughput maths. A fan-out of eight endpoints is eight HTTP requests to possibly three different services; size the dispatch batches by endpoint count, not recipient count.
- The
tagoption only deduplicates within one browser profile. Two profiles on one screen both display, because neither can see the other’s notification list.
Related
- Pruning expired push subscriptions from your database — how the extra rows left by endpoint rotation eventually leave the table.
- Subscription State Synchronization — the client-side reporting that keeps endpoint rotation from creating orphans.
- Subscriber segmentation & targeting — segment definitions have to count people, not endpoints.
Back to Push Subscription Storage & Lifecycle
FAQ
Can I detect that two endpoints belong to the same device?
Not reliably, and you should not try. Storage partitions are isolated by design, so two profiles on one machine are indistinguishable from two machines. Fingerprinting approaches merge separate people on shared hardware and split one person after a browser update. Associate endpoints through the authenticated user_id instead, and accept that logged-out subscribers stay separate.
Should signing out delete the push subscription?
No. Clear user_id so the row becomes anonymous, but keep the endpoint. The browser still holds a live subscription and will re-report it; deleting the row means the next sign-in looks like a brand-new subscriber and your acquisition numbers drift upward. Only an explicit opt-out or a 410 Gone should revoke the row.
How many endpoints per user is too many?
Above roughly eight live endpoints for one account, suspect a registration loop rather than a genuinely multi-device user. Cap the fan-out, log when the cap trims a send, and check whether the client is inserting on every page load instead of upserting on the endpoint hash.