Tracking Push Delivery vs Display Rates
A notification can reach the device and still never appear on screen, so delivery rate and display rate are two different numbers you must track separately.
Quick answer
Delivered means the browser fired the push event in your service worker — the encrypted payload arrived. Displayed means your handler successfully called showNotification() and the OS rendered the notification. The gap between them comes from the silent-push budget being exceeded, handler errors, OS-level suppression (Focus/Do Not Disturb modes), or coalescing by tag. Track delivery with a beacon fired at the start of the push handler and display with a beacon fired after showNotification() resolves; the ratio of the two is your display rate.
| Metric | Measured when | Fires from | What a low value means |
|---|---|---|---|
| Delivery rate | push event runs |
start of push handler | Endpoints expired, TTL lapsed, device offline |
| Display rate | showNotification() resolves |
after the call | Silent-push budget, handler error, OS suppression |
Why delivered ≠ displayed
The two rates also drift apart for reasons that have nothing to do with your code, which is why you instrument both rather than inferring one from the other. A device that is offline when the push is sent will receive the push event later, when it reconnects within the message TTL — so a delivery counted at 9 a.m. server time might display at noon device time, and a naive same-day comparison will undercount it. Conversely, a push the service drops because the TTL lapsed before the device came online never fires the push event at all, so it is neither delivered nor displayed from your instrumentation’s point of view even though your server counted it as sent. Holding the delivered and displayed numbers as independent measurements, each captured at the moment its event actually occurs, is the only way to keep these timing effects from corrupting your funnel.
Web Push requires that every delivered push results in a user-visible notification — this is the userVisibleOnly: true contract you accept at subscribe time. Browsers enforce it with a silent-push budget: if your service worker repeatedly receives a push event without calling showNotification(), the browser spends down a budget and may eventually show a generic “This site has been updated in the background” notification or throttle delivery entirely. So a delivered push that your handler fails to display is not a free silent message — it actively damages your standing with the browser.
Other causes widen the gap. If your handler throws before reaching showNotification() — a malformed payload, an await that rejects — the push is delivered but nothing renders. OS focus modes (Do Not Disturb, macOS Focus, Windows Focus Assist) suppress display while still delivering the event. And notifications sharing a tag collapse into one, so three delivered pushes can yield one displayed notification by design. This is the display-side detail behind the broader delivery analytics instrumentation guide; server-side delivery acknowledgment is covered separately under delivery tracking and acknowledgment.
requireInteraction does not change whether a notification displays, but it changes how long it persists — without it, Chromium auto-dismisses after a few seconds, which can make a displayed notification feel “missed” even though it counted.
It helps to separate the two rates by who controls them. Delivery rate is largely outside your runtime control: it depends on whether the endpoint is still valid, whether the device came online before the TTL expired, and how the push service prioritized your message. You influence it with endpoint hygiene and sensible TTLs, but you cannot force a delivery. Display rate is almost entirely inside your control: with the single exception of OS focus modes and the browser’s own budget enforcement, whether a delivered push becomes a visible notification comes down to your service worker code reaching showNotification() cleanly. That asymmetry is why a low display rate is usually a faster fix than a low delivery rate — the fault is in code you own. A campaign showing 98% delivery but 70% display is telling you your push handler is failing on roughly three of every ten devices, and the display_failed beacon below is how you find out why.
Instrument both rates with one handler
Fire the delivery beacon first, then display only after the render call resolves. Both run inside event.waitUntil() with fetch keepalive so the worker is not killed mid-request. The VAPID public key stays server-side in process.env.VAPID_PUBLIC_KEY; payloads stay under the 4 KB aes128gcm limit.
// sw.js: separate delivered and displayed beacons
const BEACON = 'https://api.yourdomain.com/push-events';
const beacon = (type, m) => fetch(BEACON, {
method: 'POST', keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, ...m, ts: Date.now() }),
}).catch(() => {});
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
const meta = { send_id: data.data?.send_id, campaign_id: data.data?.campaign_id };
event.waitUntil((async () => {
await beacon('delivered', meta); // the gap denominator
try {
await self.registration.showNotification(data.title, {
body: data.body, data: data.data, tag: meta.send_id,
requireInteraction: !!data.data?.urgent,
});
await beacon('displayed', meta); // the gap numerator
} catch (err) {
await beacon('display_failed', { ...meta, reason: String(err) });
}
})());
});
The display_failed beacon is what turns a mysterious gap into a diagnosable one. Without it, a delivered push that fails to render simply vanishes from your funnel — you see the delivery, you never see a display, and you have no record of why. Capturing the thrown error’s message in the reason field lets you group failures: a batch of JSON parse errors points at a malformed payload from a specific campaign, a run of quota or budget errors points at silent-push enforcement, and repeated permission errors point at a subscription that should have been pruned. The beacon costs one extra fetch on the failure path, where you have nothing to lose.
Note the deliberate tag: meta.send_id. Using the unique send id as the tag means each notification renders as its own entry; reusing a static tag across sends would cause the OS to replace the previous notification, which is sometimes what you want (a live score that updates in place) but will make displayed counts look lower than delivered counts when it is not. Decide tagging policy per campaign and make sure your display-rate expectations match it.
Both beacons live inside the same waitUntil promise, which keeps the worker alive until they settle. The delivered beacon is awaited before showNotification() so that even a synchronous throw in the render path still leaves a recorded delivery to anchor the gap against. The try/catch then converts any render failure into a display_failed event instead of an unhandled rejection — without it, a thrown error would propagate out of waitUntil, the runtime would log an uncaught error, and you would lose both the diagnostic beacon and any chance of a fallback render. If you want a last-resort guarantee that something shows, you can call showNotification() again inside the catch with a generic title, which both satisfies the browser’s user-visible contract and protects your silent-push budget.
Diagnostic steps
- Compute the gap. Query
displayed / deliveredper campaign. A healthy value is near 100%; anything under ~95% warrants investigation. Track the gap over time, not just as a snapshot — a slowly widening gap often signals a payload-shape change that started throwing on a subset of clients after a deploy. - Check for
display_failedevents. If they dominate the gap, your handler is throwing — inspect thereasonfield and validate payload shape. - Audit silent pushes. In Chrome DevTools → Application → Service Workers, send a test push and confirm
showNotification()is always reached. A budget warning notification means you have been delivering without displaying. - Rule out
tagcoalescing. If multiple sends share atag, expect displayed < delivered by design. Use a uniquesend_idas the tag when you want each to render. - Test under OS focus modes. Enable Do Not Disturb and resend; if delivered fires but displayed does not render visibly, the gap is OS suppression, not your code.
Gotchas & edge cases
- The silent-push budget is per-origin and opaque. You cannot read it. The only safe rule is: always call
showNotification()on everypushevent, even with a fallback title. keepalivebeacons can be retried, inflating counts. Dedupe ingest on(send_id, type)so the gap is not distorted by double-counted deliveries.- A thrown error after the display beacon does not un-display the notification but can corrupt later logic — keep beacon calls ordered and guarded.
- Firefox and Chromium report the gap differently because their suppression heuristics differ; compare each engine against itself over time rather than against each other.
requireInteractionis ignored on some platforms (notably mobile and Safari), so do not treat its presence as a guarantee the notification lingered.
Related
- Back to Delivery Analytics Instrumentation — the full funnel instrumentation reference.
- Delivery tracking & acknowledgment — server-side delivery receipts and bounce handling.
- Cross-browser notification quirks — engine-specific display behaviour.
FAQ
What is the silent-push budget?
It is a browser-enforced allowance for receiving push events without showing a notification. Because you subscribe with userVisibleOnly: true, every push is expected to produce a visible notification. Repeatedly skipping showNotification() spends the budget, after which the browser may show a generic background-update notification or throttle delivery. Always call showNotification() on every push.
Why is my display rate below my delivery rate?
Common causes are the handler throwing before it reaches showNotification(), OS focus or Do Not Disturb modes suppressing the render, and notifications collapsing because they share a tag. Add a display_failed beacon to distinguish handler errors from suppression, and use unique tags when each notification should render.