Handling Permission Denials Gracefully

This guide is part of Permission Flows & Progressive Enhancement.

When integrating native sharing, permission denials and system-level restrictions are expected production scenarios — not edge cases. Without an explicit denial strategy, a single NotAllowedError surfaces a broken share button with no recovery path. This guide covers: classifying DOMException types correctly, validating secure context before every invocation, routing blocked payloads through a progressive fallback chain, and managing re-engagement without triggering browser-level anti-harassment throttling.

What Breaks Without This Pattern

A naive implementation catches no errors and gives the user no alternative when the browser blocks navigator.share(). The share button silently fails, the payload is lost, and the user has no way to complete the action. On iOS, a NotAllowedError fires immediately if the call did not originate from a direct user gesture — a common cause of phantom failures in frameworks that debounce click events. On Android, persistent denials leave no system UI cue, so users have no indication that they need to visit browser settings.

The correct approach classifies every thrown exception, preserves the payload, and routes it to an appropriate alternative before the user notices the failure.

Prerequisites

Before writing any share code, confirm all of the following:

Browser Support Snapshot

Browser Minimum version Desktop Mobile Known gap
Chrome 61 Yes Yes Must be user-gesture; no gesture = NotAllowedError
Safari 12.1 macOS 13+ only iOS 12.2+ Strict gesture token; async handlers invalidated
Firefox 71 No Android only Desktop share UI never shipped
Edge (Chromium) 79 Yes Yes Follows Chromium rules
Samsung Internet 8.2 Yes Behaves like Chrome

Firefox desktop returns undefined for navigator.share, so feature detection catches it before invocation. For a full cross-version breakdown including feature flags and origin-trial history, see the browser support matrix for the Web Share API.

Decision Flow: From Share Attempt to Recovery

The diagram below shows the complete path from a user tapping the share button through error classification to fallback routing.

Permission denial handling flow A flowchart showing how a share trigger leads through feature detection, secure context check, navigator.share invocation, DOMException classification, and then routes to success, cancellation acknowledgement, fallback modal, or exponential backoff before retry. User taps Share isSecureContext && navigator.share defined? Show fallback modal canShare(payload) === true? (payload validation) navigator.share(payload) Success — done DOMException thrown error.name = ? AbortError User cancelled — no-op NotAllowedError / TypeError Fallback modal Increment denial counter + backoff No Yes No Yes OK throws

Step-by-Step Implementation

Step 1 — Validate Secure Context and Feature Support

Gate all share UI behind a runtime capability check. Call this function once on page load to conditionally render the share trigger. A missing navigator.share or an insecure origin means the native path is permanently unavailable in this browsing session.

/**
 * Returns true only when the Web Share API is available in a secure context.
 * Call before rendering any share trigger UI.
 * @returns {boolean}
 */
export function canUseNativeShare() {
  if (typeof window === 'undefined' || typeof navigator === 'undefined') return false;
  return window.isSecureContext === true && typeof navigator.share === 'function';
}

const shareButton = document.getElementById('share-trigger');
if (shareButton) {
  shareButton.hidden = !canUseNativeShare();
}

If canUseNativeShare() returns false, show the fallback UI immediately — no need to attempt the native path at all. The understanding secure context requirements guide covers why isSecureContext must be checked separately from navigator.share on mixed-content pages.

Step 2 — Validate the Payload Before Invocation

navigator.canShare() synchronously validates a ShareData payload against the browser’s allowed field set. An invalid combination — for example, files on a browser without file-sharing support — throws TypeError from navigator.share() before any system dialog appears. Check first to avoid masking the error as a user denial.

/**
 * Validates a ShareData payload against the browser's supported field set.
 * Must be called before navigator.share() to distinguish TypeError from NotAllowedError.
 * @param {ShareData} payload
 * @returns {{ valid: boolean; reason?: string }}
 */
export function validateSharePayload(payload) {
  if (!canUseNativeShare()) {
    return { valid: false, reason: 'api-unavailable' };
  }
  if (typeof navigator.canShare !== 'function') {
    // canShare not supported — assume valid and let share() surface any error
    return { valid: true };
  }
  const ok = navigator.canShare(payload);
  return ok ? { valid: true } : { valid: false, reason: 'payload-unsupported' };
}

For a deep dive into navigator.canShare() patterns, see implementing navigator.canShare() for graceful fallbacks.

Step 3 — Invoke navigator.share() and Classify the Exception

The Web Share API throws two meaningfully different DOMException subtypes that require different responses. AbortError means the user dismissed the system sheet — no fallback needed. NotAllowedError means a policy or permission blocked the call — route to an alternative immediately.

/**
 * Invokes native share and classifies the outcome.
 * Must be called from a synchronous user-gesture handler.
 * @param {ShareData} payload
 * @returns {Promise<'success' | 'cancelled' | 'denied' | 'payload-error' | 'unexpected'>}
 */
export async function invokeNativeShare(payload) {
  const validation = validateSharePayload(payload);
  if (!validation.valid) {
    return validation.reason === 'api-unavailable' ? 'denied' : 'payload-error';
  }

  try {
    await navigator.share(payload);
    return 'success';
  } catch (error) {
    if (!(error instanceof DOMException)) {
      console.error('Non-DOMException from navigator.share():', error);
      return 'unexpected';
    }
    switch (error.name) {
      case 'AbortError':
        // User cancelled the system sheet — no fallback, no counter increment
        return 'cancelled';
      case 'NotAllowedError':
        console.warn('Share blocked by browser policy or missing gesture:', error.message);
        return 'denied';
      case 'TypeError':
        // Payload contains an unsupported field or combination
        console.warn('Invalid ShareData payload:', error.message);
        return 'payload-error';
      default:
        console.error('Unexpected DOMException from navigator.share():', error.name, error.message);
        return 'unexpected';
    }
  }
}

Step 4 — Route Denied Payloads to a Fallback

When the result is 'denied' or 'payload-error', preserve the full ShareData object and hand it off to alternative sharing. The SMS and email fallback architectures guide covers building the downstream recipients of this handoff. Trigger the fallback asynchronously to avoid blocking the gesture handler’s call stack.

/**
 * Constructs a normalised ShareData object with safe defaults.
 * @param {ShareData} payload
 * @returns {Required<Pick<ShareData, 'title' | 'text' | 'url'>>}
 */
export function normaliseSharePayload(payload) {
  return {
    title: payload.title ?? document.title ?? '',
    text: payload.text ?? '',
    url: payload.url ?? globalThis.location?.href ?? '',
  };
}

/**
 * Routes a failed share payload to the fallback sharing modal.
 * Safe to call on 'denied' or 'payload-error'; no-ops on 'success' or 'cancelled'.
 * @param {ShareData} payload
 * @param {'success' | 'cancelled' | 'denied' | 'payload-error' | 'unexpected'} result
 */
export function routeToFallback(payload, result) {
  if (result === 'success' || result === 'cancelled') return;

  const normalised = normaliseSharePayload(payload);

  // queueMicrotask keeps this off the gesture handler's synchronous call stack
  queueMicrotask(() => {
    const modal = document.getElementById('share-fallback-modal');
    if (!modal) {
      console.warn('Fallback modal element not found in DOM.');
      return;
    }
    modal.dataset.payload = JSON.stringify(normalised);
    if (typeof modal.showModal === 'function') {
      modal.showModal();
    } else {
      modal.style.display = 'block';
    }
  });
}

For QR code generation as a cross-device fallback, the same normalised.url value feeds directly into the QR encoder — no reformatting required.

Step 5 — Apply Exponential Backoff Before Re-offering the Native Dialog

After a denial, incrementing a session counter and gating the next native share attempt behind a delay prevents the browser’s anti-harassment throttling from permanently suppressing the permission prompt. The counter must reset on a successful share to avoid premature escalation.

const DENIAL_STORAGE_KEY = 'share_denial_count';

/**
 * Returns the number of consecutive share denials recorded in sessionStorage.
 * @returns {number}
 */
export function getDenialCount() {
  return Number(sessionStorage.getItem(DENIAL_STORAGE_KEY) ?? 0);
}

/**
 * Increments the denial counter. Call only on 'denied', not on 'cancelled'.
 */
export function recordDenial() {
  sessionStorage.setItem(DENIAL_STORAGE_KEY, String(getDenialCount() + 1));
}

/**
 * Resets the denial counter after a successful share.
 */
export function clearDenialCount() {
  sessionStorage.removeItem(DENIAL_STORAGE_KEY);
}

/**
 * Calculates the delay in milliseconds before offering the native share again.
 * Applies jitter to prevent synchronised retries on page reload.
 * @param {number} denialCount
 * @param {number} [baseMs=1000]
 * @param {number} [maxMs=86400000] 24 hours
 * @returns {number}
 */
export function calculateBackoffDelay(denialCount = 0, baseMs = 1_000, maxMs = 86_400_000) {
  const exponential = Math.min(baseMs * 2 ** denialCount, maxMs);
  const jitter = Math.random() * exponential * 0.2;
  return Math.round(exponential + jitter);
}

The full backoff implementation with sessionStorage persistence and re-enable logic is covered in implementing exponential backoff for permission re-prompts.

Step 6 — Wire the Pieces Together

Combine all the above into a single event handler attached to the share trigger. This is the only entry point for a share action in your application.

import {
  canUseNativeShare,
  invokeNativeShare,
  routeToFallback,
  recordDenial,
  clearDenialCount,
  getDenialCount,
  calculateBackoffDelay,
} from './share-utils.js';

/**
 * Initialises the share trigger button with full denial-handling logic.
 * Call once after DOMContentLoaded.
 */
export function initShareButton() {
  const button = document.getElementById('share-trigger');
  if (!button) return;

  if (!canUseNativeShare()) {
    button.hidden = true;
    return;
  }

  button.addEventListener('click', async () => {
    const payload = {
      title: document.title,
      text: document.querySelector('meta[name="description"]')?.content ?? '',
      url: location.href,
    };

    const result = await invokeNativeShare(payload);

    if (result === 'success') {
      clearDenialCount();
      return;
    }

    if (result === 'denied' || result === 'payload-error' || result === 'unexpected') {
      recordDenial();
      routeToFallback(payload, result);

      // Disable the native trigger temporarily; re-enable after backoff delay
      button.disabled = true;
      const delay = calculateBackoffDelay(getDenialCount());
      setTimeout(() => { button.disabled = false; }, delay);
    }
    // 'cancelled' needs no action
  });
}

Payload Validation and Error Boundary

navigator.canShare() is the canonical pre-flight check, but its behaviour varies:

  • Chrome 89+ validates files arrays and rejects unsupported MIME types
  • Safari 15+ ignores the files field silently rather than returning false from canShare()
  • Firefox Android accepts canShare() but has a narrower set of supported MIME types

Always wrap navigator.share() in a try/catch regardless of what canShare() returns — the pre-flight is advisory, not a guarantee. A TypeError from navigator.share() with no prior canShare() failure indicates a browser-specific restriction not surfaced by the API.

Platform Gotchas

iOS / Safari (mobile): The gesture token is consumed at the point of the event listener registration. Any await before navigator.share() — even a short await fetch() — voids the token and produces NotAllowedError. Keep the navigator.share() call the very first await in the handler.

Android / Chrome: Persistent NotAllowedError on Android most commonly means the page is not the active foreground tab. This happens when share is triggered from a push notification action or a background MessageChannel message. Always confirm document.visibilityState === 'visible' before attempting a share.

Desktop Chrome / Edge: navigator.share() on desktop requires Chrome 89+ and is restricted to origins with a positive engagement score on some builds. If the share dialog never appears in development, check chrome://site-engagement/ for the localhost origin and interact with the page before testing.

Iframes: navigator.share() requires the allow="web-share" Permissions Policy attribute on the iframe element. Without it, NotAllowedError fires regardless of secure context or gesture state.

Testing and Verification

DevTools approach:

  1. Open DevTools → Application → Storage → Clear site data, then reload. This resets any previously set denial counters and browser engagement scores.
  2. In the Console, call canUseNativeShare() and confirm it returns true before testing the share button.
  3. Set a breakpoint inside the catch block of invokeNativeShare() and inspect error.name to confirm classification is correct for each scenario.
  4. Simulate NotAllowedError by calling navigator.share() directly from the Console (outside a gesture) — this reliably produces the error on Chrome and Edge.

Physical device checklist:

For setting up a local HTTPS environment where all these paths are testable, see configuring HTTPS for local development.

Common Pitfalls

  • Calling navigator.share() outside a synchronous user-gesture handler — the gesture token must be live at the point of invocation, not consumed by a prior await
  • Treating AbortError as a denial and routing it to fallback — this trains users to expect fallback UI after simply cancelling the native sheet
  • Not resetting the denial counter on success — this causes premature backoff escalation after a successful share following a previous session’s denial
  • Using localStorage for the denial counter instead of sessionStorage — persistent cross-session denial state prevents the counter from naturally resetting on new visits
  • Omitting navigator.canShare() pre-flight — without it, a TypeError from an invalid payload is indistinguishable from a NotAllowedError in the catch block

FAQ

How do I differentiate between a user tapping Cancel and a permanent permission denial?

Parse the caught DOMException.name. AbortError indicates user cancellation (the system sheet was dismissed); NotAllowedError indicates a system-level or policy denial. Treat them differently: cancellations require no fallback and no counter increment; denials should immediately route to alternative sharing and increment the backoff counter.

Can I programmatically reset a denied Web Share permission?

No. Browsers prevent programmatic permission resets to protect user privacy. Guide users to their browser settings (Settings → Site Settings → Permissions on Chrome Android, or Settings → Safari → Website Data on iOS). The permission resets automatically when the user clears site data.

What happens to the share payload if the native dialog fails mid-flight?

The payload remains in the JavaScript call stack as the argument passed to navigator.share(). Your catch handler has full access to it. The routeToFallback() function above receives the original ShareData object and normalises it before passing it to the fallback modal — no data is lost between the failure and the alternative UI.

Why does navigator.share() fail in an iframe even with HTTPS?

Iframes require an explicit Permissions Policy grant: <iframe allow="web-share" ...>. Without it, NotAllowedError fires regardless of secure context, user gesture, or browser version. This is enforced at the browser level and cannot be worked around in JavaScript.