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 |
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 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
}
})());
});
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 passkeepalive: trueand 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. tagreuse silently collapses on the device too. Two notifications with the sametagproduce one visible card; the second replaces the first.getNotifications({ tag })returns one entry either way, so both messages reportdisplayed. If you need per-message display accounting, make the tag unique per message and userenotifydeliberately.requireInteraction: falsecards 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 fromnotificationclickandnotificationcloseif 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
503produces up to three201responses for one logical message. Deduplicate onmessage_idbefore 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.
Related
- Debugging Missing Push Delivery Receipts — how to build the beacon pipeline itself and detect when the beacons, not the pushes, are what went missing.
- Tracking Push Delivery vs Display Rates — the analytics-side view of the same two numbers, including how to report them without misleading stakeholders.
- Why Push Messages Arrive Hours Late — the same deferral mechanisms seen from the latency side rather than the loss side.
- Handling 410 Gone Responses at Scale — pruning the dead endpoints that quietly depress your display rate before they start erroring.