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
One user, many legitimate endpoints A single user account connects to three separate browser contexts: Chrome on a laptop, a second Chrome work profile on the same machine, and Firefox on a phone. Each produces its own push endpoint, so three rows are correct rather than duplicated. user 8f21… one person Chrome · laptop default profile Chrome · work profile same laptop Firefox · phone autopush service fcm.googleapis.com/…/aB1 row 1 fcm.googleapis.com/…/kQ9 row 2 updates.push…mozilla.com/…/Tz4 row 3 Merging on a device fingerprint would silence rows 1 and 2 — two profiles on one machine are two subscribers.
Three rows for one person, all correct. The endpoint is the subscriber; the user is only an attribute of it.

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.

True duplicates versus legitimate multiplicity Left column lists three bugs that create two rows for one endpoint: inserting instead of upserting, hashing a normalised URL in one service only, and a composite unique key that includes user id. Right column lists three legitimate reasons one user holds several distinct endpoints. Same endpoint, many rows — a bug Different endpoints — correct INSERT instead of upsert on re-registration every page load can add a row one service hashes a normalised URL trailing slash or lowercasing shifts the digest composite key (user_id, endpoint_hash) anonymous then signed-in creates a second row a second browser profile or device separate storage partition, separate endpoint a service worker update rotated the endpoint old row lives until it returns 410 applicationServerKey rotation re-subscribe issues a fresh registration Fix the left column with a constraint. Manage the right column at fan-out time.
Only the left column is deduplication. The right column is a delivery policy question wearing a data-modelling disguise.

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.

Per-user fan-out collapsing One user with five live endpoints passes through a collapse window keyed on user and topic. Without collapsing the person receives five separate notifications; with collapsing only the primary endpoint is used and the person receives one. one user 5 live endpoints endpoint 1 endpoint 2 endpoint 3 most recent 2xx endpoint 4 endpoint 5 collapse window key: user + topic + 60 s policy off — 5 buzzes one per endpoint collapsed — 1 buzz primary endpoint only Storage keeps all five rows. Only the delivery policy decides how many of them get used.
The collapse decision belongs to the send job. Deleting rows to achieve the same effect throws away devices the user still relies on.

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 a last_seen_at refresh, never as a resurrection of a revoked row.
  • Signing out should not delete endpoints. Set user_id = NULL and 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_id silently, 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 tag option only deduplicates within one browser profile. Two profiles on one screen both display, because neither can see the other’s notification list.

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.