Building Notification Preference Segments

This guide turns the topic opt-ins you captured at subscribe time into named, reusable segments — and shows how to keep those segments accurate when the browser rotates a subscription endpoint via pushsubscriptionchange.

Quick answer

A preference segment is a query, not a stored list. Store each subscriber once and each topic opt-in as a tag row keyed to that subscriber; then define a segment as a WHERE/JOIN over tags plus attributes. The one hard part is endpoint rotation: when pushsubscriptionchange fires, the old endpoint dies and a new one appears, so you must migrate the tags to the new subscription or the user silently drops out of every segment. Key your data on a stable subscriber id, not on the volatile endpoint, and segments stay correct through rotation.

The reason to define segments as queries rather than precomputed audience lists is freshness. A materialised list goes stale the instant a subscriber opts out, rotates an endpoint, or hits a 410 Gone; a query evaluated at send time always reflects the current state. The cost is a join per send, which an index on the tag column makes trivial even at scale. Reserve materialisation for cases where a send must hit hundreds of thousands of rows under tight latency, and even then snapshot the query result moments before sending rather than maintaining a list that drifts.

The data model

Use two tables: one row per subscriber, many tag rows per subscriber. Critically, give each subscriber a stable surrogate id and treat the endpoint as a mutable attribute, not the primary key your tags point at. This is what makes rotation survivable. The capture side of this model — the preference UI and the POST that fills these tables — is covered in the parent subscriber segmentation and targeting guide.

CREATE TABLE subscribers (
  id          BIGSERIAL PRIMARY KEY,     -- stable; tags point here
  endpoint    TEXT UNIQUE NOT NULL,      -- mutable; changes on rotation
  p256dh      TEXT NOT NULL,
  auth        TEXT NOT NULL,
  locale      TEXT,
  platform    TEXT,
  plan        TEXT,
  active      BOOLEAN NOT NULL DEFAULT true,
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE subscription_tags (
  subscriber_id BIGINT NOT NULL REFERENCES subscribers(id) ON DELETE CASCADE,
  tag           TEXT NOT NULL,
  opted_in_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (subscriber_id, tag)
);

CREATE INDEX idx_tags_tag ON subscription_tags (tag);

Segment definitions

Store segment definitions as named, parameterised queries so marketing and engineering share one source of truth. A definition is just its filter predicate; the rows it returns change as subscribers come and go.

-- Segment: "engaged deal hunters"
-- tag = 'deals' AND active subscribers on either platform
SELECT s.id, s.endpoint, s.p256dh, s.auth
FROM subscribers s
JOIN subscription_tags t ON t.subscriber_id = s.id
WHERE t.tag = 'deals'
  AND s.active = true;

-- Segment: "security-critical desktop users"
SELECT s.id, s.endpoint, s.p256dh, s.auth
FROM subscribers s
JOIN subscription_tags t ON t.subscriber_id = s.id
WHERE t.tag = 'security'
  AND s.platform = 'desktop'
  AND s.active = true;

For composite segments (subscribers in several topics), aggregate and assert the count of matched tags:

-- Subscribers opted into BOTH 'deals' AND 'digest'
SELECT s.id, s.endpoint
FROM subscribers s
JOIN subscription_tags t ON t.subscriber_id = s.id
WHERE t.tag IN ('deals', 'digest')
  AND s.active = true
GROUP BY s.id, s.endpoint
HAVING COUNT(DISTINCT t.tag) = 2;

Keeping segments in sync on pushsubscriptionchange

When a browser migrates push servers it fires pushsubscriptionchange in the service worker, invalidating the old endpoint. If you key tags to the endpoint, the user vanishes from every segment. Because tags are keyed to the stable subscriber_id, you only need to update the endpoint and keys on the existing row — the tags follow automatically.

This is the single most important reason the data model separates a stable subscriber_id from the mutable endpoint. Endpoint rotation is not a rare edge case — it happens routinely when a browser updates, when its push service rebalances, or when the user clears certain site data — and every rotation is an opportunity to silently lose a subscriber’s hard-won topic preferences. A subscriber who opted into three topics, rotates, and is re-inserted as a fresh row is now opted into nothing, will receive no targeted sends, and looks to your analytics like churn when nothing of the sort happened. Getting rotation right is what keeps your segment counts honest over months.

// service-worker.js
self.addEventListener('pushsubscriptionchange', (event) => {
  event.waitUntil((async () => {
    const newSub = await self.registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: APPLICATION_SERVER_KEY // base64url VAPID public key bytes
    });

    await fetch('/api/push/rotate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        oldEndpoint: event.oldSubscription?.endpoint ?? null,
        newSubscription: newSub
      })
    });
  })());
});

On the server, find the existing subscriber by the old endpoint and update it in place so the tags stay attached:

-- Migrate the subscription onto the existing subscriber row; tags are untouched
UPDATE subscribers
SET endpoint   = $1,   -- new endpoint
    p256dh     = $2,
    auth       = $3,
    updated_at = now()
WHERE endpoint = $4;   -- old endpoint

Numbered build steps

  1. Model on a stable id. Make subscribers.id the foreign key target for tags and treat endpoint as mutable. This single decision is what survives rotation.
  2. Write tags transactionally. When a subscriber opts in, upsert the subscriber and insert tag rows in one transaction so you never have a subscriber without tags or vice versa.
  3. Express segments as queries. Store each segment as a named predicate, not a materialised list, so it reflects current opt-ins automatically.
  4. Handle rotation by update, not insert. On pushsubscriptionchange, look up by old endpoint and UPDATE the row. Inserting a new row orphans the tags.
  5. Mark, don’t delete, on 410 Gone. When the push service returns 410, set active = false rather than deleting, so segment queries exclude dead endpoints while preserving consent history. See handling 410 Gone responses at scale.

Gotchas & edge cases

  • event.oldSubscription can be null. Some browsers omit it on pushsubscriptionchange, leaving you no old endpoint to match. Fall back to matching by an app-issued client id stored at subscribe time, or you will create a duplicate subscriber and lose the tags.
  • Inserting on rotation orphans tags. The most common segmentation bug: treating a rotated subscription as a brand-new subscriber. Always update the existing row by old endpoint (or client id).
  • Composite segments need COUNT(DISTINCT tag). A naive tag IN (...) returns subscribers with any of the tags, not all. Use the HAVING COUNT(DISTINCT tag) = N pattern for AND semantics.
  • Deleting on 410 destroys consent history. Soft-delete with an active flag so you can prove what a user opted into and when, which GDPR and CCPA audits require.
  • Tags drift from the UI. If users edit preferences later through your opt-out and preference centers, make that edit the same upsert/delete on subscription_tags so the segment query and the UI never disagree.

FAQ

Should I store segments as queries or as precomputed lists?

As queries. A query evaluated at send time always reflects current opt-ins, opt-outs, and retired endpoints, whereas a precomputed list goes stale the moment any of those change. With an index on the tag column the per-send join is cheap; only materialise for very large, latency-critical sends, and snapshot just before sending rather than maintaining a drifting list.

Why not key the tags directly to the push endpoint?

Because the endpoint is mutable. Browsers rotate it on pushsubscriptionchange, and if your tags point at the endpoint they orphan on every rotation, silently dropping the subscriber from all segments. Keying tags to a stable surrogate subscriber_id and treating the endpoint as an updatable attribute keeps preferences attached through rotation.

How do I select subscribers opted into several topics at once?

Filter on the tags with tag IN (...), group by the subscriber, and assert the count with HAVING COUNT(DISTINCT tag) = N. A bare IN returns subscribers with any of the listed tags; the HAVING clause is what enforces AND semantics across all of them.