Push Notification Engagement & Campaign Optimization

This guide is the entry point for product, growth, and marketing teams who need to turn a working web push pipeline into measurable engagement. It assumes the protocol layer is already in place and focuses on what happens after a subscriber exists: segmenting, personalizing, sending, measuring, and winning back attention.

Where the technical guides explain how a payload travels from your server to a service worker, this section explains why a notification gets clicked, how to prove a variant performed better, and how to instrument the gap between what was delivered and what was actually displayed. Every section links to a deeper guide and to the underlying mechanics in the protocol and frontend references.

The web push engagement loop A six-stage loop: subscribe, segment, personalize, send, measure, and re-engage, where measurement feeds back into segmentation. The engagement loop 1. Subscribe consent + endpoint 2. Segment behaviour cohorts 3. Personalize copy + timing 4. Send encrypted payload 5. Measure delivery vs click 6. Re-engage win back dormant feedback Every stage writes an event; the events are what make the next send smarter.
Engagement is a loop, not a one-way funnel: measurement feeds segmentation, which feeds the next send.

1. Click-Through Optimization

Click-through rate (CTR) is the headline engagement metric for push: clicks divided by notifications delivered. The levers are copy, timing, the notification tag and actions, and the icon. Small wording changes routinely move CTR by several percentage points, which is why the optimization work belongs in a controlled experiment rather than guesswork.

The single highest-leverage decision is whether the notification earns the interruption. A defensive push handler should always call showNotification (browsers penalize silent pushes), but the payload should carry the minimal context needed to render a compelling title and body.

// Service worker: render an engagement-optimized notification
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  event.waitUntil(
    self.registration.showNotification(data.title ?? 'Update', {
      body: data.body,
      icon: '/icons/push-192.png',
      tag: data.campaignId,           // collapse duplicates per campaign
      data: { url: data.url, variant: data.variant, campaignId: data.campaignId },
      actions: data.actions ?? [{ action: 'open', title: 'View' }],
      requireInteraction: data.urgent ?? false
    })
  );
});

Notice the payload carries only identifiers and short strings. Keep it well under the 4 KB ciphertext limit imposed by push services, and remember every payload is encrypted with the aes128gcm content encoding per RFC 8291 before it leaves your server. Heavy media should be fetched after the click, not shipped in the payload. The full mechanics of running a disciplined experiment on this copy live in A/B testing push notifications.

Beyond copy, four structural levers move CTR independently of wording. The tag collapses redundant notifications so a user is never buried under five of yours at once — over-notification is the fastest path to a revoked permission, which is why cadence deserves its own controls, covered in notification frequency and fatigue management. The actions array turns a passive alert into a choice, and a notification with a clear primary action (“Track order”, “Resume checkout”) routinely outperforms a bare body. The icon and badge establish brand recognition at a glance. And timing — sending when the subscriber is awake and likely at a device — often dwarfs every copy change combined, which is why a timezone column belongs on your subscriptions table from day one. Treat each of these as a separate variable in your experiment backlog rather than bundling them into one change.

CTR levers and how to test them A five-row matrix listing the title copy, action buttons, tag collapsing, icon and badge, and send time levers, what each changes, and whether it should be isolated in its own experiment arm. Five CTR levers — change one at a time Lever What it actually changes Own test arm? Title and body copy wording, framing, urgency Yes — the core test actions buttons offers an explicit next step Yes — separate arm tag collapsing suppresses duplicate alerts No — a cadence change icon and badge brand recognition at a glance Yes — but slow to read Send time, timezone reaches an awake subscriber Yes — hold copy fixed
Bundling two levers into one variant makes a win uninterpretable — only the copy lever belongs in the same arm as itself.

2. A/B Testing Push Notifications

You cannot optimize what you do not test. A push A/B test assigns each subscriber to a variant at send time, records which variant they received, and attributes the resulting click back to that variant. Randomized assignment and a pre-declared sample size are what separate a real result from noise.

// Server: deterministic, sticky variant assignment by subscriber id
const crypto = require('crypto');

function assignVariant(subscriberId, experimentId, variants) {
  const hash = crypto.createHash('sha256')
    .update(`${experimentId}:${subscriberId}`)
    .digest();
  const bucket = hash.readUInt32BE(0) % variants.length;
  return variants[bucket]; // same subscriber always lands in the same variant
}

const variant = assignVariant(sub.id, 'cta-copy-v3', ['control', 'urgent']);

Deterministic hashing keeps assignment sticky across re-sends without a database write, and seeding the hash with the experiment id means a subscriber can be in different buckets across concurrent experiments. The end-to-end procedure, including sample-size math and lift calculation, is covered in the A/B testing guide, and the measurement primitive it depends on is detailed in measuring push notification click-through rate.

3. Behavioural Segmentation & Personalization

Blasting every subscriber the same message is the fastest way to train users to ignore you and to revoke permission. Segmentation groups subscribers by behaviour — recency, frequency, last action, declared preferences — so each cohort gets relevant content. Personalization then tailors copy and timing within a segment.

-- Build a "high-intent, recently active" segment for a campaign
SELECT s.id, s.endpoint, s.timezone
FROM push_subscriptions s
JOIN events e ON e.user_id = s.user_id
WHERE s.delivery_status = 'active'
  AND e.event_type = 'viewed_product'
  AND e.created_at > NOW() - INTERVAL '72 hours'
GROUP BY s.id, s.endpoint, s.timezone
HAVING COUNT(*) >= 2;

Storing a timezone per subscriber lets you send at a locally sensible hour rather than midnight. The deeper modelling — preference centres, decay scoring, and cohort definitions — is in push personalization and segmentation. Segmentation also connects directly to permission strategy: a subscriber who only granted permission after a well-timed prompt converts very differently from an early opt-in, which is why permission prompt timing is upstream of every segment you build.

There are three segmentation axes worth modelling explicitly. Recency-frequency-monetary (RFM) scoring ranks subscribers by how recently and how often they have engaged, and is the most reliable predictor of who will click next. Declared preferences — topics, channels, and frequency caps a user picked in a preference centre — are the strongest signal you have, because the user told you directly; honor them above any inferred behaviour. Lifecycle stage (new, active, at-risk, dormant) determines tone and cadence: a newly subscribed user needs a gentle onboarding nudge, while an at-risk user needs a reason to come back before they churn. Personalization then operates inside a segment: injecting a first name, a last-viewed item, or a locally relevant offer raises CTR, but only if the underlying segment is already relevant. Personalizing an irrelevant message just makes it a well-addressed irrelevant message.

4. Re-Engagement Campaigns

Every push list decays. Endpoints expire, interest fades, and a share of subscribers stop clicking entirely. Re-engagement (win-back) campaigns target dormant cohorts with a deliberately different message and cadence, and just as importantly, they retire subscribers who never come back so they stop polluting your CTR denominator.

// Identify dormant subscribers: no click in 30 days, still deliverable
async function selectWinBackCohort(db) {
  return db.query(`
    SELECT id, endpoint FROM push_subscriptions
    WHERE delivery_status = 'active'
      AND last_click_at < NOW() - INTERVAL '30 days'
      AND (last_winback_at IS NULL OR last_winback_at < NOW() - INTERVAL '30 days')
    LIMIT 5000
  `);
}

A win-back send must respect delivery hygiene: pace it through your queue with proper retry logic and backoff so a burst of re-engagement traffic does not trigger rate limiting. The full strategy, including suppression rules and how many attempts before retirement, is in re-engagement campaign strategies.

The discipline that makes re-engagement work is suppression. A win-back campaign should set last_winback_at so the same dormant user is not hammered repeatedly, and it should cap total attempts — typically two or three — before the subscriber is moved to a quarantine state and stops counting against your active list. This matters for measurement as well as courtesy: a list full of long-dormant endpoints depresses your headline CTR even though your engaged core is performing fine. Re-engagement is also where the cost of poor permission UX surfaces. A subscriber who blocked notifications cannot be reached at all, so recovery flows live on the frontend rather than the send path; pair this section with the recovery and preference-centre patterns in the frontend permission UX guide to give blocked users a path back.

5. Delivery vs Display Analytics

The most common analytics mistake in push is treating “the push service accepted my request” as “the user saw the notification.” They are different events separated by network conditions, OS-level throttling, battery state, and Do-Not-Disturb modes. Honest reporting instruments both: the server-side accepted/failed result, and the client-side display and click events emitted from the service worker.

// Service worker: emit a display beacon when the notification is shown
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  event.waitUntil((async () => {
    await self.registration.showNotification(data.title, { body: data.body, data });
    // fire-and-forget display telemetry
    fetch('/t/display', {
      method: 'POST', keepalive: true,
      body: JSON.stringify({ campaignId: data.campaignId, variant: data.variant, ts: Date.now() })
    }).catch(() => {});
  })());
});

The delta between accepted-on-the-server and displayed-on-the-client is one of the most actionable numbers you can track. Instrumenting it correctly is the subject of delivery analytics instrumentation, and the specific delivery-versus-display ratio is broken down in tracking push delivery vs display rates.

A complete analytics picture stacks four ratios on top of each other. Acceptance rate (accepted / sent) tells you whether your send job and credentials are healthy — a dip here usually means a 401/403 auth problem or a wave of 410 Gone retirements. Display rate (display / accepted) exposes OS-level suppression you cannot otherwise see; a chronically low display rate on one platform points at battery optimization or Do-Not-Disturb rather than anything in your code. Click-through rate (click / accepted) is your content signal. And conversion rate (converted / click) is the only number the business actually cares about. Reporting only CTR hides whether the problem is delivery, display, copy, or landing experience, so build a dashboard that shows all four side by side and lets you drill in by campaign, variant, and browser.

The four engagement ratios stacked on one campaign A worked example of ten thousand sends resolving to nine thousand two hundred accepted, eight thousand three hundred displayed, four hundred and eighty clicks and ninety-six conversions, then four cards showing what a dip in each ratio means and who owns it. One campaign, four ratios (illustrative worked example) sent 10,000 accepted 9,200 displayed 8,300 clicks 480 conversions 96 Acceptance rate accepted / sent = 92% A dip means 401/403 auth or 410 Gone churn. owned by the send path Display rate displayed / accepted = 90% A dip means OS suppression or a thrown error. owned by the service worker Click-through rate clicks / accepted = 5.2% Your content signal: copy, actions, timing. owned by campaign copy Conversion rate conversions / clicks = 20% The only number the business acts on. owned by the landing page
Reporting CTR alone hides which of the four ratios moved — and therefore which team owns the regression.

6. Campaign Playbooks

Patterns recur across industries. A cart-abandonment nudge, a SaaS re-onboarding sequence, and a breaking-news alert each have a characteristic trigger, cadence, payload shape, and success metric. Rather than reinvent each one, codify them as reusable playbooks your team can instantiate.

// A playbook is a declarative campaign template
const cartAbandonmentPlaybook = {
  trigger: 'cart_idle_30m',
  segment: 'has_cart && active_30d',
  variants: ['control', 'discount_hint'],
  schedule: [{ delayMin: 30 }, { delayMin: 1440 }], // 30 min, then 24h
  payload: ({ item }) => ({ title: `Still thinking about ${item.name}?`, url: '/cart' }),
  successMetric: 'checkout_started_within_24h'
};

Concrete, copy-ready versions of these live in the use-case playbooks section, which includes the e-commerce cart abandonment flow, a SaaS user re-engagement sequence, and a breaking-news alert architecture.

Each playbook makes a different trade-off against the delivery layer. Cart abandonment is latency-sensitive but low-volume, so a high urgency and a short TTL keep the nudge timely. SaaS re-engagement is a multi-step sequence with suppression between steps, so it leans on the same win-back hygiene described above. Breaking-news alerts are the opposite extreme: enormous fan-out in seconds, where a short TTL and aggressive batching matter far more than personalization, and where the queue architecture in the backend delivery guide becomes the limiting factor. Encoding these as templates rather than ad-hoc code means a new campaign inherits the right TTL, urgency, suppression, and success metric automatically, and your experiments compare like with like.

7. The Engagement Data Layer

Engagement reporting needs an event-grained store, not just a subscriptions table. Record every send, display, and click as an append-only event keyed by campaign and variant. This is what makes CTR, lift, and delivery-versus-display queries possible after the fact.

CREATE TABLE push_events (
  id           BIGSERIAL PRIMARY KEY,
  subscriber_id UUID NOT NULL,
  campaign_id  TEXT NOT NULL,
  variant      TEXT,
  event_type   VARCHAR(16) NOT NULL,  -- 'sent' | 'accepted' | 'failed' | 'display' | 'click'
  status_code  INT,                   -- push-service HTTP status for sent/failed
  created_at   TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_push_events_campaign ON push_events (campaign_id, variant, event_type);

From this single table, CTR is count(click) / count(accepted) per (campaign_id, variant), and the display gap is count(display) / count(accepted). Keep the table append-only so historical experiments stay reproducible.

The engagement data layer schema A one-to-many relationship between the push subscriptions table and the append-only push events table, with the click-through and display-gap ratios derived from grouped event counts. Two tables, every engagement metric push_subscriptions id — uuid, primary key endpoint — push service URL timezone — for local send hour delivery_status — active / retired consent_log — jsonb, immutable mutable state — one row per device push_events — append only subscriber_id — foreign key campaign_id — groups a send variant — experiment arm event_type — accepted/display/click status_code — push service HTTP code history — many rows per subscriber 1 : n GROUP BY campaign, variant Append-only storage means a metric definition can change without losing the history it applies to. CTR = count(click) / count(accepted) gap = count(display) / count(accepted)
One mutable subscriber table plus one append-only event table is enough to compute every rate on this page.

Derived metrics and the rollup layer

Raw events can answer any question eventually, but no dashboard tile should scan tens of millions of rows to render. Sit two derived layers on top of push_events. The first is a per-campaign, per-variant, per-day rollup that every report reads from. The second is a small set of per-subscriber derived columns — last_display_at, last_click_at, clicks_30d, dismissals_30d — that a segment query needs in single-digit milliseconds and cannot afford to aggregate on the fly.

CREATE MATERIALIZED VIEW push_campaign_daily AS
SELECT
  date_trunc('day', created_at)                    AS day,
  campaign_id,
  variant,
  COUNT(*) FILTER (WHERE event_type = 'accepted')  AS accepted,
  COUNT(*) FILTER (WHERE event_type = 'display')   AS displayed,
  COUNT(*) FILTER (WHERE event_type = 'click')     AS clicks,
  COUNT(*) FILTER (WHERE event_type = 'failed'
                    AND status_code = 410)         AS gone
FROM push_events
WHERE created_at >= NOW() - INTERVAL '400 days'
GROUP BY 1, 2, 3;

CREATE UNIQUE INDEX idx_pcd_key ON push_campaign_daily (day, campaign_id, variant);

The subtlety in any push rollup is late-arriving events. A notification can sit unread in a desktop notification centre for hours, and the click row only exists once the user finally gets to it, so today’s numbers keep moving for days. Refresh over a trailing window instead of appending one day and freezing it — recomputing the last seven days on every run is a sane default — and stamp every report with its recompute time, or someone will screenshot a partial day and quote it as final. The unique index above is what makes REFRESH MATERIALIZED VIEW CONCURRENTLY legal, so the refresh never locks out readers.

Sizing the event table before it surprises you

Do the arithmetic before you pick storage. Assume 250,000 active subscribers, three campaigns a week, and the four-ratio funnel above: each send writes one accepted row, about 0.9 of a display row, and about 0.05 of a click row — roughly 1.95 rows per send. That is 250,000 × 3 × 1.95 ≈ 1.5 million rows a week, or about 76 million a year. At an estimated 200 bytes per row including index overhead, that is roughly 15 GB a year in the transactional database: comfortable, but only if you partition by month and prune. Those are stated assumptions rather than measurements — substitute your own subscriber count and cadence, because the conclusion changes shape at ten times the volume.

The retention policy follows from the arithmetic. Keep raw events hot for the longer of your longest experiment and your longest behavioural window (90 days covers both for most teams), keep the daily rollup indefinitely because it is thousands of rows rather than millions, and drop each raw partition once its rollup has been verified. Every campaign-level rate on this page is recoverable from the rollup; only per-subscriber forensics need the raw rows, and those are exactly the rows a data-minimisation obligation applies to.

Real-time counters versus the warehouse

Two consumers want the same events at incompatible latencies. The on-call engineer needs to know within seconds whether the send that started two minutes ago is being accepted. The growth analyst needs to join sixty days of clicks against revenue and can happily wait ten minutes. Serving both from one transactional table ends with the analytical query starving the operational one at exactly the wrong moment.

Split them on purpose. The send path increments an in-memory counter keyed by campaign and outcome — INCR push:accepted:<campaign_id> with a one-day expiry — at the same moment it appends the durable row, and the alerting dashboard reads only those counters. The warehouse loads from the durable table on a schedule and owns everything historical. The counters are allowed to be lossy, because their only job is to trip an alert; the durable table stays the single source of truth for any number a human will quote. When the two disagree by a fraction of a percent, believe the durable table and resist the urge to reconcile the counters.

8. Dashboards, Guardrails & Alert Thresholds

Build three dashboards, not one. Send health is operational and refreshes in seconds: sends attempted, acceptance rate, status-code mix, queue depth, p95 send latency. Campaign performance is the four-ratio view from earlier in this guide, sliced by campaign, variant, and browser, and read from the rollup rather than the raw table. List health is the slow one: active subscribers, new subscriptions per day, 410 Gone retirements, and unsubscribes, plotted over months. Collapsing all three onto a single screen reliably lets the fast-moving operational numbers hide the slow list decay that is actually killing the channel.

Guardrail metrics are what stop you shipping a false win. Any change that raises CTR while also raising unsubscribes, dismissals, or suppressions has not won — it has borrowed engagement from next quarter. Four are worth a permanent tile: unsubscribe events per thousand displayed, dismissal rate (notifications closed without a click, observable through notificationclose), the 410 Gone retirement rate, and the share of sends your cadence governor suppressed, which is defined in notification frequency and fatigue management.

Thresholds should come from your own variance, never from a published figure. Each of these rates is a binomial proportion, so its standard error is sqrt(p * (1 - p) / n), and a band of three standard errors around the trailing median fires on genuine breaks while staying quiet on noise.

// Guardrail band for a rate metric, derived from your own trailing baseline
function guardrailBand(baselineRate, samplesPerBucket, sigmas = 3) {
  const se = Math.sqrt(baselineRate * (1 - baselineRate) / samplesPerBucket);
  return {
    lower: baselineRate - sigmas * se,
    upper: baselineRate + sigmas * se,
    standardError: se
  };
}

// Display-rate baseline 90%, 5,000 sends per hour:
guardrailBand(0.90, 5000);
// => standardError ≈ 0.0042 → alert when an hourly bucket falls below ≈ 88.7%

At 5,000 sends an hour that band is roughly ±1.3 points, so an hourly display rate of 88% is a real signal worth paging on. Run the same arithmetic at 200 sends an hour and the band widens to about ±6.4 points, which makes hourly alerting useless — aggregate to a day, or alert on an absolute count of failures instead. Matching the bucket size to the metric’s variance is the entire discipline; a threshold that fires twice a week gets muted, and a muted threshold is worse than none.

Guardrail band around a display-rate metric Twelve hourly display-rate readings sit inside a band of three standard errors around a ninety percent trailing median, until two consecutive buckets fall below the lower edge and trip the alert. Alert on a breach of your own variance, not a round number hourly display rate, baseline 90%, n = 5,000 sends per bucket (illustrative) 93% 91.3% 90.0% 88.7% 86% 00:00 05:00 10:00 two buckets below the band — page the on-call Band = 3 standard errors of a binomial proportion around the trailing median. hourly display rate guardrail band
The same 88% reading is noise at 200 sends an hour and an incident at 5,000 — the band, not the number, decides.

The last thing separating an honest dashboard from a flattering one is denominator discipline. Every guardrail must share the denominator of the metric it guards: unsubscribes per thousand displayed is a fatigue signal, while unsubscribes per thousand subscribers mostly measures how often you sent. Three traps recur. Comparing two periods whose population changed — a large acquisition burst floods the list with un-warmed subscribers and drops CTR with no content regression at all. Excluding failures from one arm’s denominator but not the other, so a browser with heavy endpoint churn looks like a copy problem. And pooling across browsers whose traffic mix moved between periods, where every individual browser’s rate can improve while the combined rate falls. The defence is mechanical: compute every rate per browser and per acquisition cohort first, aggregate second, and a change in mix then shows up as a change in mix rather than a phantom regression.

9. Engagement Metrics & Error Taxonomy

Engagement work fails quietly when delivery fails silently. The table below maps the HTTP responses your send job will see from push services to their engagement consequence, so a drop in CTR can be traced to a delivery cause rather than a copy problem.

Signal / status Meaning Engagement impact Resolution
201 Created Push service accepted the request Counts toward delivered denominator Record as accepted
400 Bad Request Malformed request or JWT Zero delivery for the batch Fix VAPID JWT / headers; see VAPID generation
401 / 403 Invalid or expired VAPID auth Whole sender blocked Rotate keys; check process.env.VAPID_PRIVATE_KEY
404 / 410 Gone Endpoint no longer valid Inflates list, depresses CTR Retire subscriber from active set
413 Payload Too Large Ciphertext over the 4 KB limit Notification never shown Trim payload; fetch media on click
429 Too Many Requests Rate limited by push service Delayed or dropped sends Honor Retry-After; apply backoff
Accepted but no display OS throttle / DND / battery Real delivery gap Track via display analytics

A persistent 410 Gone rate is both a delivery and an engagement problem: every dead endpoint that stays in the active set silently lowers your measured CTR. The encryption path that produces the 413 ceiling is documented in push API payload encryption.

Engagement optimization is constrained by consent. Under GDPR and CCPA, a push subscription is processing of personal data: the endpoint, the behavioural events you segment on, and the click logs all require a lawful basis, and that basis for marketing push is consent. Log the consent moment — timestamp, prompt version, and granted state — and keep it immutable.

ALTER TABLE push_subscriptions
  ADD COLUMN consent_log JSONB; -- { granted_at, prompt_version, basis: 'consent', locale }

Three rules keep an engagement program defensible. First, segmentation must honor declared preferences and unsubscribes immediately — a suppression check belongs in the send path, not a nightly job. Second, all registration and telemetry endpoints must be HTTPS-only with a strict Content-Security-Policy restricting service-worker script sources. Third, never blur consent for delivery with consent for behavioural profiling; if you segment on browsing behaviour, disclose it. The opt-out and preference-centre mechanics that make this enforceable are in the frontend permission UX guide.

Data minimisation closes the loop. The behavioural events you collect for segmentation are personal data, so retain them only as long as the campaign logic needs — an RFM window of 90 days rarely needs raw events older than that. Pseudonymise the subscriber_id in your analytics store where you can, separate the keying material (endpoint, p256dh, auth) from the engagement events, and make the right-to-erasure path delete both. CCPA adds a do-not-sell dimension: if behavioural push targeting is ever shared with a third-party vendor, that sharing must be disclosed and opt-out-able. Treat the consent log as the source of truth that gates every send, and your engagement metrics stay both accurate and lawful.

A consent record that only ever gets read during an audit is decoration. Make it structural: the recipient query itself must join consent and suppression state, so an opted-out subscriber cannot be selected in the first place rather than being filtered out by application code after the batch is assembled. The distinction is invisible on a good day and decisive during an incident, when somebody bypasses the campaign tooling to “just send this one manually”.

-- The only sanctioned recipient selection: consent is a join, not an afterthought
SELECT s.id, s.endpoint
FROM push_subscriptions s
WHERE s.delivery_status = 'active'
  AND s.consent_log ->> 'basis' = 'consent'
  AND s.consent_log ->> 'revoked_at' IS NULL
  AND NOT EXISTS (
    SELECT 1 FROM push_suppressions x
    WHERE x.subscriber_id = s.id
      AND (x.topic = $1 OR x.topic = '*')
  );

Two further constraints are structural rather than procedural. Service workers only register in a secure context, so the entire surface — the subscribe endpoint, the telemetry ingest, and the click landing page — is HTTPS-only by construction; add HSTS so a downgrade cannot even be attempted. And pin the worker’s origin with a Content-Security-Policy, because any third-party script able to register a service worker on your origin is able to read every push you send.

Strict-Transport-Security: max-age=63072000; includeSubDomains
Content-Security-Policy: default-src 'self'; script-src 'self'; worker-src 'self'; connect-src 'self' https://ingest.example.com; frame-ancestors 'none'

Finally, wire erasure to both stores. A deletion request must remove the subscription row together with its keying material and the behavioural events keyed to that subscriber; the one thing you keep is a minimal tombstone recording that consent was withdrawn and when, because that record is your evidence for not sending. Re-consent whenever the purpose widens: a subscriber who agreed to order updates has not agreed to a weekly digest, and quietly extending the topic list is the failure mode that actually attracts complaints.

Back to Web Push Notifications home

FAQ

What is a good click-through rate for web push notifications?

CTR varies widely by industry and segment, but a broadcast to an unsegmented list typically lands in the low single digits, while a well-targeted, behaviourally segmented campaign can reach double digits. Treat any published benchmark as a starting hypothesis and measure your own baseline first: CTR is clicks divided by notifications delivered, so a clean delivered denominator matters as much as the copy.

Should I optimize for click-through rate or for downstream conversion?

Optimize for the downstream business action and use CTR as a leading indicator. A clickbait title can lift CTR while lowering conversion and increasing unsubscribes. Track both in your push_events table and judge variants on the conversion metric, not the click alone.

How do delivery rate and display rate differ?

Delivery (acceptance) is your server receiving a 201 from the push service; display is the user’s device actually rendering the notification. The two diverge because of OS-level throttling, Do-Not-Disturb, and battery optimization. Instrument both — accepted on the server and a display beacon from the service worker — to see the gap.

Does aggressive engagement messaging hurt my subscriber list?

Yes. Over-messaging trains users to ignore notifications and drives permission revocation, which the browser remembers and which is hard to recover from. Cadence caps, behavioural segmentation, and timely re-engagement instead of constant broadcasting protect long-term list health.

What consent records do GDPR and CCPA require for push campaigns?

You need an immutable record of the consent moment: when permission was granted, which prompt version was shown, the lawful basis (consent for marketing push), and the locale. You must also honor unsubscribes immediately in the send path and disclose behavioural profiling if you segment on browsing activity.

How is engagement work connected to the technical push stack?

Directly. A high 410 Gone rate inflates your list and depresses measured CTR; a 429 from rate limiting delays campaigns; an oversized payload hits the 4 KB limit and is never shown. Engagement metrics are only trustworthy when the delivery layer — VAPID auth, encryption, retries, and backoff — is healthy.

How do I set an alert threshold on a push engagement metric?

Derive it from your own variance rather than picking a round number. Every rate here is a binomial proportion, so compute its standard error as the square root of p * (1 - p) / n for your bucket size and alert when a bucket falls more than about three standard errors below the trailing median. The same absolute drop is noise in a small bucket and an incident in a large one, so if the band comes out wider than the movement you care about, widen the bucket — aggregate hourly data to daily — instead of lowering the threshold.