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
-
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. -
Verify the beacon endpoint responds under service-worker constraints. Test
/api/push/receivedfrom 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. -
Check
waitUntil()coverage. If the beacon promise is not wrapped inevent.waitUntil(), the browser terminates the service worker before the fetch completes on low-memory devices. Inspectchrome://serviceworker-internalsorabout:debugging#/runtime/this-firefoxto confirm SW lifetime during push events. -
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.
-
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 distinctRECEIVED_NO_DISPLAYstate if your analytics require display fidelity. -
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
MISSINGclassifications. -
Validate
keepalive: trueon the beacon fetch. Withoutkeepalive, 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 setkeepalive: truefor SW beacon fetches.
Gotchas and Edge Cases
-
FCM coalescing silently drops duplicate tags. If two push messages share the same notification
tagand the device is offline, FCM may deliver only the most recent one. Your backend will see two201s 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
urgencyisvery-low. Some vendors deliver a “wake-up ping” with no data when TTL is zero, causingevent.datato be null. If your push handler unconditionally callsevent.data.json()without null-checking, the SW throws uncaught, the notification is never displayed, and no beacon is sent. Always guard withif (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
pushlistener 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 subsequentnotificationclickbeacon confirms visual display. If you need display confirmation, you must beacon onnotificationshow(non-standard, Chrome 94+) or infer fromnotificationclick/notificationcloseevents. -
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
Related
- Delivery Tracking & Acknowledgment — Production patterns for tracking dispatch events and building auditable acknowledgment pipelines.
- TTL & Expiration Handling — Configure TTL values to minimize silent vendor-side drops that produce irreconcilable gaps.
- Retry Logic & Backoff Strategies — Decide when and whether to re-dispatch notifications with confirmed
UNDELIVEREDorEXPIREDstates. - Tracking Push Delivery vs Display Rates — Build cohorted dashboards that separate 201-based reach from beacon-confirmed delivery.
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.