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.

  1. Eligible sessions. The browser supports the Push API, the context is secure, and Notification.permission is still 'default'. Sessions where permission is already granted or denied are not eligible — they were never going to convert, in either direction.
  2. 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”.
  3. Soft-ask accepted. The user chose the affirmative option. Dismissals and ignores are the complement.
  4. 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.
  5. Permission granted. The promise resolved 'granted'.
  6. 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 six-stage web push permission funnel Horizontal bar funnel over one month for one origin: 100,000 eligible sessions, 38,000 soft-asks shown, 12,500 accepted, 12,300 native prompts shown, 8,200 granted and 7,900 subscriptions persisted, each labelled with its share of eligible sessions and of the stage above. One month, one origin: where 100,000 sessions go Every stage shown as a share of eligible sessions and of the stage directly above it. Eligible sessions 100,000 · 100% the denominator Soft-ask shown 38,000 · 38.0% 38.0% of eligible Soft-ask accepted 12,500 · 12.5% 32.9% of shown Native prompt shown 12,300 · 12.3% 98.4% of accepted Permission granted 8,200 · 8.2% 66.7% of prompts Subscription persisted 7,900 · 7.9% 96.3% of grants
A worked funnel. The headline opt-in rate is the last bar over the first, not any pair in between.

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.

One numerator, three denominators A single figure of 8,200 grants divided by 100,000 eligible sessions to give 8.2 percent, by 38,000 soft-asks shown to give 21.6 percent, and by 12,300 native prompts to give 66.7 percent, with a note that only the first is comparable across sites. 8,200 grants the same numerator ÷ 100,000 eligible sessions 8.2% Opt-in rate Comparable across sites. ÷ 38,000 soft-asks shown 21.6% Soft-ask to grant Hides who never saw it. ÷ 12,300 native prompts 66.7% Native accept rate The number vendors quote. All three are true. Only the first answers: what share of my audience can I reach? A grant rate quoted without its denominator is a sentence with the verb removed.
The same 8,200 grants read as 8.2 %, 21.6 % or 66.7 % depending on what you divide by.

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

Opt-in rate bands by prompt pattern Five horizontal range bands plotted on a zero to twenty-five percent axis: prompt on page load one to five percent, delay by timer three to eight percent, engagement-gated five to twelve percent, soft-ask gate eight to twenty percent, contextual soft-ask twelve to twenty-five percent. Opt-in rate bands by prompt pattern Share of eligible sessions ending in a stored subscription. Observed spread, not a forecast. Prompt on page load 1–5% Fixed timer delay 3–8% Engagement-gated prompt 5–12% Soft-ask, generic copy 8–20% Soft-ask at a real moment 12–25% 0% 5% 10% 15% 20% 25% Vertical, geography and device mix move these bands more than the prompt pattern does.
Bands overlap heavily. Treat the pattern as the floor you build on, not the number you will hit.

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 eligible happily. 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_shown becomes fiction. Log the resolution latency of requestPermission(): a promise that settles in under about 50 ms almost certainly never drew a dialog.
  • Session identifiers regenerate on reload. If you mint SESSION_ID per 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 in sessionStorage.
  • Permission can change in another tab. The eligibility check runs once at load; a grant in a second tab leaves a stale eligible row behind. Re-check before emitting native_shown and 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.

Back to Permission Prompt Timing Strategies