Silent Permission Checks & Pre-qualification

Silent permission checks operate as a deterministic pre-flight validation layer that gates native browser dialogs until user intent is mathematically quantified. This architecture prevents premature Notification.requestPermission() invocations that historically degrade subscription conversion rates and trigger browser-level fatigue. By evaluating engagement telemetry, historical interaction states, and session context, teams can reserve native prompts exclusively for high-intent windows.

Prerequisites

The diagram below shows the gate: silent reads and a behavioral score decide whether the native dialog is ever reached. Nothing here touches browser UI.

Silent pre-qualification gate Silent permission read and a behavioral score feed a qualification gate; only a qualified, default-state user is handed to the native dialog, while denied or unqualified users route to fallback without any UI being rendered. Silent read Notification.permission Behavioral score scroll · dwell · clicks Qualification gate Hand off to the native dialog Fallback or defer still no UI rendered default + score ≥ threshold denied, granted, or below gate Both inputs are pure reads: neither branch of this diagram can open a browser dialog on its own. The native prompt is a conversion step at the very end, not a discovery mechanism at the start.
Pre-qualification is a read-and-score gate: it decides eligibility without ever rendering permission UI or opening the native dialog.

1. Architectural Role of Silent Pre-qualification

The pre-qualification layer functions as a state machine that evaluates readiness before any DOM-level permission UI is rendered. It decouples behavioral analysis from the native Web Push API, ensuring that subscription requests align with established Frontend Permission UX & Subscription Flows frameworks. This separation guarantees that native dialogs are treated as a final conversion step rather than an initial discovery mechanism.

Implementation Blueprint

// preflight-gate.js
/**
 * Evaluates session readiness before triggering native permission prompts.
 * @param {Object} context - Current session telemetry & state
 * @returns {boolean} - True if qualified for prompt invocation
 */
export function evaluatePreflight(context) {
  const {
    isReturningUser,
    hasInteractedWithCoreFeature,
    sessionDurationSec,
    scrollDepthPct
  } = context;

  // Only prompt new users who have demonstrated engagement
  const isQualifiedForPrompt =
    hasInteractedWithCoreFeature &&
    sessionDurationSec >= 45 &&
    scrollDepthPct >= 0.65 &&
    !isReturningUser; // Use separate re-engagement flow for returning users

  return isQualifiedForPrompt;
}

Architecture Trade-offs & Debugging

  • Trade-off: Synchronous evaluation blocks the UI thread minimally but requires strict payload size limits. Asynchronous evaluation improves responsiveness but introduces race conditions during rapid route changes.
  • Debugging: Use Chrome DevTools Performance panel to verify evaluation latency stays < 5 ms. Log context payloads to a staging endpoint to validate threshold boundaries before production deployment.
  • Compliance Alignment: Pre-qualification gates UI presentation only. It must never bypass explicit consent requirements under GDPR/CCPA or collect PII during the scoring phase.

2. Behavioral Signal Capture & Threshold Logic

Effective pre-qualification relies on real-time telemetry rather than arbitrary timeouts. Monitoring scroll depth, feature adoption, and session duration allows dynamic adjustment of prompt eligibility. Aligning these triggers with proven Permission Prompt Timing Strategies maximizes opt-in probability while minimizing bounce risk.

Implementation Blueprint

// telemetry-scoring.js
export class ReadinessScorer {
  constructor(threshold = 70) {
    this.score = 0;
    this.threshold = threshold;
    this.isQualified = false;
  }

  init() {
    // Passive listeners prevent main-thread blocking
    window.addEventListener('scroll', this._debounce(this._trackScroll.bind(this), 100), { passive: true });
    window.addEventListener('click', this._trackInteraction.bind(this), { passive: true });
    document.addEventListener('visibilitychange', this._trackVisibility.bind(this), { passive: true });
  }

  _trackScroll() {
    const depth = (window.scrollY + window.innerHeight) / document.body.scrollHeight;
    if (depth > 0.5) this._increment(10);
  }

  _trackInteraction(e) {
    if (e.target.closest('[data-trackable]')) this._increment(15);
  }

  _trackVisibility() {
    if (document.visibilityState === 'visible') this._increment(5);
  }

  _increment(value) {
    this.score = Math.min(this.score + value, 100);
    if (this.score >= this.threshold && !this.isQualified) {
      this.isQualified = true;
      window.dispatchEvent(new CustomEvent('preflight_qualified', { detail: { score: this.score } }));
    }
  }

  _debounce(fn, delay) {
    let timer;
    return (...args) => {
      clearTimeout(timer);
      timer = setTimeout(() => fn(...args), delay);
    };
  }

  destroy() {
    // Remove event listeners by storing references; simplified here
    window.removeEventListener('scroll', this._trackScroll);
    window.removeEventListener('click', this._trackInteraction);
    document.removeEventListener('visibilitychange', this._trackVisibility);
  }
}

Because every increment is bounded and monotonic, the score traces a staircase across the session. Plotting a real session makes the gate’s behaviour obvious: nothing happens for the first two minutes, and the preflight_qualified event fires exactly once.

Readiness score accumulation across one session A staircase line rises from zero as visibility, scroll and click signals each add their weight, crossing the threshold of seventy at around one hundred and thirty seconds into the session, at which point the preflight qualified event fires once and the score plateaus at eighty. One session, one qualification event ReadinessScorer increments are bounded and monotonic, so the score only ever steps up. 100 75 50 25 0 threshold = 70 click +15 preflight_qualified fires once 0 s 30 60 90 120 150 180 s scroll past 50 % +10 tracked click +15 tab becomes visible +5 Nothing is rendered while the score climbs — the entire staircase is invisible to the user. A visitor who never clicks a tracked element plateaus below the line and is never prompted at all.
A readiness score stepping up through one session. The dashed line is the threshold; the green marker is the single preflight_qualified dispatch.

Architecture Trade-offs & Debugging

  • Trade-off: High-frequency event tracking increases memory footprint. Debouncing and passive listeners mitigate this but may delay qualification by ~100–200 ms.
  • Debugging: Monitor window.performance.memory in Chromium to detect listener leaks. Verify that telemetry payloads are sanitized before backend sync to prevent injection vectors.

3. Client-Side State Persistence & Storage Architecture

Maintaining qualification state across page navigations requires a deterministic storage strategy. localStorage provides synchronous read/write performance ideal for UI gating. For detailed implementation patterns, refer to Using localStorage to track soft prompt interactions. The architecture must handle race conditions during rapid navigation and gracefully degrade when storage quotas are exceeded.

Implementation Blueprint

// storage-manager.js
const STORAGE_KEY = 'push_preflight:v1';
const TTL_MS = 86400000; // 24 hours

export function saveQualificationState(state) {
  const payload = {
    ...state,
    expiresAt: Date.now() + TTL_MS,
    ts: Date.now()
  };

  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
  } catch (err) {
    if (err.name === 'QuotaExceededError') {
      console.warn('Storage quota exceeded. Falling back to sessionStorage.');
      try {
        sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
      } catch (fallbackErr) {
        console.error('All storage mechanisms exhausted. State held in-memory only.');
      }
    }
  }
}

export function loadQualificationState() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY) || sessionStorage.getItem(STORAGE_KEY);
    if (!raw) return null;

    const data = JSON.parse(raw);
    if (Date.now() > data.expiresAt) {
      localStorage.removeItem(STORAGE_KEY);
      sessionStorage.removeItem(STORAGE_KEY);
      return null;
    }
    return data;
  } catch {
    return null;
  }
}

Each tier of that fallback chain buys you a different amount of memory, and it is worth knowing exactly what behaviour you lose at each step down.

Qualification state storage fallback chain Saving qualification state attempts localStorage first, which preserves cross-session signals under a twenty-four hour TTL. A QuotaExceededError drops to sessionStorage, which keeps only this tab. A second failure drops to in-memory state, which is lost on the next navigation and forces a re-score on every page. saveQualificationState() one call, three tiers localStorage cross-session, 24 h TTL Full behaviour return-visit signals survive sessionStorage this tab, this session Degraded session signals only in-memory only lost on navigation Minimal re-scores on every page QuotaExceededError storage throws again Every read compares expiresAt before trusting the flag, so a 24-hour TTL sends a stale qualification back through a fresh evaluation. Bump the STORAGE_KEY suffix on any schema change so old payloads are ignored, not mis-parsed.
The storage fallback chain and what each tier costs you: cross-session memory, then per-tab memory, then nothing.

Architecture Trade-offs & Debugging

  • Trade-off: localStorage is synchronous and can block the main thread during large reads/writes. The fallback chain (localStoragesessionStorage → memory) ensures resilience but complicates state synchronization across tabs.
  • Debugging: Use navigator.storage.estimate() to monitor quota consumption. Validate that storage keys contain zero PII and that TTL logic correctly purges stale flags.
  • Compliance Alignment: Provide clear privacy disclosures if storing behavioral scores. Ensure all keys are anonymized and respect regional data minimization mandates.

4. Secure Execution & Graceful Degradation Pathways

Once pre-qualification passes, the system must securely invoke the native permission API. If the user denies or the browser blocks the request, the architecture should immediately route to contextual alternatives. This ensures continuity and aligns with UI Fallbacks & Soft Prompts best practices.

Implementation Blueprint

// permission-executor.js
export async function executePermissionFlow(onGranted, onDenied, onFallback) {
  // 1. Synchronous state check
  const currentStatus = Notification.permission;

  if (currentStatus === 'granted') {
    onGranted();
    return;
  }
  if (currentStatus === 'denied') {
    onFallback();
    return;
  }

  try {
    // 2. Async invocation — must be within a user gesture handler
    const result = await Notification.requestPermission();

    if (result === 'granted') {
      onGranted();
    } else {
      onDenied();
      onFallback();
    }
  } catch (error) {
    console.error('Permission API invocation failed:', error);
    onFallback();
  }
}

// Usage Example
executePermissionFlow(
  () => registerServiceWorker('/sw-push.js'),
  () => logTelemetry('permission_denied'),
  () => renderSoftPromptUI()
);

Architecture Trade-offs & Debugging

  • Trade-off: Native prompts are single-use per origin per session (in many browser configurations). Caching results across sessions without re-querying can lead to stale UI states.
  • Debugging: Test across Safari (requires explicit user gesture), Firefox (blocks prompts in cross-origin iframes), and Chromium (respects Permissions-Policy). Verify service worker registration scope matches the origin.
  • Security Practice: Always query Notification.permission synchronously before UI rendering. Do not cache the result across page loads.

The most important silent check is detecting an existing block before you waste any UI on a user you can never re-prompt — the techniques for reliably reading a blocked state without firing a dialog are covered in detecting denied push permission without prompting. Denied users belong in a re-permission recovery flow, not in the prompt funnel.

5. Validation, Telemetry & Iterative Optimization

Implementation success depends on continuous measurement. Track metrics such as pre-qualification pass rate, native prompt trigger latency, and opt-in conversion delta. Use browser DevTools to audit storage writes and event listener overhead.

Implementation Blueprint

// telemetry-dispatcher.js
export function trackEvent(eventType, metadata = {}) {
  // Respect Do Not Track headers
  if (navigator.doNotTrack === '1' || window.doNotTrack === '1') return;

  const payload = {
    event: eventType,
    ts:    Date.now(),
    // Do not include navigator.userAgent in production without user consent
    ...metadata
  };

  fetch('/api/telemetry/push', {
    method:   'POST',
    headers:  { 'Content-Type': 'application/json' },
    body:     JSON.stringify(payload),
    keepalive: true
  }).catch(() => {}); // Fail silently to preserve UX
}

export async function auditPermissionSupport() {
  if (!('Notification' in window)) return false;
  if ('permissions' in navigator) {
    const status = await navigator.permissions.query({ name: 'notifications' });
    // 'prompt' means the native dialog is still available; 'denied' means blocked
    return status.state !== 'denied';
  }
  return true;
}

Architecture Trade-offs & Debugging

  • Trade-off: High-frequency telemetry increases network overhead. Batching events via navigator.sendBeacon() or keepalive: true fetch requests ensures delivery without blocking navigation.
  • Debugging: Instrument custom events (preflight_pass, preflight_fail, prompt_invoked) in your analytics pipeline. Run A/B tests on threshold configurations to isolate conversion deltas.
  • Compliance Alignment: Ensure telemetry collection strictly complies with Do Not Track (DNT) headers and regional data minimization mandates. Strip IP addresses and device fingerprints at the edge before ingestion. The qualified intent signals captured here also seed subscriber segmentation & targeting, so keep score categories consistent with your segment taxonomy.

6. Configuration Reference

Parameter Type Default Notes
threshold integer 70 Score required to emit preflight_qualified
sessionDurationSec integer 45 Minimum dwell before a new user qualifies
scrollDepthPct float (0–1) 0.65 Minimum scroll penetration in the preflight check
TTL_MS integer 86400000 How long a qualification flag survives in storage (24 h)
STORAGE_KEY string push_preflight:v1 Versioned key; bump the suffix on schema changes
debounceMs integer 100 Scroll-handler debounce to cap main-thread cost

7. Error & Edge-Case Matrix

Condition Cause Fix
QuotaExceededError on save localStorage full or blocked Fall back to sessionStorage, then in-memory state
Listener leak / rising memory Bound method references differ on removeEventListener Store the bound function once and pass the same reference to add/remove
User qualifies but is already blocked Score evaluated without a permission read Read Notification.permission first; short-circuit on denied
Stale qualification after long absence TTL not enforced on load Compare expiresAt on read and purge expired flags
No qualification in private mode Storage APIs throw or are partitioned Degrade to memory-only state for the session

8. Verifying That the Layer Is Genuinely Silent

The correctness claim of this whole subsystem is negative — no browser UI appears — and negative claims need explicit tests, because the failure mode is invisible in code review and obvious to users.

Prove no dialog can be reached

Instrument the only API that can open one, then exercise the entire scoring path and assert the counter stays at zero:

// Load this before your app bundle in a test build.
window.__promptCalls = [];
const realRequest = Notification.requestPermission.bind(Notification);
Notification.requestPermission = function (...args) {
  window.__promptCalls.push(new Error().stack);
  return realRequest(...args);
};

// After driving scroll, clicks and a full qualification cycle:
console.assert(window.__promptCalls.length === 0, 'pre-qualification opened a dialog');
console.log('score:', scorer.score, 'qualified:', scorer.isQualified);

Expected console output after a qualifying session is a score at or above the threshold with an empty call list:

// score: 80 qualified: true
// __promptCalls.length === 0

If the array is non-empty, read the captured stack: the usual culprit is a subscribe() call somewhere in an analytics or personalisation module, because pushManager.subscribe() will itself trigger the permission dialog when the state is default. That is the trap in this design — the silent layer can be perfect and a single unrelated subscribe() still spends the prompt.

Watch the state change without polling

navigator.permissions.query() returns a live PermissionStatus. Attaching a listener is still a pure read, and it is the only way to observe a change made in browser settings while your page is open:

const status = await navigator.permissions.query({ name: 'notifications' });
console.log('initial state:', status.state);        // 'prompt' | 'granted' | 'denied'
status.addEventListener('change', () => {
  console.log('permission changed to:', status.state);
  reconcileQualificationState(status.state);
});

In Chrome DevTools, open Application → Notifications to read the current state, then change it from the site-settings icon beside the address bar; the change handler should log once per transition. Note the vocabulary mismatch you must normalise: the Permissions API says prompt where Notification.permission says default. Code that compares the two strings directly is a common source of a scorer that qualifies nobody.

Resetting between runs

Because qualification state is yours rather than the browser’s, resetting it is a one-liner and should be exposed as a debug helper rather than done by hand:

// Clears the qualification flag without touching browser permission state.
localStorage.removeItem('push_preflight:v1');
sessionStorage.removeItem('push_preflight:v1');

Keep permission resets and qualification resets separate in your test plan. Wiping storage to clear a score also wipes the permission on Chromium and Gecko, so a test that intended to check “qualified user, already granted” silently becomes “unqualified user, default” and passes for the wrong reason. Reset the score from storage, and reset the permission from browser settings.

Assert the negative cases

Three tests catch nearly every regression: a session with zero tracked clicks must end below threshold and emit no preflight_qualified event; a session on an origin already in denied must short-circuit before any listener is attached, which you can verify by checking that getEventListeners(window).scroll is undefined in Chromium; and a second qualifying session must not re-dispatch preflight_qualified while the stored flag is inside its TTL.

9. Cross-Browser Divergence in Silent Reads

Reading permission state looks like the most portable thing you could possibly do. It is not — three of the four surfaces behave differently enough to change your capability-detection order.

Silent read support by engine Four platform rows compare whether Notification.permission is readable, whether the Permissions API accepts the notifications descriptor, whether pushManager.permissionState works, and how durable persisted qualification storage is, for Chrome, Firefox, Safari on macOS and iOS standalone. Which silent reads you can rely on, and how long your score survives platform Notification .permission permissions .query() permissionState() in the worker score durability Chrome / Edge desktop + Android yes yes, live status yes weeks Firefox Gecko yes yes, guard the call yes weeks Safari macOS WebKit yes descriptor may throw yes 7 days (ITP) iOS standalone Home Screen only yes, once installed guard the call yes separate store
Only Notification.permission is universally safe to read; every other row needs a guard, and Safari's storage lifetime caps how long a score is worth keeping.

Feature-detect the descriptor, not the API. navigator.permissions existing tells you nothing about whether { name: 'notifications' } is an accepted descriptor. A rejected or unsupported descriptor throws a TypeError rather than resolving, so the read must be wrapped:

export async function readPermissionSilently() {
  if (!('Notification' in window)) return 'unsupported';
  const base = Notification.permission;            // always safe, never prompts
  if (!('permissions' in navigator)) return base;
  try {
    const status = await navigator.permissions.query({ name: 'notifications' });
    return status.state === 'prompt' ? 'default' : status.state;
  } catch {
    return base;                                   // descriptor unsupported — fall back
  }
}

Normalising prompt to default in one place, as above, is what keeps the vocabulary mismatch from leaking into every call site.

Read from the worker with permissionState(). Notification is not available in every ServiceWorkerGlobalScope, so a worker that needs to know whether it may display anything should ask the push manager instead: await registration.pushManager.permissionState({ userVisibleOnly: true }) returns prompt, granted or denied and never opens UI. This matters when a push event arrives on a subscription whose permission has since been revoked — the correct behaviour there is covered by the display rules in cross-browser notification quirks.

Safari caps how long a score is worth storing. WebKit’s storage policy evicts script-writable storage for origins the user has not interacted with in roughly seven days, so a 24-hour TTL is comfortably inside the window but a 30-day “returning visitor” bonus is not — on Safari it will silently never fire. Do not compensate by lengthening the TTL; compensate by treating the absence of stored state as “unknown” rather than “new user”, and by keeping the authoritative engagement history server-side against the account.

iOS standalone has its own storage. A site opened from the Home Screen does not share localStorage with the same origin in a Safari tab. A user who built up a qualifying score while browsing arrives at the installed experience with an empty store, which is exactly the moment you most want their history. Re-hydrate from the server on first launch rather than re-scoring from zero, and remember that on iOS the permission dialog is only reachable from the installed context in the first place.

Signal fairness and the zero-footprint rule

Two accessibility obligations fall specifically on the silent layer rather than on the UI it gates.

The first is that it must have no observable footprint. No focus change, no live-region text, no injected node, no animation — crossing the threshold is an event in your code, not in the user’s task. If a screen reader announces anything at the moment a score crosses 70, the layer has stopped being silent and has become an unrequested interruption. Concretely: dispatch preflight_qualified on window, never on a DOM node inside a live region, and never render a hidden container “ready” for the prompt.

The second is that a scorer built from pointer and scroll events measures mouse users and under-measures everyone else. A keyboard user tabbing through a page may fire no scroll event at all; a screen-reader user navigating by headings with a virtual cursor produces neither. Because the users least able to recover from a missed opportunity are the ones who would have to hunt through browser settings later, this is a correctness bug rather than a polish item. Capture the equivalent signals:

// Inclusive signal capture: intent is not only expressed with a mouse.
_bindInclusiveSignals() {
  document.addEventListener('focusin', (e) => {
    if (e.target.closest('[data-trackable]')) this._increment(15);
  }, { passive: true });

  document.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      if (e.target.closest('[data-trackable]')) this._increment(15);
    }
  }, { passive: true });

  // Section visibility works for any traversal method, pointer or not.
  new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) this._increment(10);
    }
  }, { threshold: 0.6 }).observe(document.querySelector('#article-end'));
}

IntersectionObserver on a sentinel element near the end of the content is strictly better than a scrollY calculation for this purpose: it fires for keyboard paging, for find-in-page jumps, for anchor navigation and for assistive-technology cursors, none of which reliably produce scroll events. Weight focusin on a tracked control the same as a click, since for a keyboard user they are the same act of attention.

10. Operating the Qualification Layer

What to instrument

The layer produces one decision per session, so instrument the inputs as well as the outcome or you will have no way to explain a drift. Emit preflight_evaluated with the final score, the permission state read, and a compact list of which signals contributed; emit preflight_qualified once; emit preflight_storage_degraded with the tier reached (local, session, memory). Report the score as a bucketed histogram rather than a raw number so you can see the shape of the distribution — a healthy scorer produces a bimodal shape with a clear trough, and a scorer whose distribution has collapsed into a single spike just below the threshold is mis-weighted rather than unlucky.

Track qualification rate against sessions, and the median time-to-qualify. Both are leading indicators: a site redesign that moves tracked elements out of the default viewport shows up as a time-to-qualify regression days before it shows up as a subscription shortfall.

What to alert on

  • Qualification rate stepping down. Usually a data-trackable attribute lost in a template refactor. Alert on a week-over-week ratio, not an absolute floor.
  • preflight_storage_degraded at tier memory above a low baseline. Indicates real quota pressure or a partitioned-storage environment; the layer still works but re-scores on every page, which inflates qualification rate misleadingly.
  • Permission reads returning denied at a rising share. Your prompt is being spent badly upstream, and the silent layer is the first place it becomes visible.
  • Any preflight_qualified for a session whose permission read was denied. Structurally impossible if the short-circuit is intact; one occurrence means the read has moved after the scoring.
  • Score distribution shifting toward the threshold. Means the threshold is now the median rather than a filter, which defeats the purpose.

Tuning the threshold as an experiment

Threshold changes are the cheapest experiment in the permission stack because they touch no browser resource: nothing about evaluating a score is one-shot, so you may run several arms concurrently and reassign freely between releases. Assign the threshold server-side in your config payload, log it on every preflight_evaluated row, and compare arms on subscriptions retained at 30 days per thousand sessions rather than on qualification rate — a lower threshold trivially raises qualification and just as reliably lowers acceptance downstream. Where a threshold change alters who reaches the dialog, the downstream acceptance effect must be measured by the timing layer, not here; keep the two experiments sequential so their effects remain attributable.

Multiple devices and cleared storage

A score is a property of a browser profile, not of a person. The same user qualifies independently on each device, and there is no client-side mechanism to share the state — localStorage is origin- and profile-scoped by design. Treat the per-device score as a fast local cache and the account-level engagement history on your server as the durable record, then seed a new device’s score from the server on first authenticated page view. This is what turns a returning customer’s second device from a cold start into an immediate qualification, and it is the same record that lets you suppress a prompt on a device belonging to a user who already granted elsewhere.

Cleared site data removes the score entirely, and that is the correct behaviour: the user has asked to be forgotten locally. Two consequences are worth designing for. First, because the flag and its TTL vanish together, a user who clears storage weekly will re-score from zero every time — which is fine, provided your signals are earnable within a single session. Second, do not treat a missing flag as evidence of a first visit in any user-facing copy; “welcome back” logic keyed to localStorage is wrong for private windows, for cleared storage and for every second device. Key that copy to the account, and keep the local store strictly for gating decisions the server does not need to make.

On most commercial sites the qualification layer is not the only thing deciding whether it may run. A consent management platform gates analytics and personalisation categories, a Permissions-Policy header can disable the Notifications API for the document outright, and a Content Security Policy governs whether your script can load at all. Getting the ordering wrong produces a layer that scores every visitor and then discovers it was never allowed to act.

Resolve the environment before you score, not after. Behavioural scoring is personalisation in the sense most consent frameworks mean, so if the visitor has refused that category, the correct behaviour is to skip scoring entirely and fall back to an explicit, user-initiated entry point such as the bell described in UI fallbacks and soft prompts. Subscribe to your platform’s consent-changed callback rather than reading a cookie once at load, because a visitor who accepts mid-session should become eligible without a reload, and one who withdraws should stop being scored immediately.

Permissions-Policy is the cheapest check and the one teams forget. A document served with Permissions-Policy: notifications=() — or an embedded document that was not granted the feature by its parent frame — cannot open the dialog no matter what the score says. Feature-detect it once and cache the result for the document’s lifetime:

function notificationsAllowedHere() {
  if (!('Notification' in window)) return false;
  // A cross-origin iframe, or a document whose Permissions-Policy withholds the feature.
  if (document.featurePolicy?.allowsFeature && !document.featurePolicy.allowsFeature('notifications')) {
    return false;
  }
  return window.isSecureContext === true;
}

Three environments deserve explicit handling because they look eligible and are not. A cross-origin iframe inherits neither the feature nor, in a partitioned-storage browser, the top-level origin’s storage — so a score accumulated inside a widget is invisible to the parent page and vice versa. An in-app WebView on Android or iOS frequently exposes Notification while refusing to surface any dialog, which produces a permanent default state and a visitor who qualifies repeatedly and never converts; watch for a qualification rate near 100% paired with an acceptance rate near zero on a single user-agent family. And a document under a strict CSP with no 'unsafe-inline' will silently drop an inline bootstrap snippet, so ship the layer as an external module and let the failure be a load error you can see rather than a scorer that never starts.

Log the resolved environment alongside every preflight_evaluated row — consent category state, feature availability, secure-context flag, and whether the document is framed. That single addition turns “our opt-in rate fell” into “our opt-in rate fell on the embedded checkout, which lost the notifications feature in a header change”, which is a fix rather than an investigation.

Back to Frontend Permission UX & Subscription Flows

FAQ

Does reading Notification.permission ever show a dialog?

No. Notification.permission and navigator.permissions.query({ name: 'notifications' }) are both pure reads — they return the current state synchronously (or via a promise) and never render browser UI. Only Notification.requestPermission() can open the dialog, and only from a user gesture.

Can I detect a blocked user silently?

Yes. Notification.permission === 'denied' (or a denied result from the Permissions API) tells you the origin is blocked without prompting. Use this to suppress all permission UI for that user and route them to a recovery flow instead of burning the one-shot dialog.

Where should qualification state live?

Use localStorage for cross-session persistence with a TTL, falling back to sessionStorage and then memory under quota pressure. Keep the payload free of PII — store only anonymized scores and timestamps, versioned by key so you can migrate cleanly.

What score threshold should trigger the prompt?

Start around 70 on a 0–100 scale weighted toward feature interaction over passive scrolling, then tune against your own opt-in conversion. The absolute number matters less than the consistency of the signals feeding it; recalibrate whenever your engagement model changes.