Blog · Metrics
How to measure the click-through rate of your push notifications
How sent, delivered and clicked differ, which denominator to use, how to record events in the service worker, and how to follow a click through to a conversion.
The click-through rate of a push notification is clicks divided by delivered notifications, meaning the ones the browser actually displayed, not by notifications sent. To measure it, your service worker must record two events per device (notification shown and notification clicked) against a stable ID for each send, and you must always compare within the same time window.
In short:
- The funnel has four steps: targeted, sent, delivered and clicked; each one loses devices for different reasons.
- Use clicks over delivered to judge content, and delivered over sent to judge deliverability.
- Count at most one click per device per notification.
- Add UTM parameters to the URL to follow the path from click to conversion.
- There is no universal "good" rate: compare against your own history and by message type.
Sent, delivered and clicked are not the same thing
There are several hops between your server and the user's finger, and each one is known to a different part of the system:
| Step | What it means | Who knows it | Why devices drop out |
|---|---|---|---|
| Targeted | Active subscriptions matching the target | Your server | — |
| Sent | The push service accepted the message (201) | The push service response | Expired subscriptions (404/410), errors |
| Delivered | The browser received the message and showed the notification | The service worker on the device | Device offline until TTL runs out |
| Clicked | The person tapped the notification | The service worker, in notificationclick | User choice |
In practice, "sent" tells you nothing about whether anyone saw the notification. A message with a short TTL can be accepted by the push service and never reach a laptop that stayed closed all weekend; see TTL and urgency in web push.
Which denominator to use
A hypothetical example: you send a notification to 10,000 devices, 8,000 receive it and 400 people click. The rate over delivered is 5%; over sent, 4%. Both numbers are correct, but they answer different questions:
- Clicks / delivered: did the title, body and timing persuade the people who saw it? This is the metric for comparing content.
- Delivered / sent: how many devices actually received it? It measures audience health and the effect of TTL, not message quality.
Blending them into one figure (clicks / sent) makes a TTL change or a base full of inactive devices look like a copywriting problem. Two more details: count unique clicks (at most one per device per notification, even if the event arrives twice), and if a click comes from a device with no recorded delivery, count it as delivered too. The delivery event is sent by the device itself and can be lost if it drops offline at that exact moment.
Recording events in the service worker
If you run your own service worker, record delivery only once showNotification() resolves (that is what the person actually saw) and the click inside notificationclick, the event the browser fires when a notification is tapped, as described in MDN's reference.
// sw.js: the payload carries { id, sub, title, body, url }
self.addEventListener('push', (event) => {
const msg = event.data.json();
const data = { id: msg.id, sub: msg.sub, url: msg.url };
event.waitUntil(
self.registration
.showNotification(msg.title, { body: msg.body, data })
.then(() => track('delivered', data))
);
});
self.addEventListener('notificationclick', (event) => {
const data = event.notification.data;
event.notification.close();
event.waitUntil(Promise.all([track('clicked', data), data.url && clients.openWindow(data.url)]));
});
function track(type, { id, sub }) {
return fetch('/api/push-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, notificationId: id, subscriptionId: sub }),
keepalive: true,
}).catch(() => {}); // analytics must never block showing or opening
}On the server, store events under a unique key per notification and subscription so duplicates cannot inflate the count. Dismissing a notification fires a different event, notificationclose, which must not count as a click.
If you use castly, its service worker already does this: every device moves through sent, delivered and clicked in the notification's log, and clicks are counted once per device.
Computing it from the totals
With a notification's totals, the math is straightforward. This example uses GET /v1/notifications/:id, which returns sent, delivered and clicked among its totals:
const res = await fetch(`https://castly.sh/v1/notifications/${id}`, {
headers: { Authorization: `Bearer ${process.env.PUSH_SECRET_KEY}` },
});
const { totals } = await res.json();
const ctr = totals.delivered ? totals.clicked / totals.delivered : 0;
const delivery = totals.sent ? totals.delivered / totals.sent : 0;
console.log(`CTR ${(ctr * 100).toFixed(1)}% · delivery ${(delivery * 100).toFixed(1)}%`);Fix a reading window. Clicks keep coming in while the notification sits on screen and while TTL allows late deliveries, so comparing a send from an hour ago with one from last week is not fair. A rule of thumb: read every metric 24 or 48 hours after the send and store it in your own warehouse, because detailed logs are not kept forever (castly keeps them for 30 days).
From click to conversion
A click is a means, not the goal. To see what happened next, add UTM parameters to the notification URL, for example ?utm_source=push&utm_medium=web-push&utm_campaign=price-drop-nov, and your analytics tool will attribute the session to the notification. Point that URL at the exact page the notification talks about, such as the order, the thread or the product, never your home page: a click that lands on a generic page forces people to search again, and many simply leave, which makes a good notification look like a poor one.
For conversions that happen in your backend (a purchase, a booking), the notification.clicked webhook includes the notification, the subscriber and your externalUserId, so you can join the click with what that person did next in your database. The event fields are in the webhooks documentation.
And break the metric down before drawing conclusions: by message type (transactional or promotional), by browser and OS (a notification looks different on mobile and desktop), by send time and by segment. If you want to test two versions of a message, split the audience with a randomly assigned tag at subscription time and send each variant to its half, as shown in segmenting with tags.
Frequently asked questions
What is a good click-through rate for push notifications?
There is no reliable universal figure: it depends on the message type, the audience and how it is measured. An expected transactional alert ("your order has arrived") and a promotion are not comparable. Use your own history as the baseline and look for improvements against it.
Why do I sometimes see more clicks than deliveries?
Because delivery is reported by the device, and that report can be lost if the connection drops at that moment. That is why every click without a recorded delivery should also count as delivered.
Does dismissing a notification count as an interaction?
Not as a click. Dismissing it fires notificationclose, which you can record separately if you want to measure rejections, but it should never add to your click-through rate.
Do clicks on action buttons count as clicks?
If you add buttons with the actions option, the same notificationclick event fires with event.action set to the button pressed. Decide whether they count, and record them separately so you can compare.
How long should I wait before measuring a send?
Long enough for most of the activity to happen, and always the same window for every send. Reading at 24 or 48 hours is a sensible rule of thumb for most notifications.