Implementing Exponential Backoff for Permission Re-Prompts
This guide is part of Handling Permission Denials Gracefully, which itself sits within Permission Flows & Progressive Enhancement.
When navigator.share() returns NotAllowedError, re-prompting immediately triggers browser-level throttling on Chromium and silent suppression on WebKit. This page shows how to schedule a jittered exponential backoff, persist retry state across SPA route changes with sessionStorage, and clean up pending timers with AbortController so phantom prompts never fire after navigation.
Feature Detection Gate
Every retry strategy must confirm the API is available and the page is in a secure context before scheduling any re-prompt. This check runs once; if it returns false the native share path is permanently unavailable for this session and you should route directly to a fallback such as the QR code cross-device sharing flow.
/**
* Returns true only when the Web Share API is available in a secure context.
* Must pass before any retry scheduling is attempted.
* @returns {boolean}
*/
export function canScheduleShareRetry() {
if (typeof window === 'undefined' || typeof navigator === 'undefined') return false;
return window.isSecureContext === true && typeof navigator.share === 'function';
}
Checking window.isSecureContext separately from typeof navigator.share matters on mixed-content pages where the property exists but is non-functional. The understanding secure context requirements guide explains why both checks are always needed together.
The Retry State Diagram
The diagram below shows the full lifecycle of a share attempt with backoff: from the initial call through error classification, delay scheduling, and the teardown path when a component unmounts or the threshold is exhausted.
Solution Walkthrough
Step 1 — Classify the DOMException Before Scheduling Anything
The single most common source of inflated denial counters is misidentifying AbortError (the user dismissed the system sheet) as NotAllowedError (a policy or browser block). Only NotAllowedError should ever increment the retry counter.
/**
* Classifies a DOMException thrown by navigator.share().
* @param {unknown} err
* @returns {'success' | 'cancelled' | 'denied' | 'unrecoverable'}
*/
export function classifyShareError(err) {
if (!(err instanceof DOMException)) return 'unrecoverable';
switch (err.name) {
case 'AbortError':
// User tapped Cancel on the system sheet — not a denial
return 'cancelled';
case 'NotAllowedError':
// Browser policy, missing gesture, or OS-level block
return 'denied';
default:
// TypeError, SecurityError — payload or context problem; no retry
return 'unrecoverable';
}
}
For a broader look at all DOMException variants and their recovery paths, see handling permission denials gracefully.
Step 2 — Calculate Jittered Delay
A deterministic backoff sequence — 2 s, 4 s, 8 s — causes every open tab to retry in lockstep, which can saturate browser throttling heuristics simultaneously. Adding ±20% random jitter desynchronises the attempts across tabs.
/**
* Calculates the delay before the next share re-prompt attempt.
* Applies ±20% jitter to desynchronise retries across open tabs.
* @param {number} attemptIndex Zero-based attempt number (0 = first retry)
* @param {number} [baseMs=2000]
* @param {number} [maxMs=300000] 5 minutes
* @returns {number} Milliseconds to wait
*/
export function calculateBackoffDelay(attemptIndex, baseMs = 2_000, maxMs = 300_000) {
if (!window.isSecureContext || typeof navigator.share !== 'function') {
throw new Error('calculateBackoffDelay called outside a valid share context');
}
const exponential = baseMs * Math.pow(2, attemptIndex);
const capped = Math.min(exponential, maxMs);
const jitter = (Math.random() * 0.4) - 0.2; // uniform [-0.2, +0.2]
return Math.round(capped * (1 + jitter));
}
Step 3 — Persist Retry State in sessionStorage
Using localStorage for the denial counter causes it to survive across browser sessions, locking returning users into maximum backoff indefinitely. sessionStorage resets on every new session while still surviving SPA route transitions and soft page reloads within the same tab.
const RETRY_KEY = 'share_backoff_state';
/**
* Reads persisted retry state from sessionStorage.
* @returns {{ attempts: number; nextRetryAt: number }}
*/
export function readRetryState() {
try {
const raw = sessionStorage.getItem(RETRY_KEY);
if (!raw) return { attempts: 0, nextRetryAt: 0 };
return JSON.parse(raw);
} catch {
return { attempts: 0, nextRetryAt: 0 };
}
}
/**
* Writes updated retry state after a denial.
* @param {number} attempts
* @param {number} delayMs
*/
export function writeRetryState(attempts, delayMs) {
const state = { attempts, nextRetryAt: Date.now() + delayMs };
sessionStorage.setItem(RETRY_KEY, JSON.stringify(state));
}
/**
* Clears retry state after a successful share.
*/
export function clearRetryState() {
sessionStorage.removeItem(RETRY_KEY);
}
Step 4 — Schedule the Re-Prompt with AbortController Cleanup
Wrap setTimeout in an AbortController signal check so that any pending timer is cancelled cleanly when the calling component unmounts or the user navigates in an SPA. Without this, the timer fires after navigation and calls navigator.share() outside a user gesture, producing NotAllowedError on every retry.
/**
* Manages exponential backoff scheduling for Web Share API re-prompts.
* Call teardown() on component unmount or SPA route change.
*/
export class ShareBackoffScheduler {
#controller = new AbortController();
#timerId = null;
#maxAttempts = 3;
/**
* Attempts to share; schedules a re-prompt on denial.
* Must be called from a synchronous user-gesture handler.
* @param {ShareData} payload
* @param {() => void} onExhausted Called when max attempts are reached
*/
async attempt(payload, onExhausted) {
if (!canScheduleShareRetry()) {
onExhausted();
return;
}
const { attempts } = readRetryState();
if (attempts >= this.#maxAttempts) {
clearRetryState();
onExhausted();
return;
}
try {
await navigator.share(payload);
clearRetryState();
} catch (err) {
const outcome = classifyShareError(err);
if (outcome === 'cancelled') {
// Dismissal — do not touch retry state
return;
}
if (outcome === 'unrecoverable') {
clearRetryState();
onExhausted();
return;
}
// outcome === 'denied'
const nextAttempts = attempts + 1;
const delay = calculateBackoffDelay(nextAttempts - 1);
writeRetryState(nextAttempts, delay);
this.#scheduleRetry(payload, delay, onExhausted);
}
}
#scheduleRetry(payload, delay, onExhausted) {
this.#timerId = setTimeout(() => {
if (this.#controller.signal.aborted) return;
// Re-entry: the user must re-trigger; surface a UI nudge instead of
// auto-invoking navigator.share() outside a gesture.
this.#emitRetryReadyEvent(payload);
}, delay);
}
#emitRetryReadyEvent(payload) {
const event = new CustomEvent('share:retry-ready', { detail: payload, bubbles: true });
document.dispatchEvent(event);
}
/** Cancel any pending retry timer. Call on component unmount. */
teardown() {
this.#controller.abort();
if (this.#timerId !== null) {
clearTimeout(this.#timerId);
this.#timerId = null;
}
}
}
Note that #scheduleRetry emits a custom event rather than directly calling navigator.share(). The browser requires a fresh synchronous user gesture for every invocation — an auto-fired setTimeout callback does not qualify. The custom event lets the UI re-enable the share button and show a “Try again” cue, which the user then taps to trigger a new gesture.
Failure Modes and Recovery
| Error name | User-visible symptom | Root cause | Minimal fix |
|---|---|---|---|
NotAllowedError on first attempt |
Share button does nothing | No user gesture token, or OS-level policy | Ensure share call is the first await in the click handler |
NotAllowedError on every retry |
Permanent silent failure | iOS Safari same-session suppression | Check sessionStorage — if attempts >= 1 on iOS, skip the re-prompt and show clipboard fallback immediately |
AbortError increments counter |
Backoff triggers on dismissal | AbortError misclassified as NotAllowedError |
Use classifyShareError() from Step 1; never increment on AbortError |
| Phantom prompt after navigation | Share dialog appears on wrong page | setTimeout not cleared on unmount |
Call scheduler.teardown() in component cleanup |
| Counter never resets | Every session starts at max backoff | Denial state stored in localStorage |
Use sessionStorage exclusively for the denial counter |
When all retry attempts are exhausted, pass the payload to an alternative sharing mechanism. The SMS and email fallback architectures guide covers building SMS URI and mailto: handlers that accept the same ShareData object without reformatting.
Browser and Platform Caveats
iOS Safari suppresses subsequent navigator.share() prompts after a single denial within the same browsing session — no UI appears, and NotAllowedError fires instantaneously. On this platform the backoff timer is effectively an appearance of retry logic, but the re-prompt will never show a system sheet unless the user has manually reset permissions or started a new session. Detect this condition by reading attempts from sessionStorage on load: if the counter is already >= 1 on a Safari session, skip the native path entirely and surface the clipboard or QR code fallback immediately. Chrome on Android respects the backoff — provided the call originates from a genuine user gesture — and will show the share sheet again after a cooldown period. Desktop Chrome and Edge also throttle repeated calls within a short window, making jittered delays important even outside mobile contexts.
FAQ
How does iOS Safari handle permission re-prompts differently than Chrome?
iOS Safari silently suppresses subsequent prompts after a single denial within the same session, returning NotAllowedError without any visible UI. Chrome enforces throttling but typically still shows UI for a grace period. Backoff for Safari must rely on sessionStorage state and extended base delays rather than expecting a new prompt to appear.
What happens if a user navigates away during the backoff period?
In SPAs, route changes unmount components and leave dangling setTimeout references. Call teardown() in your component’s cleanup function (useEffect cleanup in React, onUnmounted in Vue) to abort pending timers. To persist the retry timestamp across navigation, read nextRetryAt from sessionStorage on re-entry and skip the delay if it has already elapsed.
Can exponential backoff be combined with service worker deferred sharing?
Yes. When backoff thresholds are exhausted, serialise the share payload and dispatch it to a service worker via postMessage. The worker can cache it in IndexedDB and attempt delivery when connectivity and permission states stabilise — this is the pattern detailed in syncing offline share queues with service workers.
How do I test throttling behaviour without triggering permanent browser blocks?
Use Chrome DevTools Application → Clear site data to reset permission states, or run tests in incognito mode. For automated testing, mock navigator.share with a Proxy that simulates NotAllowedError after N calls, allowing deterministic validation of backoff intervals without hitting real browser throttling.
Related
- Handling Permission Denials Gracefully — parent guide covering DOMException classification, fallback routing, and the full denial-handling pattern
- Designing Contextual Permission Prompts — how to avoid permission fatigue by timing prompts around genuine user intent
- Syncing Offline Share Queues with Service Workers — deferred sharing when backoff thresholds are exhausted