Permission Prompt Conversion Rate Benchmarks
Almost every opt-in rate you will read quoted in a vendor deck is a ratio whose denominator has been quietly chosen to flatter it. Before you can tell whether your own number is good, you have to decide what you are dividing by.
Quick answer
Measure opt-ins as a share of eligible sessions — sessions that could legitimately have been asked — and nothing else. Against that denominator, the bands below are what production sites actually land in. They are ranges because the spread within any one pattern is wider than the gap between patterns.
| Prompt pattern | Opt-in rate (of eligible sessions) |
|---|---|
| Native prompt fired on page load | 1–5 % |
| Native prompt delayed by a fixed timer | 3–8 % |
| Native prompt gated on an engagement score | 5–12 % |
| Soft-ask gate with generic copy | 8–20 % |
| Soft-ask tied to a contextual moment | 12–25 % |
Anything above roughly 25 % of eligible sessions almost always means the denominator is wrong — usually because already-subscribed users, bot traffic, or sessions on browsers that cannot subscribe at all have been filtered out of the bottom of the fraction but not the top.
The funnel, stage by stage
A permission funnel has six stages, and each one needs a definition precise enough that two engineers instrumenting it independently would produce the same numbers.
- Eligible sessions. The browser supports the Push API, the context is secure, and
Notification.permissionis still'default'. Sessions where permission is alreadygrantedordeniedare not eligible — they were never going to convert, in either direction. - Soft-ask shown. Your own in-page UI actually rendered and was visible. A bell icon that is below the fold for the entire session did not “show”.
- Soft-ask accepted. The user chose the affirmative option. Dismissals and ignores are the complement.
- Native prompt shown.
Notification.requestPermission()was called and the browser actually displayed the dialog. These are not the same thing: a quiet-notification setting or a prior dismissal streak can make the call resolve without any UI. - Permission granted. The promise resolved
'granted'. - Subscription persisted.
pushManager.subscribe()returned an endpoint and your server acknowledged storing it. This is the only stage that corresponds to a subscriber you can actually reach.
The 3.7 % gap between grants and persisted subscriptions in that example is not rounding. It is pushManager.subscribe() rejecting, the storage POST failing, or the tab closing between the two — and it is the stage teams most often forget to instrument, which is why reported subscriber counts and reachable subscriber counts drift apart over time.
Why a grant rate without a denominator is meaningless
Take the same 8,200 grants from the funnel above and divide them three different ways. Every result is arithmetically correct and every one describes something different.
The 66.7 % native accept rate is the seductive one, because a well-built soft-ask gate should push it high — you only fire the native dialog at people who already said yes. Optimising for it directly is how teams end up showing the soft-ask to two per cent of their audience and celebrating a 90 % accept rate that produces almost no subscribers. The soft-ask design guidance in designing a push soft-ask bell icon is worth reading alongside this, because impression coverage is the lever that actually moves the end-to-end number.
Report all three internally. Publish only the first.
What good looks like, by prompt pattern
The bands overlap on purpose. A contextual soft-ask on a low-intent content site can underperform a plain engagement-gated prompt on a logged-in product, because what the notification is for dominates how you asked. “Tell me when this back-ordered item ships” converts at a rate no amount of prompt-timing work will reproduce for “get our latest articles”.
Two structural effects distort cross-site comparison badly enough to mention before you benchmark yourself against anyone:
- Device mix. Android Chrome accepts push at materially higher rates than desktop Chrome. A site that is 80 % mobile Android is not comparable to a desktop B2B tool.
- iOS. Safari on iOS only exposes push to web apps added to the home screen, so almost all iOS traffic sits outside the eligible pool. If your eligibility filter is wrong here, iOS-heavy months will look like a collapse in opt-in rate. The mechanics are covered in iOS web push requires Add to Home Screen.
Instrumenting the funnel
Emit one event per stage per session, keyed on a session identifier, and let the warehouse do the arithmetic. Do not compute rates on the client.
const SESSION_ID = crypto.randomUUID();
const VARIANT = window.__promptVariant || 'control';
function emit(stage, extra = {}) {
navigator.sendBeacon('/collect/prompt', JSON.stringify({
sessionId: SESSION_ID,
stage,
variant: VARIANT,
occurredAt: new Date().toISOString(),
...extra
}));
}
function isEligible() {
if (!('Notification' in window) || !('serviceWorker' in navigator)) return false;
if (!('PushManager' in window) || !window.isSecureContext) return false;
// Already decided, in either direction — never part of the denominator.
return Notification.permission === 'default';
}
if (isEligible()) emit('eligible');
Reading Notification.permission costs nothing and triggers no UI, which is what makes an honest denominator cheap to collect; the surrounding technique is in detecting denied push permission without prompting.
The remaining five stages hang off the actual control flow:
// Stage 2 — fire from an IntersectionObserver, not from render().
softAskObserver = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { emit('soft_ask_shown'); softAskObserver.disconnect(); }
}, { threshold: 0.6 });
// Stages 3–6
async function onSoftAskAccepted() {
emit('soft_ask_accepted');
emit('native_shown');
const result = await Notification.requestPermission();
if (result !== 'granted') return emit('declined', { result });
emit('granted');
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY_BYTES
});
const res = await fetch('/subscriptions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(sub)
});
if (res.ok) emit('persisted');
}
Two details make or break the data. Stage 2 must fire on visibility, not on mount, or a bell icon nobody scrolled to inflates your impressions. And emit('native_shown') immediately before requestPermission() is an approximation — the browser may resolve the promise without drawing anything under a quiet-notification setting, so treat a native accept rate above about 90 % as evidence the dialog is being suppressed rather than as a triumph.
The SQL
One table, one query. Stages are recorded as rows so that adding a stage later does not require a migration.
CREATE TABLE push_prompt_events (
event_id bigserial PRIMARY KEY,
session_id uuid NOT NULL,
stage text NOT NULL,
variant text NOT NULL DEFAULT 'control',
ua_family text,
is_bot boolean NOT NULL DEFAULT false,
occurred_at timestamptz NOT NULL
);
CREATE INDEX ON push_prompt_events (occurred_at, stage);
CREATE UNIQUE INDEX ON push_prompt_events (session_id, stage);
The unique index is the important one: it makes each stage idempotent per session, so a double-fired beacon cannot invent conversions.
WITH s AS (
SELECT session_id,
variant,
max((stage = 'eligible')::int) AS eligible,
max((stage = 'soft_ask_shown')::int) AS soft_shown,
max((stage = 'soft_ask_accepted')::int) AS soft_accepted,
max((stage = 'native_shown')::int) AS native_shown,
max((stage = 'granted')::int) AS granted,
max((stage = 'persisted')::int) AS persisted
FROM push_prompt_events
WHERE occurred_at >= now() - interval '30 days'
AND is_bot = false
GROUP BY session_id, variant
)
SELECT variant,
sum(eligible) AS eligible_sessions,
sum(soft_shown) AS soft_ask_shown,
sum(soft_accepted) AS soft_ask_accepted,
sum(native_shown) AS native_prompt_shown,
sum(granted) AS granted,
sum(persisted) AS persisted,
round(100.0 * sum(soft_shown) / nullif(sum(eligible), 0), 2) AS coverage_pct,
round(100.0 * sum(granted) / nullif(sum(native_shown), 0), 2) AS native_accept_pct,
round(100.0 * sum(persisted) / nullif(sum(eligible), 0), 2) AS opt_in_rate_pct
FROM s
GROUP BY variant
ORDER BY variant;
coverage_pct and opt_in_rate_pct are the two numbers worth putting on a dashboard. Coverage tells you whether the gate is too tight; opt-in rate tells you whether the whole thing works. native_accept_pct is a diagnostic, not a goal.
Reading your own numbers honestly
- Sessions, not users, and never page views. A user who visits eleven times and never subscribes contributes eleven eligible sessions and zero grants. That is correct: eleven opportunities were spent. Switching to distinct users to make the ratio look better is the single most common quiet manipulation.
- Exclude bots before the denominator, not after. Headless traffic executes your JavaScript and emits
eligiblehappily. Filter on user-agent and on the absence of any pointer or scroll event. - Watch the persisted-to-granted gap. Anything worse than about 95 % means
subscribe()is failing for a segment of your traffic, and the resulting phantom subscribers are indistinguishable from the ones described in detecting stale push subscriptions in the browser. - Do not read week-on-week movement as signal. At an 8 % base rate you need on the order of tens of thousands of eligible sessions per arm to detect a one-point difference; the sample-size arithmetic is in statistical significance for push notification tests.
- Pair the opt-in rate with a retention number. An aggressive prompt that lifts opt-ins by two points and doubles 30-day unsubscribes has made things worse. Read this against push unsubscribe rate benchmarks and churn before declaring a win.
Gotchas and edge cases
- A dismissal is not a denial, but Chrome will treat repeats as one. Three ignored dialogs on an origin and Chrome switches to quiet UI, at which point
native_shownbecomes fiction. Log the resolution latency ofrequestPermission(): a promise that settles in under about 50 ms almost certainly never drew a dialog. - Session identifiers regenerate on reload. If you mint
SESSION_IDper page load rather than per session, a user who converts on their fourth page view registers as four eligible sessions and one grant instead of one and one. Store it insessionStorage. - Permission can change in another tab. The eligibility check runs once at load; a grant in a second tab leaves a stale
eligiblerow behind. Re-check before emittingnative_shownand drop the session from the funnel if it has moved on. - Prompt-timing thresholds and the funnel move together. Raising the engagement gate lifts the accept rate and lowers coverage, usually leaving the opt-in rate flat. Tune the two jointly using the thresholds in ideal page views before showing push prompt and the deferral patterns in best practices for delaying push permission requests.
- A quoted benchmark from a vendor whose product is the soft-ask is measuring their soft-ask. Ask which stage the denominator comes from before you use it as a target.
FAQ
What counts as an eligible session?
A session where the browser exposes Notification, PushManager and navigator.serviceWorker, the page is a secure context, and Notification.permission is still ‘default’. Sessions from already-subscribed users, from users who previously blocked, and from browsers or platforms that cannot subscribe at all are excluded. Everything else — including users who never scrolled far enough to see your soft-ask — stays in, because a session you failed to reach is still an opportunity you spent.
My native accept rate is 92%. Is that good?
It is more likely to be a measurement artefact than a success. Rates that high usually mean either that your soft-ask gate is so tight that only near-certain converters ever reach the dialog — in which case coverage will be very low and the end-to-end opt-in rate mediocre — or that the browser is resolving requestPermission() under quiet UI without ever drawing a dialog, so your native_shown count is undercounted. Check coverage and the promise resolution latency before celebrating.
How long should I collect data before comparing two prompt patterns?
Long enough to cover a full weekly cycle at minimum, because weekday and weekend traffic have different intent profiles, and long enough to reach the sample size your base rate demands. At an 8% opt-in rate, detecting a one-percentage-point absolute difference with conventional power needs roughly 12,000–15,000 eligible sessions per arm. Below that you are reading noise, no matter how clean the funnel query is.
Related
- Permission Prompt Timing Strategies — the timing architecture these numbers measure
- Ideal Page Views Before Showing Push Prompt — the engagement thresholds that set your coverage
- Best Practices for Delaying Push Permission Requests — deferral logic that shifts the funnel’s middle stages
- Statistical Significance for Push Notification Tests — sample sizes for comparing two prompt variants
- Designing a Push Soft-Ask Bell Icon — the surface whose impression coverage drives stage 2