Subscriber Segmentation & Targeting

Untargeted push is a fast route to mass unsubscribes. The moment to fix that is at subscribe time, while the user is engaged and willing to tell you what they want. By pairing a lightweight preference center with the subscription handshake, you capture topic opt-ins and client-side attributes, attach them as tags to the PushSubscription, and sync everything to a backend schema you can query when it is time to send. The result is segments — “price-drop watchers in EU”, “release-notes subscribers on desktop” — instead of one undifferentiated blast list.

This guide lives inside the broader Frontend Permission UX & Subscription Flows architecture. It focuses on the capture-and-store half of segmentation; the campaign-side targeting and behavioural enrichment live in push personalization and segmentation.

The economic case for capturing segments early is straightforward. A subscriber who only ever wanted security alerts but receives three marketing pushes a week will block you, and a blocked subscriber is far harder and rarer to recover than one you simply send less often. Every irrelevant notification spends trust you cannot easily refill. Capturing topic intent at the exact moment a user opts in — when they are already thinking about what this site will send them — costs almost nothing and pays off for the entire lifetime of the subscription. Bolting segmentation on later means inferring preferences from behaviour, which is noisier, slower, and never as clean as a checkbox the user ticked themselves.

Prerequisites

  • pushManager.subscribe() flow
  • process.env.VAPID_PUBLIC_KEY / process.env.VAPID_PRIVATE_KEY — never hardcoded
  • localhost)

What a Segment Is Made Of

A segment is a query over two kinds of data captured at subscribe time. Topic opt-ins are explicit user choices — “deals”, “product updates”, “security alerts” — surfaced as toggles. Attributes are client-side facts you can read without asking: locale (navigator.language), platform (parsed from the user agent), timezone (Intl.DateTimeFormat().resolvedOptions().timeZone), and your own app context (plan tier, current page section). Both travel alongside the PushSubscription to the server, where they become columns and rows you can filter.

Keep the capture honest. Topics are consent; do not pre-check boxes you have not earned, and log every choice so it stands up to a GDPR or CCPA audit. Attributes are conveniences, not surveillance — read what the platform already exposes and avoid fingerprinting.

There is also a design distinction worth holding onto: topics are durable and attributes are volatile. A topic opt-in expresses a stable intent — “I want deal alerts” — that remains true until the user changes it, so it belongs in a join table you treat as the source of truth. Attributes like the current page section or even locale can change between sessions, so capture them as a snapshot at subscribe time and refresh them opportunistically rather than treating any single reading as permanent. Mixing the two — for instance storing a transient “viewing checkout” flag as if it were a lasting preference — produces segments that look precise but target the wrong people a week later. Decide for each field whether it is a standing preference or a moment-in-time signal, and store it accordingly.

Segmentation capture data flow A preference center collects topic opt-ins and reads client attributes. These are bundled with the PushSubscription and posted to the backend, where a subscribers table and a tags table support segment queries. Preference Center topic opt-ins + locale, platform, tz PushSubscription endpoint + keys + tags payload subscribers endpoint, locale, plan subscription_tags topic opt-ins Segment query JOIN on filters
Preference choices and client attributes ride along with the PushSubscription, land in a subscribers table plus a tags table, and become a segment query.

Step 1 — Build the Preference Center UI

Surface topics as a small set of clearly labelled checkboxes. Keep the list short — five to seven topics is plenty — and make none of them pre-selected unless a default opt-in is a deliberate, disclosed choice. This is the same accessibility discipline as your opt-out and preference centers; the difference is that here it runs at opt-in rather than opt-out.

const TOPICS = [
  { id: 'deals',    label: 'Deals & price drops' },
  { id: 'updates',  label: 'Product updates' },
  { id: 'security', label: 'Security alerts' },
  { id: 'digest',   label: 'Weekly digest' }
];

function renderPreferenceCenter(mount) {
  mount.innerHTML = `
    <fieldset>
      <legend>What should we notify you about?</legend>
      ${TOPICS.map(t => `
        <label>
          <input type="checkbox" name="topic" value="${t.id}"> ${t.label}
        </label>`).join('')}
    </fieldset>
    <button type="button" id="enable-push">Enable notifications</button>
  `;
}

function selectedTopics(mount) {
  return [...mount.querySelectorAll('input[name="topic"]:checked')].map(i => i.value);
}

Step 2 — Read Client-Side Attributes

Collect a small, honest set of attributes from APIs the platform already exposes. These become the non-topic filters for your segments.

function collectAttributes() {
  const ua = navigator.userAgent;
  const platform = /Mobi|Android|iPhone/.test(ua) ? 'mobile' : 'desktop';
  return {
    locale: navigator.language || 'en',
    platform,
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    // App-supplied context — read from your own state, not from fingerprinting:
    plan: window.__APP_PLAN__ || 'free',
    section: location.pathname.split('/')[1] || 'home'
  };
}

Step 3 — Subscribe and Send Tags Alongside the Subscription

Run the subscribe and attach the tags in a single POST so the server never sees an endpoint without its segment data. Only call subscribe() after permission is granted and behind a real user gesture.

async function subscribeWithTags(mount) {
  if (Notification.permission !== 'granted') {
    const result = await Notification.requestPermission();
    if (result !== 'granted') return; // honour the decision; consider recovery later
  }

  const reg = await navigator.serviceWorker.ready;
  const subscription = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(window.__VAPID_PUBLIC_KEY__)
  });

  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      subscription,
      topics: selectedTopics(mount),
      attributes: collectAttributes(),
      consentAt: new Date().toISOString()
    })
  });
}

The applicationServerKey here is the base64url VAPID public key as bytes; the private key never leaves the server.

Step 4 — Define the Backend Tag Schema

Store the subscription and its attributes in one row, and topic opt-ins as rows in a join table so a subscriber can carry many tags and you can query them efficiently. Index the tag column — segment queries filter on it constantly.

CREATE TABLE subscribers (
  id          BIGSERIAL PRIMARY KEY,
  endpoint    TEXT UNIQUE NOT NULL,
  p256dh      TEXT NOT NULL,
  auth        TEXT NOT NULL,
  locale      TEXT,
  platform    TEXT,          -- 'mobile' | 'desktop'
  timezone    TEXT,
  plan        TEXT,
  consent_at  TIMESTAMPTZ NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  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,        -- e.g. 'deals', 'security'
  opted_in_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (subscriber_id, tag)
);

CREATE INDEX idx_tags_tag ON subscription_tags (tag);

The two-table shape is deliberate. Putting topics in their own join table rather than, say, a comma-separated string column on subscribers means a single indexed lookup answers “who is opted into deals?” without scanning and parsing every row, and adding a new topic never requires a schema migration. The unique constraint on endpoint lets you upsert idempotently when the same browser re-subscribes, and the ON DELETE CASCADE guarantees that retiring a subscriber cleans up its tags automatically. These are small decisions, but they are the difference between a segment query that stays fast at a million subscribers and one that degrades into a full-table scan.

A segment is then a join. For example, “deals subscribers on mobile in the fr locale”:

SELECT 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.platform = 'mobile'
  AND s.locale LIKE 'fr%';

Step 5 — Sync Idempotently and Keep Topics Editable

The subscribe-time POST is the first write, not the last. Browsers rotate endpoints, users change their minds, and your preference center will let them edit topics long after they first opted in. Make every write idempotent so retries and re-subscribes never create duplicates or lose consent history.

On the server, upsert the subscriber on endpoint and reconcile the tag set in one transaction. The pattern is: write the subscriber row, then bring subscription_tags into line with the topics the client just sent — insert the new ones, remove the deselected ones, and leave untouched ones alone.

-- 1. Upsert the subscriber, returning the stable id
INSERT INTO subscribers (endpoint, p256dh, auth, locale, platform, timezone, plan, consent_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (endpoint) DO UPDATE
  SET p256dh = EXCLUDED.p256dh,
      auth   = EXCLUDED.auth,
      locale = EXCLUDED.locale,
      updated_at = now()
RETURNING id;

-- 2. Add newly selected topics (idempotent)
INSERT INTO subscription_tags (subscriber_id, tag)
SELECT $1, unnest($2::text[])
ON CONFLICT (subscriber_id, tag) DO NOTHING;

-- 3. Remove topics the user deselected
DELETE FROM subscription_tags
WHERE subscriber_id = $1
  AND tag <> ALL($2::text[]);

Because the same reconcile runs whether the call comes from the first subscribe or a later preference edit, you have exactly one code path keeping the database and the UI in agreement. The mechanics of keeping tags attached through endpoint rotation — the one case where the endpoint key alone is not enough — are detailed in building notification preference segments.

Configuration Reference

Param Type Default Notes
topics string[] [] Checked topic ids; empty means a general/transactional-only subscriber.
attributes.locale string navigator.language Used for language and region targeting.
attributes.platform string derived mobile or desktop, parsed from the user agent.
attributes.timezone string IANA tz Drives send-time localization.
consentAt ISO timestamp now Recorded for audit; never omit.
userVisibleOnly boolean true Required by Chromium for web push.

Verification

  1. In DevTools → Application → Service Workers, confirm the worker is active before subscribing.
  2. Submit the preference center with a couple of topics checked and watch the POST /api/push/subscribe body in the Network tab — it should carry subscription, topics, attributes, and consentAt.
  3. Query the database: SELECT * FROM subscription_tags WHERE subscriber_id = <id>; and confirm one row per checked topic.
  4. Run a segment query and confirm only matching endpoints come back.
  5. Send a test push to the segment via DevTools → Application → Push and verify only the intended subscriber receives it.

Error & Edge-Case Matrix

Condition Cause Fix
Subscription saved without tags Tags posted in a separate, later request that failed Send tags in the same POST as the subscription; make the write transactional.
Duplicate subscriber rows Endpoint changed but you inserted instead of upserting INSERT ... ON CONFLICT (endpoint) DO UPDATE keyed on endpoint.
Stale tags after a re-subscribe pushsubscriptionchange produced a new endpoint and orphaned old tags Migrate tags to the new row; see building notification preference segments.
Empty segments Over-narrow filters or attributes never captured Verify attribute capture; treat missing attributes as wildcards in queries.
Consent not auditable consent_at missing or not per-topic Store opted_in_at on each tag row and consent_at on the subscriber.

Cross-Browser Notes

The capture flow is portable, but the attributes differ. navigator.language is reliable everywhere; user-agent parsing for platform is least reliable in embedded webviews and on iOS, where Safari masks some details. On Safari (iOS 16.4+) push only works for Home-Screen-installed web apps, so capture display-mode: standalone as an attribute to avoid building segments that can never be reached. Firefox and Chromium behave identically for the subscription payload itself.

One subtle consequence for segment accuracy: an iOS subscriber who later removes your web app from the Home Screen loses push silently, and the next send to that endpoint returns 410 Gone rather than any client-side signal. Build your retirement logic to treat 410 uniformly across browsers — flag the subscriber inactive and exclude them from segments — rather than special-casing platforms. The send path is the same regardless of which browser produced the subscription, so segment hygiene happens at the delivery layer, not at capture. Likewise, do not assume desktop Chrome and Android Chrome behave identically for engagement: the same topic opt-in performs very differently by platform, which is exactly why platform is worth capturing as a segment attribute in the first place.

FAQ

Where should I store topic opt-ins — on the subscription or in my own database?

In your own database, keyed by the subscription’s endpoint. The PushSubscription object itself has no place to persist custom tags; you collect them in the browser and POST them alongside the subscription to a server-side schema where they become queryable segment data.

What client-side attributes are safe to capture without consent concerns?

Read only what the platform already exposes for legitimate function: navigator.language for locale, the IANA timezone from Intl.DateTimeFormat, a coarse platform string from the user agent, and your own app context such as plan tier. Avoid combining signals into a device fingerprint, and always log explicit consent for topic opt-ins.

How many topics should a preference center offer?

Five to seven is the sweet spot. Too few and segments are coarse; too many and users skip the choice or feel overwhelmed, which lowers opt-in completion. Group related notifications under a single topic rather than exposing every message type.

Do I need to pre-check any topics?

Only if a default opt-in is a deliberate, disclosed business decision — and even then, transactional/security categories are the safest defaults. Pre-checking marketing topics by default erodes trust and can fail consent audits under GDPR and CCPA.