Measuring Push Notification Click-Through Rate

This guide explains exactly how to measure web push click-through rate (CTR) so the number you report is trustworthy enough to base copy and targeting decisions on.

Quick Answer

Web push CTR is the share of delivered notifications that were clicked:

CTR = clicks / accepted   (NOT clicks / sent)

Use accepted (a 201 from the push service) as the denominator, because notifications the service rejected were never delivered and cannot be clicked. As a rough orientation, an unsegmented broadcast often sits in the low single digits (roughly 1–4%), while a tightly targeted, behaviourally segmented send can reach the high single digits or double digits. Treat any benchmark as a hypothesis and measure your own baseline first.

Denominator choice What it counts Effect on CTR Verdict
sent Every send attempt, including rejects Artificially low Avoid
accepted (201) Notifications the push service queued Honest delivered baseline Use this
display Notifications the device actually rendered Highest, hardest to capture Use as a secondary view

Why the Denominator Decides Everything

A push send passes through three gates: your server attempts it (sent), the push service accepts it (accepted), and the device renders it (display). Each gate loses some volume. If you divide clicks by sent, every 410 Gone dead endpoint and every 413 Payload Too Large rejection drags your CTR down for reasons that have nothing to do with your copy — and worse, if rejections are unevenly distributed across A/B test arms, the comparison is biased. That is why the A/B testing guide insists on accepted as the denominator.

The gap between accepted and display is its own signal: it reveals OS-level throttling, Do-Not-Disturb, and battery optimization eating your notifications before they are seen. Measuring that gap is the focus of delivery analytics instrumentation, and it sits within the broader engagement and campaign optimization practice.

There is a second reason to standardise on accepted: comparability. The moment you start running experiments through the A/B testing workflow, every arm must be measured the same way, or the lift you compute is meaningless. If control happens to have more dead endpoints than variant — common when one cohort is older — a sent-based denominator will hand the win to whichever arm had fewer rejections, not whichever had better copy. Anchoring on the push service’s 201 acceptance removes that confound entirely, because a rejected request is excluded from both numerator and denominator by definition.

Reading benchmarks correctly

Published CTR benchmarks are useful only as a sanity check, never as a target. They are aggregated across wildly different industries, list ages, segmentation maturity, and — critically — denominator definitions, so a vendor quoting “average push CTR of 12%” may be counting displays, not accepted, which makes their number incomparable to yours. The right move is to establish your own baseline on a representative send, then improve against that baseline. A useful internal benchmark is the CTR of a known-good campaign you would happily send again; new variants are judged against it, not against an external average.

From sent to clicked: where CTR is measured A descending funnel: sent narrows to accepted, then displayed, then clicked. CTR is clicks divided by accepted. sent (every attempt) accepted = 201 (CTR denominator) displayed (device rendered) clicked (numerator)
CTR is the ratio of the bottom band (clicks) to the accepted band — not the top.

The notificationclick Handler

Clicks are captured client-side. The browser fires notificationclick inside the service worker when the user taps the notification; that handler is your only reliable source for the CTR numerator. Beacon the click with keepalive: true so it survives the page navigation that follows.

// sw.js — record a click and route the user
self.addEventListener('notificationclick', (event) => {
  const d = event.notification.data || {};
  event.notification.close();
  event.waitUntil((async () => {
    await fetch('/t/click', {
      method: 'POST',
      keepalive: true,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ campaignId: d.campaignId, variant: d.variant, ts: Date.now() })
    }).catch(() => {});
    const url = d.url || '/';
    const wins = await clients.matchAll({ type: 'window', includeUncontrolled: true });
    const open = wins.find((w) => w.url.includes(url));
    return open ? open.focus() : clients.openWindow(url);
  })());
});

Delivery vs click attribution

Attribution is the act of tying a click back to the exact send that produced it. The reliable join key is a campaignId (and, for experiments, a variant) embedded in the notification data at send time and echoed back by the click beacon. Wall-clock proximity is not a safe key, because a user may tap a notification minutes or hours after it was shown — the click still belongs to the original campaign. Carry the identifiers in the encrypted payload, not in the URL alone, so they survive even if the user is routed through a redirect.

This is also where the delivery and click sides of your data meet. The accepted event is written server-side when the push service returns 201; the click event is written client-side from the service worker. They share subscriber_id, campaignId, and variant, which is what lets a single grouped query divide one by the other. If either side drops its identifiers, attribution breaks and CTR silently becomes a ratio of two unrelated counts.

Computing CTR From the Event Store

With accepted and click events recorded per campaign and variant, CTR is a single grouped query. Keep the denominator scoped to the same campaign and variant window as the numerator.

SELECT
  campaign_id,
  variant,
  COUNT(*) FILTER (WHERE event_type = 'click')    AS clicks,
  COUNT(*) FILTER (WHERE event_type = 'accepted')  AS accepted,
  ROUND(
    COUNT(*) FILTER (WHERE event_type = 'click')::numeric
    / NULLIF(COUNT(*) FILTER (WHERE event_type = 'accepted'), 0),
    4
  ) AS ctr
FROM push_events
WHERE campaign_id = 'title-personalization-v1'
GROUP BY campaign_id, variant
ORDER BY variant;

Diagnostic Steps

  1. Confirm both events arrive. In Chrome DevTools, open the Application panel, push a test notification to yourself, click it, and watch for both the accepted write (server log) and the /t/click beacon (Network tab).
  2. Check the denominator source. Verify your query divides by accepted, not sent or total subscribers.
  3. Deduplicate clicks. A user can tap the same notification twice; count distinct (subscriber_id, campaign_id) clicks if you want unique CTR.
  4. Scope the time window. Late clicks (a user taps hours later) should still attribute to the original send; key on campaignId, not wall-clock proximity.
  5. Segment by browser. Split CTR by user agent so one platform’s display throttling does not mask a real copy result.

Gotchas & Edge Cases

  • keepalive is mandatory. Without it, the click beacon is frequently cancelled when the service worker focuses or opens a window, undercounting clicks.
  • Action buttons fire the same event. notificationclick also fires for event.action button taps; decide whether button clicks count toward CTR and branch on event.action.
  • Dead endpoints poison the denominator. A subscriber whose endpoint returns 410 Gone should be retired, not counted as accepted. See handling 410 Gone responses at scale.
  • Oversized payloads never display. Anything over the 4 KB ciphertext limit (payloads are encrypted with aes128gcm per RFC 8291) is rejected with 413 and silently absent from your clicks — keep payloads to ids and a url.
  • Auto-closed notifications. Replacing a notification via the same tag removes the chance to click the earlier one; account for collapses when interpreting low CTR.
  • Clock skew on the beacon. The ts you send from the client is the device clock, which can drift; stamp the authoritative time server-side on receipt and treat the client timestamp only as a hint.
  • Bot and prefetch noise. Synthetic clicks from automated link-checkers or prefetchers can inflate the numerator; filter known crawler user agents and ignore clicks that arrive before the display beacon.

FAQ

Is push CTR clicks over delivered or clicks over sent?

Clicks over delivered, where delivered means the push service accepted the request (a 201 response). Using sent inflates the denominator with rejected notifications that could never be clicked, depressing CTR for reasons unrelated to your content.

How do I capture clicks reliably across the navigation?

Send the tracking request from the service worker’s notificationclick handler with fetch and keepalive set to true, before or alongside opening the window. keepalive lets the request complete even as the worker focuses or opens a new tab, which otherwise cancels in-flight requests.

Should action-button taps count as clicks?

That is a product decision. notificationclick fires for both the notification body and any action buttons, distinguishable via event.action. Pick one definition, branch on event.action, and apply it consistently so your CTR stays comparable over time.