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()flowprocess.env.VAPID_PUBLIC_KEY/process.env.VAPID_PRIVATE_KEY— never hardcodedlocalhost)
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.
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
- In DevTools → Application → Service Workers, confirm the worker is active before subscribing.
- Submit the preference center with a couple of topics checked and watch the
POST /api/push/subscribebody in the Network tab — it should carrysubscription,topics,attributes, andconsentAt. - Query the database:
SELECT * FROM subscription_tags WHERE subscriber_id = <id>;and confirm one row per checked topic. - Run a segment query and confirm only matching endpoints come back.
- 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.
Related
- Back to Frontend Permission UX & Subscription Flows — the parent guide covering the full permission and subscription lifecycle.
- Building notification preference segments — turn the stored tags into named segments and keep them in sync on
pushsubscriptionchange. - Push personalization and segmentation — the campaign-side view: enriching segments with behaviour and targeting sends.
- Opt-out and preference centers — let subscribers edit the same topic choices later and unsubscribe cleanly.
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.