Why Push Delivered Counts Exceed Displayed Counts

A campaign reports 100,000 delivered and 71,000 displayed, and nobody can say where the missing 29,000 went. The gap is real, it is explainable, and almost all of it is created by a single reporting mistake: counting 201 Created as a delivery.

Quick Answer

201 Created is an acceptance receipt from the push service, not a delivery receipt. It confirms the push service took custody of an encrypted blob. Everything after that — storage, forwarding, waking the browser, decrypting, running your handler, and drawing a notification — happens outside your observability, and each stage sheds messages silently.

A typical Chrome-heavy consumer campaign loses messages in roughly these proportions:

Stage Loss Remaining Visible to sender?
Push service accepted (201 Created) 100,000 Yes — HTTP status
TTL elapsed before the device reconnected 9,400 90,600 No
Browser never ran inside the retention window 7,100 83,500 No
Doze / App Standby / iOS low-power deferral 5,200 78,300 No
Handler threw before showNotification() 4,300 74,000 Only if you instrument it
Replaced by a later message with the same Topic 2,100 71,900 No
Notification budget or silent-push penalty 900 71,000 Only if you instrument it
Actually drawn on screen 71,000 Only if you instrument it
Attrition from accepted to displayed across one push campaign A horizontal waterfall. The top bar is 100,000 messages accepted with a 201 Created response. Six shrinking bars below subtract TTL expiry of 9,400, browser not running of 7,100, Doze and low power of 5,200, handler errors of 4,300, Topic collapse of 2,100 and budget suppression of 900, leaving a final bar of 71,000 notifications actually displayed. Accepted to displayed: one campaign, 100,000 sends Accepted (201) 100,000 taken by the push service TTL elapsed 90,600 -9,400 Browser not running 83,500 -7,100 Doze / low power 78,300 -5,200 Handler threw 74,000 -4,300 Topic collapse 71,900 -2,100 Budget suppressed 71,000 -900 Displayed 71,000 drawn on screen Every row above returned 201 Created. None of the six losses produces an error for the sender.
The attrition ledger: six silent loss stages sit between the HTTP acceptance you can measure and the display event you usually cannot.

What 201 Created Actually Proves

RFC 8030 deliberately gives the application server no delivery feedback. The POST to the subscription endpoint returns 201 Created the moment the push service has written the encrypted payload to its own storage. The response carries a Location header (the push message resource) and a TTL header echoing the retention the service actually applied — which may be lower than the value you asked for.

That is the entire acknowledgment surface. There is no callback, no webhook, no status polling endpoint, and no way to ask “was message X shown?” after the fact. The delivery tracking and acknowledgment layer you build in your own application is the only thing that can close the loop, and it has to run inside the service worker because that is the first place your code executes on the device.

The observability boundary of a 201 Created response Five boxes left to right: app server, push service, browser and operating system, service worker, notification. A green bracket over the first two boxes is labelled 201 Created proves this. A red dashed bracket over the remaining three boxes is labelled nothing is reported back. Numbered markers under the last four boxes map to a legend listing TTL expiry and Topic collapse, Doze and a browser that is not running, a handler that throws before showNotification, and notification budget suppression. 201 Created proves this Silent: nothing is reported back App server Push service Browser / OS Service worker Notification 1 2 3 4 1 TTL elapses in storage, or a later message with the same Topic overwrites this one. 2 Doze, App Standby or low-power mode defers the wake-up; the browser may never run. 3 The push handler throws, rejects or times out before showNotification() resolves. 4 Budget penalties, quiet hours or an identical tag suppress or replace the card.
The acknowledgment boundary sits one hop from your server. Loss points 1 to 4 all occur inside the region no HTTP status code describes.

The Six Loss Classes

1. TTL elapsed before the device reconnected

A message sent with TTL: 3600 to a phone that stays offline for four hours is discarded by the push service without notice. This is the single largest contributor in most consumer campaigns because send windows concentrate in the evening and a meaningful slice of the audience is on aeroplane mode, out of coverage, or on a laptop that is shut. Sends with TTL: 0 are worse and better at once: they drop far more, but they drop predictably. The trade-off is covered in depth in TTL 0 vs TTL 86400 delivery guarantees.

2. The browser never ran inside the retention window

Push delivery requires a live connection between the browser and the push service. On desktop that connection exists only while the browser process is running; Chrome and Edge keep a background process alive after the last window closes only if the user has “continue running background apps” enabled, and Firefox does not keep one at all. A user who quits the browser on Friday evening and opens it Monday morning has a TTL=86400 message that expired on Saturday.

3. Doze, App Standby and iOS low-power deferral

An Android device in Doze holds its network stack down between maintenance windows. The push service still holds the message and the TTL is still ticking, but the device does not acknowledge until the next window — which can be hours out on a stationary, unplugged phone. Urgency: high requests priority escalation and bypasses Doze for genuinely urgent messages, at the cost of stricter per-application rate limits. iOS Safari applies its own budget: web push on a home-screen web app is deferred aggressively in Low Power Mode and when the app has not been opened recently. This class overlaps heavily with why push messages arrive hours late — the same mechanism produces late arrivals or no arrival, depending on whether the TTL survives the deferral.

4. The handler threw before showNotification()

This is the class you own, and the only one you can fix with a deploy. A push event whose handler rejects — or whose promise never settles inside event.waitUntil() — produces no notification. Common causes: event.data.json() on a payload that is not JSON, a fetch() for personalisation content that times out, an IndexedDB read that fails while the origin’s storage is being evicted, or a decryption failure surfacing as an empty event.data (see aes128gcm encoding and decryption errors). Chrome will show a generic “This site has been updated in the background” card when a push handler resolves without calling showNotification(), but a handler that throws can skip even that on some versions.

5. Notification budget and silent-push penalties

Chrome enforces a budget on pushes that do not result in a user-visible notification. Each visible notification credits the origin; each silent push debits it. Once the balance is exhausted, Chrome may drop subsequent push messages for that origin entirely — no handler invocation, no generic card, nothing. Background-sync-style pushes that intentionally skip showNotification() burn this budget quickly. If your delivered-to-displayed ratio degrades over the course of a week rather than being flat, budget exhaustion is the first thing to check.

6. Collapse and Topic overwrites

The Topic header (FCM’s collapse key) is a deduplication instruction: a new message with the same topic replaces the stored one. Send four price-drop alerts for the same product to an offline user with Topic: price-42, and the push service delivers exactly one. Four 201 Created responses, one displayed notification. Any dashboard that treats each 201 as a delivery over-counts by three. This is intentional and usually desirable — but it must be modelled in your metrics, not discovered in a quarterly review.

Instrumenting the Real Display Event

The fix is to stop inferring display and start recording it. Emit two beacons from the service worker: one when the push event fires, and one after showNotification() has resolved. The delta between them is your handler failure rate; the delta between 201 counts and the first beacon is everything the device and push service ate.

// sw.js — every path emits exactly one terminal beacon.
const BEACON = '/api/push-events';

function report(event, body) {
  // keepalive lets the beacon survive service worker termination.
  return fetch(BEACON, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...body, at: Date.now() }),
    keepalive: true,
  }).catch(() => undefined); // never let telemetry break delivery
}

self.addEventListener('push', (event) => {
  event.waitUntil((async () => {
    let msg;
    try {
      msg = event.data ? event.data.json() : null;
    } catch (err) {
      await report(event, { phase: 'parse_failed', error: String(err) });
      // Still show something — a silent push costs notification budget.
      await self.registration.showNotification('New update', { tag: 'fallback' });
      return;
    }

    if (!msg || !msg.messageId) {
      await report(event, { phase: 'no_payload' });
      await self.registration.showNotification('New update', { tag: 'fallback' });
      return;
    }

    await report(event, { phase: 'received', messageId: msg.messageId });

    // Stale-message guard: do not draw an expired offer that sat in storage.
    const ageSeconds = (Date.now() - msg.sentAt) / 1000;
    if (msg.ttl && ageSeconds > msg.ttl) {
      await report(event, { phase: 'suppressed', reason: 'stale', messageId: msg.messageId, ageSeconds });
      // A suppressed push is a silent push — draw a low-key card to protect budget.
      await self.registration.showNotification(msg.fallbackTitle || 'Updates available', { tag: msg.tag });
      return;
    }

    try {
      await self.registration.showNotification(msg.title, {
        body: msg.body,
        icon: msg.icon,
        tag: msg.tag,
        data: { messageId: msg.messageId, url: msg.url },
      });
      // Confirm the card actually exists before claiming a display.
      const shown = await self.registration.getNotifications({ tag: msg.tag });
      await report(event, {
        phase: shown.length > 0 ? 'displayed' : 'display_missing',
        messageId: msg.messageId,
      });
    } catch (err) {
      await report(event, { phase: 'show_failed', messageId: msg.messageId, error: String(err) });
      throw err; // surface in the browser's service worker error log
    }
  })());
});
Beacon sequence that reconciles delivered against displayed Three lifelines: push service, service worker and metrics endpoint. The push service sends an encrypted push event to the service worker, which beacons received to the metrics endpoint, awaits showNotification, beacons displayed, runs a getNotifications audit, and beacons suppressed with a reason when no card was drawn. A note explains that the displayed metric is the count of displayed beacons, never the count of 201 responses. Push service Service worker Metrics endpoint push event (aes128gcm payload) beacon: received await showNotification() beacon: displayed getNotifications() audit beacon: suppressed + reason Displayed = the count of displayed beacons, never the count of 201 responses. A push that never wakes the handler emits no beacon at all — that silence is the gap.
Three terminal beacons — received, displayed, suppressed — turn an unmeasurable gap into three named buckets.

Reconciling the Two Numbers

With beacons landing, the reconciliation becomes a single query. Store one row per dispatch keyed by message_id, then left-join the beacon table.

SELECT
  d.campaign_id,
  COUNT(*)                                                        AS accepted_201,
  COUNT(*) FILTER (WHERE b.phase IS NOT NULL)                     AS reached_handler,
  COUNT(*) FILTER (WHERE b.phase = 'displayed')                   AS displayed,
  COUNT(*) FILTER (WHERE b.phase IN ('show_failed','parse_failed')) AS handler_errors,
  COUNT(*) FILTER (WHERE b.phase = 'suppressed')                  AS suppressed,
  COUNT(*) FILTER (WHERE b.phase IS NULL)                         AS never_reached_device,
  ROUND(100.0 * COUNT(*) FILTER (WHERE b.phase = 'displayed') / COUNT(*), 1) AS display_rate_pct
FROM push_dispatch d
LEFT JOIN LATERAL (
  SELECT phase FROM push_beacon
  WHERE message_id = d.message_id
  ORDER BY at DESC LIMIT 1
) b ON TRUE
WHERE d.status_code = 201
  AND d.sent_at >= NOW() - INTERVAL '24 hours'
GROUP BY d.campaign_id;

never_reached_device is the bucket that used to be invisible. It still cannot be attributed to a specific cause per message — the push service will not tell you whether it was TTL expiry or collapse — but it can be attributed statistically by segmenting on the TTL and Topic you sent, and on the last-seen timestamp of the subscription. Subscriptions last seen more than 30 days ago carry a much higher never-reached rate and are also the ones most likely to be returning 410 Gone soon.

Gotchas and Edge Cases

  • Beacon loss inflates the gap. A fetch() from a terminating service worker is cancelled unless you pass keepalive: true and keep the body small. Undersized beacon infrastructure looks exactly like device-side loss. Validate by comparing beacon counts against notification click counts for the same campaign — clicks cannot exceed displays.
  • tag reuse silently collapses on the device too. Two notifications with the same tag produce one visible card; the second replaces the first. getNotifications({ tag }) returns one entry either way, so both messages report displayed. If you need per-message display accounting, make the tag unique per message and use renotify deliberately.
  • requireInteraction: false cards auto-dismiss after roughly 20 seconds on desktop. They were displayed, so counting them as displayed is correct — but the user may never have seen them. Track a separate “seen” signal from notificationclick and notificationclose if that distinction matters to the campaign.
  • Firefox does not implement Chrome’s notification budget. A delivered-to-displayed gap that appears only in Chrome traffic and not Firefox traffic is strong evidence of budget exhaustion rather than a handler bug. Segment the reconciliation query by user agent before debugging code.
  • Retries multiply the accepted count, not the displayed count. A dispatch retried twice after a 503 produces up to three 201 responses for one logical message. Deduplicate on message_id before dividing, or your denominator is wrong and the gap is partly an artefact of your own retry logic.

FAQ

Can I get a real delivery receipt from FCM or Mozilla Autopush?

No. Neither service exposes a per-message delivery callback for Web Push endpoints. FCM’s delivery-data export applies to its native FCM SDK integrations, not to RFC 8030 Web Push subscriptions created through pushManager.subscribe(). The only reliable display signal is a beacon emitted by your own service worker after showNotification() resolves.

Why is my displayed count higher than my delivered count on some days?

Almost always a clock or window problem. Beacons arrive when the device reconnects, which can be hours after the dispatch was recorded, so a display can land in a later reporting window than its acceptance. Join beacons to dispatches on message_id and attribute the display to the dispatch’s timestamp, not the beacon’s. The other cause is duplicate beacons from a service worker that ran the handler twice after being restarted mid-event; deduplicate on (message_id, phase).

Does sending a silent push to sync data really cost me future notifications?

In Chrome, yes. The origin holds a notification budget that is credited by user-visible notifications and debited by pushes that do not show one. Exhaust it and Chrome stops waking your service worker for subsequent pushes entirely — the messages are dropped before your code runs, and you still receive 201 Created for each. Keep silent pushes rare, or draw a low-key notification on the paths that would otherwise be silent.

Back to Delivery Tracking & Acknowledgment