Push Personalization & Segmentation: Engineering Reference

Broadcasting one identical message to every subscriber is the fastest way to train people to ignore your notifications and then revoke permission. Personalization and segmentation turn a single send into a set of targeted, relevant deliveries — each shaped by who the subscriber is, what they have done, and when they are awake. This guide covers the data model, the query layer, dynamic payload templating, timezone-aware scheduling, and frequency capping you need to ship a production segmentation engine.

Prerequisites

  • p256dh, and auth keys per subscriber (see the Web Push & Subscription Lifecycle reference).
  • process.env.VAPID_PUBLIC_KEY and process.env.VAPID_PRIVATE_KEY — never hardcode the public key in server code.
  • delivery analytics instrumentation.
  • fetch/templating examples and the web-push library.

Attribute segments vs behavioural segments

Segmentation splits your subscriber base into addressable groups. Two complementary strategies drive almost every campaign.

Attribute segments are defined by static or slowly-changing properties: locale, plan tier, signup source, country, device platform. They answer who the subscriber is. Attribute segments are cheap to query because the values live directly on the subscriber row and rarely change.

Behavioural segments are defined by what the subscriber does over time: pages viewed, products added to cart, last session date, notification click history. They answer how engaged the subscriber is. Behavioural segments are more expensive — they require aggregating an event stream — but they are where the engagement lift lives. The dedicated guide on segmenting subscribers by behaviour covers RFM-style recency/frequency/engagement scoring in depth.

Most high-performing campaigns combine both: “premium-tier users in Europe (attribute) who abandoned a cart in the last 24 hours (behaviour).” The frontend side of this — building preference-driven groups at opt-in time — is handled by subscriber segmentation and targeting.

A useful way to think about the trade-off: attribute segments are stable but coarse, behavioural segments are sharp but perishable. A subscriber’s locale will not change between when you build a segment and when you send to it, so attribute predicates can be evaluated once and cached. Behavioural predicates — “clicked in the last 24 hours” — are only true within a narrow window, so they should be evaluated as close to send time as the volume allows. The data model in the next step is shaped to make both kinds of predicate cheap: stable attributes live inline on the subscriber row, and a denormalized engagement summary is kept fresh by your event pipeline so behavioural queries never have to scan the raw event log on the hot path.

Personalization is the layer above segmentation. Once a cohort is selected, the message each member receives should still adapt to their individual attributes — name, locale, currency, the specific product they abandoned. Segmentation decides who gets a campaign; personalization decides what each person in that campaign sees. The two are independent: you can broadcast a personalized message to everyone, or send an unpersonalized message to a tight segment, but the campaigns that move metrics do both at once.

Push segmentation and templating pipeline Subscriber attributes and an event stream feed a segment query, which selects a cohort; a payload template merges per-subscriber variables before the personalized push is encrypted and sent. Subscriber attributes locale · tier · country Event stream views · carts · clicks Segment query cohort selection Payload template merge variables Frequency cap + timezone gate Encrypt & send aes128gcm · ≤4 KB Attributes + behaviour define the cohort; templating personalizes it; caps and timezone gate the send.
The segmentation pipeline: attributes and events define a cohort, templating personalizes each message, and frequency/timezone gates control delivery.

Step 1 — Model subscriber attributes and engagement state

Extend your subscription table so segment queries never need a join for the common case. Store the slowly-changing attributes inline and keep a denormalized engagement summary updated by your event pipeline.

-- Subscriber attribute & engagement schema (PostgreSQL)
CREATE TABLE push_subscribers (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  endpoint           TEXT NOT NULL UNIQUE,
  p256dh_key         TEXT NOT NULL,
  auth_secret        TEXT NOT NULL,
  user_id            UUID,
  -- attribute segment dimensions
  locale             TEXT        DEFAULT 'en-US',
  timezone           TEXT        DEFAULT 'UTC',     -- IANA name, e.g. 'Europe/Berlin'
  country            TEXT,
  plan_tier          TEXT        DEFAULT 'free',
  platform           TEXT,                          -- 'chrome', 'firefox', 'safari'
  -- behavioural / engagement summary (updated by event pipeline)
  last_seen_at       TIMESTAMPTZ,
  last_clicked_at    TIMESTAMPTZ,
  session_count_30d  INT         DEFAULT 0,
  click_count_30d    INT         DEFAULT 0,
  -- frequency capping
  last_pushed_at     TIMESTAMPTZ,
  pushes_sent_today  INT         DEFAULT 0,
  status             TEXT        DEFAULT 'active',   -- active | bounced | unsubscribed
  consent_log        JSONB
);

CREATE INDEX idx_subs_segment ON push_subscribers (status, plan_tier, country);
CREATE INDEX idx_subs_engage  ON push_subscribers (last_clicked_at, click_count_30d);

The timezone column stores an IANA zone name, not a numeric offset — offsets break across daylight-saving boundaries. Keep consent_log for the audit trail your unsubscribe flows depend on.

The schema deliberately denormalizes engagement state onto the subscriber row. You could compute click_count_30d on demand by aggregating an events table at query time, but at any meaningful subscriber count that turns every segment evaluation into a wide scan. Instead, let your event pipeline maintain these summary columns incrementally: when a click beacon arrives, increment click_count_30d and stamp last_clicked_at; a nightly job ages out events that have fallen outside the 30-day window. This keeps segment queries to a single indexed table read. Capture platform at subscribe time from the user agent so you can target — or exclude — engines with known quirks, and so cross-browser display differences show up cleanly in your analytics rather than as unexplained noise.

The two indexes matter. idx_subs_segment covers the common attribute filter (status, tier, country) so the planner can satisfy a broadcast-to-a-tier query without a sequential scan. idx_subs_engage covers the behavioural recency filter so “clicked in the last N days” stays fast even as the table grows into the millions. Add composite indexes that match your most frequent real campaigns rather than indexing every column speculatively.

Step 2 — Write a composable segment query

A segment is just a WHERE clause. Build it from a small descriptor so campaign tooling can compose attribute and behavioural predicates without string-concatenating SQL. Always parameterize.

// Node.js: build a parameterized segment query from a descriptor
function buildSegmentQuery(segment) {
  const clauses = ["status = 'active'"];
  const params = [];

  if (segment.planTier) {
    params.push(segment.planTier);
    clauses.push(`plan_tier = $${params.length}`);
  }
  if (segment.countries?.length) {
    params.push(segment.countries);
    clauses.push(`country = ANY($${params.length})`);
  }
  if (segment.engagedWithinDays) {
    params.push(segment.engagedWithinDays);
    clauses.push(`last_clicked_at > NOW() - ($${params.length} || ' days')::interval`);
  }
  if (segment.minClicks30d != null) {
    params.push(segment.minClicks30d);
    clauses.push(`click_count_30d >= $${params.length}`);
  }

  const sql = `
    SELECT id, endpoint, p256dh_key, auth_secret, locale, timezone, plan_tier
    FROM push_subscribers
    WHERE ${clauses.join(' AND ')}`;
  return { sql, params };
}

// "Premium subscribers in DE/AT who clicked in the last 7 days"
const { sql, params } = buildSegmentQuery({
  planTier: 'premium',
  countries: ['DE', 'AT'],
  engagedWithinDays: 7,
  minClicks30d: 1,
});

Run the query through your connection pool and iterate the cohort. For very large segments, page with a keyset cursor on id so you do not hold the whole result set in memory.

The descriptor pattern is what keeps a growing campaign tool maintainable. Each predicate is a small, independently testable branch that appends both a clause and a parameter, so the SQL and its bind values can never drift out of sync — the single most common source of injection bugs in hand-rolled query builders. Because every value goes through a $n placeholder, even a free-text segment name supplied by a marketer is safe. When you add a new dimension (say, signupSource), you add one branch and nothing else changes. Resist the temptation to let campaign authors write raw SQL fragments; a constrained descriptor is both safer and far easier to reason about when you later need to estimate a segment’s reach or schedule it.

For segments that span attribute and behavioural predicates that the summary columns cannot express — for example “added to cart but did not purchase” — fall back to a join against the events table, but do it in a separate, explicitly slower code path so the common attribute-only case stays on the fast index-only plan. The behavioural deep-dive covers these event-driven cohorts in full.

Step 3 — Template the payload per subscriber

Each subscriber should receive a payload merged from their attributes. Keep the encrypted ciphertext under the 4 KB payload size limit — Web Push uses the aes128gcm content encoding (RFC 8291), and exceeding the limit returns 413 Payload Too Large. Personalize with short identifiers and resolve heavy content client-side.

// Node.js: locale-aware payload templating
const TEMPLATES = {
  'en-US': { title: 'Hi {{name}}, your cart is waiting',
             body: '{{count}} items in {{currency}} — finish checkout' },
  'de-DE': { title: 'Hallo {{name}}, dein Warenkorb wartet',
             body: '{{count}} Artikel in {{currency}} — jetzt bestellen' },
};

function render(str, vars) {
  return str.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
}

function buildPayload(subscriber, campaign) {
  const tpl = TEMPLATES[subscriber.locale] ?? TEMPLATES['en-US'];
  const vars = {
    name: campaign.firstName ?? 'there',
    count: campaign.cartCount,
    currency: subscriber.locale === 'de-DE' ? 'EUR' : 'USD',
  };
  return JSON.stringify({
    title: render(tpl.title, vars),
    body: render(tpl.body, vars),
    tag: `cart-${campaign.cartId}`,   // collapse duplicate sends
    data: { url: `/cart/${campaign.cartId}`, campaign: campaign.id },
  });
}

The tag field collapses repeated notifications so a subscriber never sees five stacked cart reminders. For experiment-driven copy variants, pair templating with A/B testing for push notifications.

Two disciplines keep templating from becoming a liability. First, every variable must have a default, because a missing attribute should degrade to a sensible generic string rather than render “Hi , your cart is waiting” — the vars[k] ?? '' fallback in render plus a per-locale fallback template handle both halves of that. Second, treat the template set as locale-complete: ship every campaign with at least your default-locale template and let unknown locales fall through to it, so a subscriber whose locale you have never seen still gets a coherent message. Currency, date, and number formatting belong in Intl.NumberFormat and Intl.DateTimeFormat rather than hardcoded strings; a hardcoded $ in front of a German subscriber’s total is the kind of detail that quietly erodes trust.

Keep the merged result lean. The title and body together compete for the same 4 KB budget as the data object and any action buttons, and the budget is measured after encryption, so leave headroom. Anything heavy — a product image, a full order summary — should be referenced by an identifier in data.url and fetched when the notification opens, not embedded in the payload. This also lets you change the destination content after the push has been sent, which is impossible once a payload is encrypted and queued at the push service.

Step 4 — Gate on timezone and frequency, then send

Targeting when matters as much as who. Compute the subscriber’s local hour from their IANA timezone and only send inside an allowed window. Enforce a per-subscriber daily cap before encrypting.

// Node.js: timezone gate + frequency cap + send
const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:ops@yourdomain.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

function localHour(timezone) {
  const h = new Intl.DateTimeFormat('en-US', {
    hour: 'numeric', hour12: false, timeZone: timezone,
  }).format(new Date());
  return Number(h) % 24;
}

async function sendToCohort(rows, campaign, { dailyCap = 3, window = [9, 21] }) {
  for (const sub of rows) {
    const hour = localHour(sub.timezone);
    if (hour < window[0] || hour >= window[1]) continue;     // outside local daytime
    if (sub.pushes_sent_today >= dailyCap) continue;          // frequency cap

    const subscription = {
      endpoint: sub.endpoint,
      keys: { p256dh: sub.p256dh_key, auth: sub.auth_secret },
    };
    try {
      await webpush.sendNotification(subscription, buildPayload(sub, campaign), { TTL: 86400, urgency: 'normal' });
      // bump counters (do this in your DB layer / pipeline)
      sub.pushes_sent_today += 1;
    } catch (err) {
      if (err.statusCode === 410 || err.statusCode === 404) {
        await markUnsubscribed(sub.id);   // endpoint gone — stop sending
      }
    }
  }
}

Reset pushes_sent_today with a scheduled job at the subscriber’s local midnight (or globally at UTC midnight if approximate capping is acceptable). For high-volume sends, push the cohort through a queue rather than a tight loop — see retry logic and backoff strategies.

Frequency capping deserves more thought than a single daily number. A subscriber’s tolerance is not uniform across message types: a security alert or an order-shipped notification is welcome at any hour and should bypass the marketing cap entirely, while a third promotional reminder in one day is the kind of thing that triggers a permission revocation. Model this by tagging each campaign with a class — transactional versus promotional — and applying the cap only to the latter. Track caps at more than one granularity if you can: a daily cap prevents same-day fatigue, while a rolling weekly cap prevents a subscriber from receiving the maximum every single day. The cost of an over-aggressive cadence is not just a lower click rate on the next message; it is the permanent loss of the channel when the subscriber blocks notifications, which no later campaign can recover.

The timezone gate is what separates a competent send from a clumsy one. Computing the subscriber’s local hour with Intl.DateTimeFormat keyed on the IANA zone name means a 9 a.m. local send genuinely lands at 9 a.m. for a subscriber in Berlin and 9 a.m. for one in San Francisco, fired from the same scheduled job. Pair the window with urgency deliberately: a time-sensitive alert can use urgency: 'high' to discourage the push service from deferring it for battery reasons, while a promotional message can sit at normal and accept some delivery latency. Note the catch on the send itself — a 410 Gone or 404 is the push service telling you the endpoint is dead, and continuing to send to it inflates your sent count while contributing nothing to delivered, so mark it unsubscribed immediately rather than retrying.

Configuration reference

Param Type Default Notes
dailyCap int 3 Max pushes per subscriber per local day. Transactional sends often bypass this.
window [int,int] [9,21] Allowed local-hour send window (24h clock).
TTL int (sec) 86400 How long the push service retains an undelivered message.
urgency enum normal very-low/low/normal/high — affects battery-saving delivery deferral.
tag string Collapses duplicate notifications client-side.
engagedWithinDays int Behavioural recency predicate for the segment query.
minClicks30d int Behavioural engagement threshold.

Verification

Confirm a segment resolves to the cohort you expect before sending. Count first, send second.

-- Dry-run: how many subscribers match, broken down by locale
SELECT locale, COUNT(*) AS reach
FROM push_subscribers
WHERE status = 'active' AND plan_tier = 'premium'
  AND country = ANY(ARRAY['DE','AT'])
  AND last_clicked_at > NOW() - INTERVAL '7 days'
GROUP BY locale;

Then send one test push to your own subscription and inspect it in DevTools. In Chrome, open chrome://serviceworker-internals or DevTools → Application → Service Workers and trigger a test push; verify the rendered title/body match the merged template and that data.url is correct. Watch the network for the 201 Created response from the push service.

Error and edge-case matrix

Condition Cause Fix
413 Payload Too Large Templated payload exceeds 4 KB after aes128gcm encryption Trim copy, move detail behind data.url, drop large icons
410 Gone / 404 Subscriber endpoint expired or unsubscribed Mark status = 'unsubscribed'; stop sending — see handling 410 responses at scale
429 Too Many Requests Cohort send rate exceeds push service limits Throttle via queue and exponential backoff
Wrong local time Numeric offset stored instead of IANA zone Store timezone as IANA name; recompute with Intl.DateTimeFormat
Empty merge variable Missing attribute renders blank text Default every variable (vars[k] ?? '') and a fallback template per locale
Stacked duplicate notifications No tag set Set a deterministic tag per logical event

Cross-browser notes

Chromium (FCM) and Firefox (Mozilla Autopush) both honor tag, urgency, and TTL. Safari (APNs, iOS 16.4+/macOS Ventura+) is stricter: it requires user-visible notifications for every push, ignores some action layouts, and applies its own delivery throttling that can defer low-urgency messages. Always set userVisibleOnly: true at subscribe time and treat urgency: 'high' as a request, not a guarantee. Test locale rendering across all three engines — emoji and right-to-left text render inconsistently in notification UI.

FAQ

Should I store a timezone offset or an IANA zone name?

Always store the IANA zone name (for example Europe/Berlin), not a numeric offset like +02:00. Offsets change twice a year with daylight-saving transitions, so a stored offset silently drifts. Compute the subscriber’s current local hour at send time with Intl.DateTimeFormat and the IANA name.

How small does a personalized payload need to be?

The encrypted ciphertext must stay under 4 KB, because Web Push uses the aes128gcm content encoding defined in RFC 8291 and push services reject anything larger with 413 Payload Too Large. Personalize with short strings and identifiers, and resolve heavy content (images, full product data) client-side after the notification opens.

What is the difference between attribute and behavioural segments?

Attribute segments are based on static or slowly-changing properties such as locale, plan tier, or country — they describe who the subscriber is. Behavioural segments are derived from actions over time, such as recent clicks or session counts — they describe how engaged the subscriber is. Combining both produces the most relevant targeting.

How do I stop over-messaging a subscriber?

Apply a per-subscriber frequency cap. Track pushes_sent_today, check it before each send, and skip subscribers at the cap. Reset the counter at the subscriber’s local midnight. Use the tag field to collapse duplicate notifications for the same logical event so reminders never stack.