How to avoid permission fatigue in progressive web apps
This guide is part of Designing Contextual Permission Prompts, which lives under the Permission Flows & Progressive Enhancement section.
Problem statement
PWAs hit a specific failure mode: the app calls navigator.share() on page load, on route transitions, or inside a setTimeout — before the user has taken any deliberate action. Browsers detect these unanchored invocations and begin returning NotAllowedError without displaying any dialog. Once that throttle engages, even correctly-timed user-gesture-bound calls start failing silently. The user experiences a share button that does nothing; the developer sees a wall of NotAllowedError with no obvious cause. This page shows the exact sequence needed to break that loop.
Feature detection gate
Run this check before attaching share UI or calling any share API. Never skip it — the Web Share API is not available in all browsers, and calling it in a non-secure context throws immediately.
export function detectShareSupport() {
if (!window.isSecureContext) return false;
if (typeof navigator.share !== 'function') return false;
return true;
}
navigator.permissions.query does not accept 'share' as a permission name — there is no queryable permission for the Web Share API. Use the feature-detection pattern above, not a permissions query.
Solution walkthrough
Step 1 — Bind share calls strictly to user gestures
Never call navigator.share() on load, DOMContentLoaded, route transitions, or timers. Attach it only inside a click or pointerdown handler. The browser checks user activation state at the moment of the call; any call outside a direct event handler fails immediately.
// ❌ Anti-pattern — fires without user gesture, triggers throttle
window.addEventListener('load', async () => {
await navigator.share({ title: document.title, url: location.href });
});
// ✅ Correct — called only from a pointer event handler
document.getElementById('share-btn').addEventListener('click', async (event) => {
await handleContextualShare({ title: document.title, url: location.href });
});
Step 2 — Read and write denial state in sessionStorage
On NotAllowedError, record a timestamp. On the next share attempt, read the timestamp and suppress the prompt for at least 24 hours if the denial is recent. This keeps your share UI visible but routes to a fallback, so the user is never stranded.
const DENIAL_KEY = 'share_denied_at';
const COOLDOWN_MS = 86_400_000; // 24 hours
export function isInDenialCooldown() {
const raw = sessionStorage.getItem(DENIAL_KEY);
if (!raw) return false;
return Date.now() - Number(raw) < COOLDOWN_MS;
}
export function recordDenial() {
sessionStorage.setItem(DENIAL_KEY, Date.now().toString());
}
Step 3 — Validate the payload with navigator.canShare() before invoking
Calling navigator.share() with an unsupported file type or an oversized payload wastes the user’s gesture activation — iOS Safari may resolve the promise silently without showing any sheet. Call navigator.canShare(payload) first; if it returns false, go straight to the clipboard fallback.
export async function handleContextualShare(payload) {
if (!detectShareSupport()) {
return fallbackToClipboard(payload.url);
}
if (isInDenialCooldown()) {
return fallbackToClipboard(payload.url);
}
if (!navigator.canShare(payload)) {
return fallbackToClipboard(payload.url);
}
try {
await navigator.share(payload);
} catch (err) {
if (err.name === 'AbortError') {
// User dismissed the dialog — not a denial, do not update state
return;
}
if (err.name === 'NotAllowedError') {
recordDenial();
return fallbackToClipboard(payload.url);
}
// TypeError or DataError — payload issue, go to fallback
return fallbackToClipboard(payload.url);
}
}
Step 4 — Schedule expensive capability checks with requestIdleCallback
If you need to inspect device capabilities (e.g. checking navigator.canShare for file-sharing support on load to decide which UI to display), do it in requestIdleCallback to avoid blocking the main thread during animation frames. Never use the result to pre-invoke navigator.share() — only use it to adjust the UI.
export function scheduleCapabilityCheck(onResult) {
requestIdleCallback(() => {
const canShareFiles =
window.isSecureContext &&
typeof navigator.canShare === 'function' &&
navigator.canShare({ files: [new File([''], 'test.png', { type: 'image/png' })] });
onResult(canShareFiles);
});
}
Failure modes and recovery
| Error name | User-visible symptom | Cause | Fix |
|---|---|---|---|
NotAllowedError (immediate, no dialog) |
Share button does nothing | Browser throttling active — too many ungestured calls | Enforce gesture binding; clear test denial state in DevTools |
NotAllowedError (after dialog) |
User saw prompt and denied | Hard user denial | Record timestamp, enter cooldown, show clipboard fallback |
AbortError |
User dismissed dialog | User closed the share sheet | Do not record as denial; re-enable share UI normally |
TypeError |
No visible feedback | Invalid or missing payload field | Validate with navigator.canShare() before every call |
| Silent resolve (iOS, no sheet) | Nothing shared, no error | Unsupported MIME type or oversized file | Pre-check with navigator.canShare(payload) |
The AbortError vs NotAllowedError distinction is the most common source of runaway denial state. AbortError means the user dismissed the OS sheet — not a denial. Treating it as a denial inflates your cooldown counter and causes the share feature to stop working for users who simply changed their mind.
To diagnose active throttling in Chrome, open DevTools, go to Application → Site Settings (or the address bar lock icon → Permissions) and look for the share permission state. In Safari Web Inspector, the Storage → Session Storage panel shows your share_denied_at key.
Browser and platform caveats
The gesture-binding and sessionStorage pattern works on all browsers that support navigator.share, but the throttle behaviour differs. Chromium on Android enforces throttling after repeated ungestured calls and may show an infobar before hard-blocking. iOS Safari is stricter: a single denial within a session permanently suppresses the prompt for the rest of that session, returning NotAllowedError immediately on subsequent calls regardless of gesture state. Desktop Chrome and Edge generally do not throttle as aggressively but will block calls outside user activation. Firefox does not support navigator.share as of mid-2026 except in Firefox for Android (version 112+), where it delegates to the Android share sheet. For Firefox desktop and other unsupported browsers, the QR code fallback or a clipboard copy is the correct degradation path.
How do I tell the difference between the browser blocking the prompt due to throttling and the user actively denying it?
Both surface as NotAllowedError. The signal that the browser is throttling — not the user — is that the error is thrown without any visible dialog ever appearing. Track whether the share sheet opened before the error landed; if it never appeared, the engine suppressed it. Record the timestamp of the first NotAllowedError in sessionStorage and enforce a 24-hour silence window before presenting share UI again. For distinguishing throttle from denial in tests, use Chrome DevTools Application → Site Settings to manually reset the permission state between runs.
Can I call navigator.share() without a user gesture to reduce friction?
No. Modern browsers enforce strict user activation requirements for the Web Share API. Calling navigator.share() outside a click or pointerdown handler throws NotAllowedError immediately and contributes to engine-level throttling for future calls. The only correct approach is gesture-binding every share invocation to an explicit pointer or keyboard event.
Should denial state go in sessionStorage or localStorage?
sessionStorage is the safer default because it clears automatically when the tab closes, preventing stale denial state from carrying into future sessions. If you need a cross-session cooldown (for example, a 7-day backoff after repeated denials), use localStorage with a versioned key — e.g. share_denied_v2 — so that stale state from an older app version does not permanently suppress the feature after a redeployment. The exponential backoff approach for permission re-prompts covers the cross-session strategy in detail.
Why does iOS Safari silently succeed on navigator.share() but nothing actually shares?
iOS Safari resolves the share promise without throwing an error when the payload contains unsupported MIME types or the total file size exceeds the system limit. The OS share sheet either appears briefly with nothing to share or does not appear at all. Always call navigator.canShare(payload) before navigator.share() — if canShare returns false, route directly to the clipboard fallback. This is part of the broader payload validation strategy described in Designing Contextual Permission Prompts.
Related
- Designing Contextual Permission Prompts — parent guide covering trigger timing, gesture binding, and fallback routing
- Implementing Exponential Backoff for Permission Re-Prompts — cross-session denial state with jittered retry intervals
- QR Code Generation for Cross-Device Sharing — fallback for browsers where the Web Share API is unavailable