Debugging Missing Push Delivery Receipts: Closing the Gap Between HTTP 201 and Real Display

The Web Push Protocol returns an HTTP 201 Created when the push service accepts your payload — not when the device receives it, not when the service worker processes it, and not when the notification appears on screen. There is no standardized delivery receipt in the protocol. Engineers who conflate the 201 with end-user delivery are measuring queue ingestion, not actual reach.

Quick Answer

Web push provides no native delivery receipt. The only way to confirm that a notification was processed by a service worker is to fire a beacon — a fetch() call — from inside the push event handler back to your own backend. Even that only confirms service worker execution, not visual display. Reconciling all three states (push service accepted → service worker received → notification displayed) requires combining server-side ledger records, service worker beacons, and optional notificationclick or notificationclose events. Gaps between these layers expose real delivery loss — network drops, TTL expiration, service worker termination, or permission revocations that your push gateway never reports.

The Three-State Delivery Gap

Browser push infrastructure introduces three observable checkpoints that are architecturally independent of each other.

State 1 — Push service ingestion: Your server POSTs to the push endpoint. The vendor (FCM, Mozilla Autopush, or APNs Web Push) acknowledges with 201. This means the message is in the vendor’s queue. It says nothing about the device. Vendors drop messages silently when the device has been offline longer than the TTL window, when storage quotas are exhausted, or during vendor-side outages. Understanding TTL behavior is a prerequisite for any reconciliation strategy — see TTL & Expiration Handling for how to configure TTL values that minimize silent drops.

State 2 — Service worker push event fires: The device eventually connects, the vendor forwards the payload, and the browser wakes the service worker. This is observable — the service worker runs your push event handler. If the service worker is unregistered, broken, or out of memory, this state is never reached, and the vendor gives you no indication that delivery failed.

State 3 — Notification displayed: Calling self.registration.showNotification() inside the push event is what actually renders the notification. If the call throws (bad icon URL, permission revoked mid-flight, notification tag conflict), the notification is silently dropped. The service worker itself may have succeeded — the push event fired and your beacon went out — but the user saw nothing.

No single server-side signal distinguishes which of these three states failed. That is the root cause of all “missing receipt” debugging pain.

Why the 201 Is a Poor Delivery Proxy

At face value, a 201 with no subsequent error looks like success. In practice, it means the vendor accepted the message for its own queue — a queue your code cannot inspect, poll, or subscribe to. The vendor’s internal delivery SLA is opaque. For this reason, treating 201 as “delivered” produces inflated delivery metrics and masks real reach problems.

This is documented behavior in the Delivery Tracking & Acknowledgment pipeline: the correct internal state after a 201 is PENDING_DELIVERY, not DELIVERED. The gap between PENDING_DELIVERY and a confirmed RECEIVED acknowledgment is exactly what the beacon pattern closes.

The Backend Delivery Architecture & Queue Management layer is responsible for tracking all three states. Architecturally, that means your dispatch system must write a ledger entry at send time, and separately update it when a service-worker beacon arrives.

Service Worker Beacon Implementation

The beacon fires from within the push event handler using event.waitUntil() to extend the service worker’s lifetime until the fetch completes. Without waitUntil(), the browser may terminate the service worker before the network request goes out.

The notification ID must be embedded in the push payload at send time. There is no other reliable way to correlate a service worker execution back to a specific server-side dispatch record.

// service-worker.js

self.addEventListener('push', (event) => {
  // Parse the push payload — always validate before use
  let data = {};
  if (event.data) {
    try {
      data = event.data.json();
    } catch {
      data = { body: event.data.text() };
    }
  }

  const notificationId = data.notificationId;
  const title = data.title ?? 'Notification';
  const options = {
    body: data.body ?? '',
    icon: data.icon ?? '/icons/icon-192.png',
    badge: data.badge ?? '/icons/badge-72.png',
    tag: notificationId,          // Deduplicates concurrent deliveries
    data: { notificationId },
  };

  // Chain the beacon and showNotification inside a single waitUntil.
  // If either rejects, the browser still considers the push event handled.
  event.waitUntil(
    Promise.allSettled([
      // Beacon: confirm service-worker receipt before attempting display.
      // Use keepalive:true so the request survives SW termination edge cases.
      fetch('/api/push/received', {
        method: 'POST',
        keepalive: true,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          notificationId,
          receivedAt: Date.now(),
          clientTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
        }),
      }),
      // Display: a separate settled promise so a failed beacon doesn't
      // prevent the notification from rendering, and vice versa.
      self.registration.showNotification(title, options),
    ])
  );
});

// Optional: beacon a "displayed" signal on explicit user interaction.
self.addEventListener('notificationclick', (event) => {
  const { notificationId } = event.notification.data ?? {};
  event.notification.close();

  if (notificationId) {
    event.waitUntil(
      fetch('/api/push/clicked', {
        method: 'POST',
        keepalive: true,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ notificationId, clickedAt: Date.now() }),
      })
    );
  }
});

Promise.allSettled() is intentional. If showNotification() throws because the user revoked permission between the push event firing and the call executing, you still want the beacon to reach your backend. That failure is diagnostic information. Using Promise.all() would mask beacon success when display fails.

Server-Side Reconciliation

The reconciliation function compares the push-sent ledger against received beacons to surface gaps. Run this on a schedule — every 15–30 minutes for time-sensitive notifications, or as a nightly batch for lower-urgency traffic. This feeds directly into the analytics workflow described in Delivery Analytics & Instrumentation.

// reconcile-push-receipts.ts
// Compares dispatched records against service-worker beacons.
// Assumes PostgreSQL with two tables:
//   push_dispatches(notification_id, sent_at, ttl_seconds, endpoint_hash, status)
//   push_receipts(notification_id, received_at, source)

import { Pool } from 'pg';

type ReconciliationResult = {
  notificationId: string;
  sentAt: Date;
  receivedAt: Date | null;
  ttlExpiredAt: Date | null;
  gap: 'RECEIVED' | 'TTL_EXPIRED' | 'MISSING' | 'WITHIN_TTL_WINDOW';
};

export async function reconcilePushReceipts(
  pool: Pool,
  windowMinutes = 30
): Promise<ReconciliationResult[]> {
  const windowStart = new Date(Date.now() - windowMinutes * 60 * 1000);

  // Fetch dispatches in the reconciliation window that have a
  // PENDING_DELIVERY status — i.e., a 201 was received but no beacon yet.
  const { rows: dispatches } = await pool.query<{
    notification_id: string;
    sent_at: Date;
    ttl_seconds: number;
  }>(
    `SELECT notification_id, sent_at, ttl_seconds
     FROM push_dispatches
     WHERE sent_at >= $1
       AND status = 'PENDING_DELIVERY'
     ORDER BY sent_at ASC`,
    [windowStart]
  );

  if (dispatches.length === 0) return [];

  const notificationIds = dispatches.map((d) => d.notification_id);

  // Fetch all beacons for those IDs in one query.
  const { rows: receipts } = await pool.query<{
    notification_id: string;
    received_at: Date;
  }>(
    `SELECT notification_id, received_at
     FROM push_receipts
     WHERE notification_id = ANY($1)`,
    [notificationIds]
  );

  const receiptMap = new Map(receipts.map((r) => [r.notification_id, r.received_at]));

  const results: ReconciliationResult[] = dispatches.map((dispatch) => {
    const receivedAt = receiptMap.get(dispatch.notification_id) ?? null;
    const ttlExpiredAt = new Date(
      dispatch.sent_at.getTime() + dispatch.ttl_seconds * 1000
    );
    const now = new Date();

    let gap: ReconciliationResult['gap'];
    if (receivedAt) {
      gap = 'RECEIVED';
    } else if (ttlExpiredAt < now) {
      gap = 'TTL_EXPIRED';
    } else if (ttlExpiredAt > now) {
      // Still within the TTL window — beacon may arrive later.
      gap = 'WITHIN_TTL_WINDOW';
    } else {
      gap = 'MISSING';
    }

    return {
      notificationId: dispatch.notification_id,
      sentAt: dispatch.sent_at,
      receivedAt,
      ttlExpiredAt,
      gap,
    };
  });

  // Persist MISSING and TTL_EXPIRED gaps for alerting and analytics.
  const undelivered = results.filter(
    (r) => r.gap === 'MISSING' || r.gap === 'TTL_EXPIRED'
  );

  if (undelivered.length > 0) {
    const ids = undelivered.map((r) => r.notificationId);
    const newStatus = (r: ReconciliationResult) =>
      r.gap === 'TTL_EXPIRED' ? 'EXPIRED' : 'UNDELIVERED';

    // Batch update — one query per gap type to keep the plan simple.
    await pool.query(
      `UPDATE push_dispatches
       SET status = CASE
         WHEN notification_id = ANY($1) THEN 'EXPIRED'
         ELSE 'UNDELIVERED'
       END,
       reconciled_at = NOW()
       WHERE notification_id = ANY($2)`,
      [
        undelivered.filter((r) => r.gap === 'TTL_EXPIRED').map((r) => r.notificationId),
        ids,
      ]
    );
  }

  return results;
}

Pair the UNDELIVERED rows with endpoint hash lookups. A subscription with repeated UNDELIVERED results across multiple notification IDs is a candidate for staleness analysis — distinct from a 410 but still worth flagging for list hygiene. See Retry Logic & Backoff Strategies for how to handle re-dispatch decisions on confirmed delivery gaps.

Diagnostic Steps

  1. Confirm your payload includes a stable notificationId. Every push payload sent by the server must embed a unique notification ID that survives JSON serialization. Verify this by logging the raw payload body before dispatch. If the ID is missing or inconsistent, no reconciliation is possible.

  2. Verify the beacon endpoint responds under service-worker constraints. Test /api/push/received from a service worker context. Use browser DevTools → Application → Service Workers → “Offline” toggle while triggering a synthetic push (DevTools → Application → Push). Confirm the beacon fires via the Network panel of the service worker’s DevTools scope. CORS headers must permit the origin if the beacon endpoint differs from the SW origin.

  3. Check waitUntil() coverage. If the beacon promise is not wrapped in event.waitUntil(), the browser terminates the service worker before the fetch completes on low-memory devices. Inspect chrome://serviceworker-internals or about:debugging#/runtime/this-firefox to confirm SW lifetime during push events.

  4. Correlate beacon timestamps against send timestamps. If beacons arrive with a delay of more than the configured TTL window, the message was held by the vendor due to device offline state. This is expected behavior, not a bug — but it explains gaps in near-real-time dashboards. See Tracking Push Delivery vs Display Rates for how to bucket these into meaningful cohorts.

  5. Audit notification permission state at beacon time. The push event fires even when the user has blocked notifications at the OS level (some platforms allow the vendor to wake the SW, but the OS suppresses display). In that case, showNotification() throws or silently no-ops depending on the platform. The beacon fires but the display never happens. Track this as a distinct RECEIVED_NO_DISPLAY state if your analytics require display fidelity.

  6. Check your TTL configuration against beacon arrival rates. If a high fraction of beacons arrive after the reconciliation window closes but before TTL expiry, your reconciliation window is too short. Widen the window or run the reconciliation in two passes — one at 15 minutes and one at TTL expiry — to reduce false MISSING classifications.

  7. Validate keepalive: true on the beacon fetch. Without keepalive, the request may be aborted when the service worker is terminated before the response is received. The server may still log the request if TCP data arrived, but the beacon is unreliable. Always set keepalive: true for SW beacon fetches.

Gotchas and Edge Cases

  • FCM coalescing silently drops duplicate tags. If two push messages share the same notification tag and the device is offline, FCM may deliver only the most recent one. Your backend will see two 201s but receive only one beacon. Deduplication by tag on the client hides upstream drops.

  • Push event fires without a payload when the TTL is 0 and urgency is very-low. Some vendors deliver a “wake-up ping” with no data when TTL is zero, causing event.data to be null. If your push handler unconditionally calls event.data.json() without null-checking, the SW throws uncaught, the notification is never displayed, and no beacon is sent. Always guard with if (event.data).

  • Service worker update races can swallow push events. If a new service worker version is waiting to activate when a push event fires, some browsers route the push to the old SW and some to the new one. During the activation race, the event may fire in an SW version that has no push listener registered yet. Monitor for beacon gaps around deployment windows.

  • Beacon beaconing itself introduces a timing gap. The beacon reaching your server does not mean showNotification() succeeded. Promise.allSettled() resolves both settled, but only the presence of a subsequent notificationclick beacon confirms visual display. If you need display confirmation, you must beacon on notificationshow (non-standard, Chrome 94+) or infer from notificationclick / notificationclose events.

  • Private browsing and storage partitioning. Chromium-based browsers disable push subscriptions in private/incognito contexts in most configurations. Safari’s Intelligent Tracking Prevention can partition service worker storage in ways that silently prevent push event delivery without any protocol-level error.

Three-State Delivery Gap Diagram

Web Push Three-State Delivery Gap Diagram showing three sequential checkpoints: push service returns HTTP 201, service worker push event fires and sends beacon, and notification is displayed to the user. Arrows between states are labeled with potential failure modes including silent vendor drops, SW termination, and permission revocation. State 1 Push service returns HTTP 201 TTL drop / vendor outage State 2 Service worker push event fires SW error / perm revoked State 3 Notification displayed SW beacon → /api/push/received

Each state transition can fail silently — only the beacon makes State 2 observable

The three-state delivery gap in web push. HTTP 201 confirms vendor ingestion only. The service worker beacon (dashed arrow) is the first server-observable signal that State 2 was reached. State 3 requires interaction events to confirm.

Back to Delivery Tracking & Acknowledgment

FAQ

Does the push service ever send a delivery confirmation back to my server automatically?

No. The Web Push Protocol (RFC 8030) defines no delivery receipt or callback mechanism. After returning 201, the vendor’s obligation to your server ends. Any confirmation of client-side receipt must originate from the client — specifically from your service worker — and be transmitted back to your server over a separate channel. Some proprietary SDKs (FCM Data Messages via the legacy XMPP API) have offered delivery callbacks, but the standard Web Push Protocol has none. Do not design your architecture expecting the push endpoint to notify you of delivery success or failure beyond the initial HTTP response.

What happens if the service worker is terminated before the beacon fetch completes?

The browser may terminate the service worker to reclaim memory after the push event handler returns. If your beacon fetch is not covered by event.waitUntil(), the SW lifetime is not extended and the request may be aborted mid-flight. Setting keepalive: true on the fetch() call instructs the browser to keep the underlying TCP connection alive and attempt to deliver the request body even after the SW context is destroyed. This is analogous to navigator.sendBeacon() semantics. Note that keepalive requests are capped at 64 KB per origin in Chromium, which is more than sufficient for a JSON beacon payload. Always combine event.waitUntil() with keepalive: true for maximum reliability.

How do I distinguish a notification that was displayed from one that was silently suppressed by the OS?

You cannot do so reliably from the service worker alone. showNotification() resolves without throwing even when the OS notification center suppresses the alert (for example, in Do Not Disturb mode on macOS or Focus mode on iOS). Chrome 94+ introduced the Notification.prototype.onshow event but it is non-standard and not cross-browser. The most practical approach is to treat a beacon from inside the push event as “service worker received” rather than “displayed,” and use subsequent notificationclick or notificationclose events as proxies for user visibility. Structurally, this means you will always have a fraction of beaconed notifications with no interaction event — some because the user ignored them, some because the OS suppressed them. You cannot separate these two populations without device-level telemetry outside the web platform’s scope.