Push Delivery Analytics Instrumentation: Engineering Reference

Most push “analytics” stop at sent — the count of requests your server fired at the push service. That number tells you almost nothing about whether anyone saw or acted on a notification. This guide instruments the real funnel: delivered → displayed → clicked → converted, with each stage measured at the layer where it actually happens. You will add service-worker beacons, a server ingest endpoint, and an aggregation model that turns raw events into the rates campaigns are judged on.

Prerequisites

  • push and notificationclick (see service worker registration patterns).
  • process.env.VAPID_PUBLIC_KEY / process.env.VAPID_PRIVATE_KEY — never hardcode the public key in server code.
  • data object so events can be attributed.
  • delivery tracking and acknowledgment.

The four funnel stages — and why they differ

Each stage is measured by a different actor, and conflating them produces misleading rates.

  • Sent — your server received a 201 Created from the push service. The message is queued, not delivered.
  • Delivered — the browser woke your service worker and fired the push event. The device received the encrypted payload.
  • Displayed — your handler called showNotification() and the OS actually rendered it. Delivered does not guarantee displayed: a silent-push budget violation, a thrown error in the handler, or OS-level suppression can drop the notification. The dedicated guide on tracking delivery vs display rates covers this gap in detail.
  • Clicked — the subscriber activated the notification, firing notificationclick.
  • Converted — the subscriber completed the campaign’s goal (purchase, upgrade) on the resulting page, measured by your normal web analytics.

The reason this distinction matters operationally is that each stage has different failure modes and different owners. A drop between sent and delivered is a backend or endpoint-hygiene problem — expired subscriptions, lapsed TTLs, devices that have been offline longer than the message lived. A drop between delivered and displayed is a client-side problem — a throwing handler, an exhausted silent-push budget, an OS focus mode. A drop between displayed and clicked is a content and targeting problem — wrong audience, weak copy, bad timing. If you only track a single “open rate” you cannot tell which of these three teams owns a regression, and you will waste cycles rewriting copy when the real fault is that a third of your endpoints are dead. Instrumenting all four stages turns a vague “engagement is down” into a precise, assignable diagnosis.

It also changes how you read benchmarks. A 4% click-through rate computed against sent and a 4% rate computed against displayed describe very different campaigns: the first might have a healthy display funnel and mediocre copy, the second a great message reaching only a fraction of the audience. Always state the denominator when you report a rate, and prefer display-based denominators for content decisions and delivery-based denominators for infrastructure decisions.

Push delivery analytics funnel Five stages measured at different layers: sent at the server, delivered and displayed in the service worker push handler, clicked in the notificationclick handler, and converted in page analytics. Sent server · 201 Delivered push event Displayed showNotification Clicked notificationclick Converted page goal measured in the push handler (beacon) measured after showNotification resolves measured in the notificationclick handler Each stage is measured by a different actor — never conflate them.
The push funnel: each stage is observed at a different layer, so a separate beacon or measurement is required per stage.

Step 1 — Embed attribution data in the payload

Every event you collect must tie back to a campaign and notification instance. Stamp identifiers into the payload data at send time so the service worker can echo them in its beacons.

// Server-side: stamp attribution into the payload
const webpush = require('web-push');
webpush.setVapidDetails(
  'mailto:ops@yourdomain.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

function buildPayload(campaign, subscriberId) {
  return JSON.stringify({
    title: campaign.title,
    body: campaign.body,
    data: {
      url: campaign.url,
      campaign_id: campaign.id,
      send_id: `${campaign.id}:${subscriberId}:${Date.now()}`, // unique per send
    },
  });
}

The send_id is the spine of the whole system. It must be unique per individual delivery — campaign id, subscriber id, and a timestamp is a reliable composition — because it is simultaneously the attribution key that ties a click back to a specific send and the deduplication key that keeps keepalive retries from double-counting. Generate it once at send time and never reconstruct it client-side, where clock skew or a missing field would silently break attribution. Keep campaign_id coarse enough to aggregate across all recipients of a campaign and send_id fine enough to identify exactly one. Everything downstream — the rollup, the funnel, the experiment analysis — depends on these two identifiers being present and stable in every event.

Step 2 — Beacon delivered and displayed from the push handler

In the service worker, fire a delivered beacon as soon as the push event runs, then fire displayed only after showNotification() resolves. Use fetch with keepalive: true so the request survives the worker being torn down. Keep the beacon inside event.waitUntil() so the runtime does not kill the worker mid-request.

The ordering here is deliberate and load-bearing. Firing delivered first guarantees you capture the denominator even if showNotification() later throws — without that, a campaign that fails to render would show zero deliveries and zero displays, hiding the very failure you most need to see. Firing displayed only after the render call resolves means the number genuinely reflects notifications the OS accepted, not merely calls you attempted. Note that navigator.sendBeacon, the usual choice for unload-time telemetry, is not exposed in the service worker global scope; fetch with keepalive: true is the correct primitive in this context, and the .catch(() => {}) is essential — a failed analytics request must never be allowed to reject the promise passed to waitUntil and abort the notification itself.

// sw.js: delivered + displayed beacons
const BEACON = 'https://api.yourdomain.com/push-events';

function beacon(type, data) {
  return fetch(BEACON, {
    method: 'POST',
    keepalive: true,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ type, ...data, ts: Date.now() }),
  }).catch(() => {});  // never let a failed beacon break the notification
}

self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  const meta = { campaign_id: data.data?.campaign_id, send_id: data.data?.send_id };

  event.waitUntil((async () => {
    await beacon('delivered', meta);                 // push event fired
    await self.registration.showNotification(data.title, {
      body: data.body,
      data: data.data,
      tag: meta.send_id,
    });
    await beacon('displayed', meta);                 // notification actually rendered
  })());
});

Step 3 — Beacon clicks from notificationclick

The notificationclick handler fires a clicked beacon and then focuses or opens the target URL. Always close the notification and keep the work inside waitUntil().

// sw.js: click beacon + open target
self.addEventListener('notificationclick', (event) => {
  const data = event.notification.data ?? {};
  event.notification.close();

  event.waitUntil((async () => {
    await beacon('clicked', {
      campaign_id: data.campaign_id,
      send_id: data.send_id,
    });
    const url = data.url ?? '/';
    const all = await clients.matchAll({ type: 'window', includeUncontrolled: true });
    const existing = all.find((c) => c.url.includes(url));
    if (existing) return existing.focus();
    return clients.openWindow(url);
  })());
});

Step 4 — Ingest and aggregate server-side

The ingest endpoint validates the event and appends it to a raw table; a rollup query turns raw events into funnel rates. Append-only raw storage lets you recompute metrics if definitions change.

// Server-side: ingest endpoint (Express)
app.post('/push-events', async (req, res) => {
  const { type, campaign_id, send_id, ts } = req.body ?? {};
  if (!['delivered', 'displayed', 'clicked'].includes(type) || !campaign_id) {
    return res.status(400).end();
  }
  await db.query(
    `INSERT INTO push_events (type, campaign_id, send_id, occurred_at)
     VALUES ($1, $2, $3, to_timestamp($4 / 1000.0))
     ON CONFLICT (send_id, type) DO NOTHING`,   // idempotent: dedupe retries
    [type, campaign_id, send_id, ts ?? Date.now()]
  );
  res.status(204).end();
});
-- Funnel rollup per campaign
SELECT
  campaign_id,
  COUNT(*) FILTER (WHERE type = 'delivered') AS delivered,
  COUNT(*) FILTER (WHERE type = 'displayed') AS displayed,
  COUNT(*) FILTER (WHERE type = 'clicked')   AS clicked,
  ROUND(100.0 * COUNT(*) FILTER (WHERE type = 'displayed')
        / NULLIF(COUNT(*) FILTER (WHERE type = 'delivered'), 0), 1) AS display_rate,
  ROUND(100.0 * COUNT(*) FILTER (WHERE type = 'clicked')
        / NULLIF(COUNT(*) FILTER (WHERE type = 'displayed'), 0), 1) AS ctr
FROM push_events
GROUP BY campaign_id;

This rollup is the data layer behind any dashboard and the raw input for A/B testing push notifications and behavioural scoring in push personalization and segmentation.

Two architectural choices in the ingest path are worth dwelling on. The append-only raw table means metric definitions are decisions you can revisit: if next quarter you redefine “engaged” or want to compute a new ratio, you recompute from raw events rather than discovering the aggregate you kept threw away the data you now need. Roll older raw events up into a compact daily summary table and prune the raw rows past your retention window to keep the hot table small. The second choice is idempotency: ON CONFLICT (send_id, type) DO NOTHING makes the endpoint safe to call more than once for the same event, which it will be, because keepalive retries and at-least-once network semantics guarantee duplicates. An ingest endpoint that is not idempotent will report inflated, untrustworthy numbers the first time a flaky network doubles a beacon.

For a real dashboard, schedule the rollup query to materialize into a summary table on a short interval rather than running it live against the raw table on every page load. Surface the three headline ratios — display rate, click-through rate, and conversion rate — per campaign and over time, and alert when display rate drops, since that is the early signal of a client-side regression that would otherwise masquerade as a content problem. Build conversion attribution on top by joining the clicked events to your web analytics on campaign_id and the session that the click opened.

Configuration reference

Param Type Default Notes
keepalive bool false Set true so beacons survive worker teardown.
send_id string Unique per delivery; the dedupe key for idempotent ingest.
BEACON endpoint URL Must be HTTPS and CORS-permitted for the SW origin.
Raw retention days 90 Keep raw events long enough to recompute; roll up older data.
ON CONFLICT key (send_id, type) Prevents double-counting on keepalive/network retries.
urgency enum normal Lower urgency can defer delivery, shifting funnel timing.

Verification

Trigger a test push to your own subscription and watch all three beacons fire. In Chrome DevTools → Application → Service Workers, confirm the worker is active, then in the Network tab filter for push-events and verify three POSTs (delivered, displayed, clicked) appear with 204 responses. Cross-check the ingest table.

# Confirm the funnel for a test campaign landed
curl -s "https://api.yourdomain.com/internal/funnel?campaign_id=test-123" | jq
# Expect delivered >= displayed >= clicked

Error and edge-case matrix

Condition Cause Fix
delivered logged, no displayed Handler threw before/while calling showNotification(), or silent-push budget exceeded Wrap handler in try/catch; audit silent pushes — see tracking delivery vs display rates
Beacons missing entirely fetch ran outside waitUntil() and the worker was killed Move beacon into event.waitUntil() with keepalive: true
Double-counted events keepalive retried the POST Dedupe on (send_id, type) with ON CONFLICT DO NOTHING
clicked with no matching displayed Beacon dropped on a flaky network Treat funnel stages as monotonic floors; alert on inversions
Inflated sent, low delivered Expired endpoints (410 Gone) still in the list Prune via delivery tracking and acknowledgment
Conversion not attributed campaign_id lost on navigation Pass it as a URL param and persist to your web analytics session

Cross-browser notes

The push and notificationclick events fire in Chromium (FCM), Firefox (Autopush), and Safari (APNs), but timing and reliability differ. Safari is stricter about requiring a visible notification per push and may suppress or coalesce notifications, widening the delivered-vs-displayed gap. fetch with keepalive is well-supported in Chromium and Firefox; on Safari, beacons fired during worker teardown are less reliable, so favour firing them before showNotification() resolves rather than after the worker idles. Always treat the funnel as approximate on Safari and corroborate with click and conversion signals.

FAQ

What is the difference between delivered and displayed?

Delivered means the browser woke your service worker and fired the push event — the encrypted payload reached the device. Displayed means your handler successfully called showNotification() and the OS rendered it. A notification can be delivered but never displayed if the handler throws, the silent-push budget is exceeded, or the OS suppresses it.

Why use fetch with keepalive instead of navigator.sendBeacon?

navigator.sendBeacon is not available in the service worker global scope, where push and click handlers run. fetch with keepalive: true works there and lets the request outlive the worker being torn down, which is essential for beacons fired at the end of a handler.

How do I avoid double-counting beacon events?

Send a unique send_id per delivery and make ingest idempotent by upserting on (send_id, type) with ON CONFLICT DO NOTHING. The keepalive flag can cause the browser to retry a beacon, so deduplication at ingest is what keeps your rates accurate.

Where does conversion get measured?

Conversion happens on the destination page after a click, so it is measured by your normal web analytics, not in the service worker. Pass the campaign_id through the notification’s target URL and persist it into the session so the eventual goal completion can be attributed back to the push.