Retrying Failed Shares with Exponential Backoff
This guide is part of Background Sync for Deferred Web Share Actions, which itself is part of Offline-First PWA Patterns for Web Share API.
A queued share that fails to deliver should be retried — but retrying immediately and indefinitely is how you turn one flaky request into a self-inflicted denial-of-service against your own API. Exponential backoff spaces retries so each successive attempt waits longer, jitter keeps a batch of entries from firing in unison, and an attempt cap ensures a permanently broken payload eventually stops consuming resources. This page implements that policy over an IndexedDB share queue, and — critically — keeps a user’s deliberate cancellation from ever counting against the retry budget.
Feature Detection Gate
Backoff logic runs in the same secure context as the queue itself, so validate the environment before touching storage or attempting delivery. Retrying is only meaningful when the share can, in principle, still be delivered.
// retry-gate.js
export function canProcessRetries() {
return (
window.isSecureContext === true &&
'indexedDB' in window &&
typeof navigator.onLine === 'boolean'
);
}
If canProcessRetries() is false, do not spin the retry loop — there is either no durable store to read from or no way to reach the network. Bail early and leave entries untouched so no attempts are wasted while delivery is impossible.
Solution Walkthrough
Step 1 — Store attempts and a nextAttempt timestamp
Make retry eligibility a pure time comparison. Each entry carries an attempts counter and a nextAttempt epoch millisecond value; an entry is due when Date.now() >= nextAttempt. Storing the timestamp rather than a relative delay means a service worker restart or an app relaunch loses no scheduling state.
// retry-schema.js
export function newQueueEntry(payload) {
return {
id: crypto.randomUUID(),
idempotencyKey: crypto.randomUUID(),
payload,
status: 'pending',
attempts: 0,
createdAt: Date.now(),
nextAttempt: Date.now() // eligible immediately on first pass
};
}
export function isDue(entry, now = Date.now()) {
return entry.status === 'pending' && now >= entry.nextAttempt;
}
Querying by status alone is not enough — a pending entry may be mid-backoff. Filtering with isDue() ensures you never re-attempt an entry before its scheduled window, which is what makes the backoff actually take effect.
Step 2 — Compute the delay with full jitter
The core formula is delay = base * 2 ^ attempts, clamped to a ceiling so the wait never grows absurd, plus jitter to decorrelate concurrent entries. Full jitter — a random value between zero and the computed delay — is the most effective at spreading load, and it is what production retry libraries default to.
// backoff.js
const BASE_DELAY_MS = 2_000; // first retry ~2s later
const MAX_DELAY_MS = 5 * 60_000; // cap at 5 minutes
const MAX_ATTEMPTS = 6;
export function computeBackoff(attempts) {
const exponential = BASE_DELAY_MS * 2 ** attempts;
const capped = Math.min(exponential, MAX_DELAY_MS);
// Full jitter: random point in [0, capped]
const jitter = Math.random() * capped;
return Math.round(jitter);
}
export function scheduleRetry(entry) {
const attempts = entry.attempts + 1;
if (attempts >= MAX_ATTEMPTS) {
return { ...entry, attempts, status: 'failed', failedAt: Date.now() };
}
return {
...entry,
attempts,
status: 'pending',
nextAttempt: Date.now() + computeBackoff(attempts)
};
}
scheduleRetry() is the single place attempts are incremented and the cap is enforced. Reaching MAX_ATTEMPTS flips the entry to failed with a timestamp rather than deleting it, so the UI can show the user what did not go through.
Step 3 — Classify the failure before counting it
Not every rejection is a delivery failure. An AbortError from navigator.share() means the user dismissed the share sheet on purpose — that is a cancellation, and counting it would spend the retry budget on an action the user chose to stop. Distinguish it from hard errors and leave it pending without incrementing attempts.
// deliver-with-retry.js
import { scheduleRetry } from './backoff.js';
export async function deliverEntry(db, entry) {
await setStatus(db, entry.id, 'processing');
try {
const res = await fetch('/api/share', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Idempotency-Key': entry.idempotencyKey
},
body: JSON.stringify(entry.payload)
});
if (res.ok) {
await deleteEntry(db, entry.id);
return;
}
if (res.status >= 400 && res.status < 500) {
// Client error — the payload is permanently unacceptable
await putEntry(db, { ...entry, status: 'failed', failedAt: Date.now() });
return;
}
// 5xx — a countable, retryable failure
await putEntry(db, scheduleRetry(entry));
} catch (err) {
if (err.name === 'AbortError') {
// User cancelled a foreground native re-share — not a failure, do not count
await putEntry(db, { ...entry, status: 'pending' });
return;
}
// Network fault or unknown error — countable, apply backoff
await putEntry(db, scheduleRetry(entry));
}
}
The two branches that call scheduleRetry() — a 5xx response and a network exception — are the only ones that increment attempts. A 4xx marks the entry failed immediately because retrying a payload the server rejected on its merits will never succeed, and an AbortError returns to plain pending. This three-way split is what keeps the retry budget spent only on failures that a later attempt could actually fix.
Step 4 — Drive the loop over due entries only
Run the retry pass wherever your queue drains — the Chromium sync handler, or the foreground visibilitychange drain on iOS. Select pending entries, filter to those that are due, and deliver each. Entries mid-backoff are skipped untouched.
// process-retries.js
import { canProcessRetries } from './retry-gate.js';
import { isDue } from './retry-schema.js';
import { deliverEntry } from './deliver-with-retry.js';
export async function processDueRetries() {
if (!canProcessRetries() || !navigator.onLine) return;
const db = await openShareDB();
const pending = await getAllByStatus(db, 'pending');
const now = Date.now();
const due = pending.filter((entry) => isDue(entry, now));
await Promise.allSettled(due.map((entry) => deliverEntry(db, entry)));
}
Using Promise.allSettled means one entry’s failure never aborts the batch, and because each deliverEntry writes its own next nextAttempt, the following pass naturally re-schedules only what still needs it.
Failure Modes and Recovery
| Symptom | Root cause | Fix |
|---|---|---|
| Retry storm the instant network returns | No jitter — all due entries fire together | Apply full jitter in computeBackoff() |
| Retry budget exhausted on a working share | AbortError cancellations counted as failures |
Branch on err.name === 'AbortError'; keep pending, do not increment |
| Queue never stops retrying, battery drains | No attempt cap | Enforce MAX_ATTEMPTS; flip to failed when reached |
| Entry retries immediately, ignoring backoff | Loop filters on status only, not nextAttempt |
Filter with isDue() so Date.now() >= nextAttempt |
| 4xx payload retried forever | Client errors treated as retryable | Mark 4xx failed at once; only retry 5xx and network faults |
| Backoff resets after app relaunch | Delay stored relatively, not as a timestamp | Persist an absolute nextAttempt epoch value on the entry |
Browser and Platform Caveat
This backoff policy is transport-agnostic: it governs how your code re-attempts delivery, so it behaves identically whether the drain is triggered by Chromium’s Background Sync sync event or by the iOS foreground poll described in fixing Background Sync not firing on iOS Safari. Note that on Chromium the browser also applies its own backoff to a rejected sync tag, so your per-entry backoff and the browser’s per-tag backoff compose — keep your base delay modest to avoid stacking two long waits. This share-delivery backoff is deliberately distinct from the exponential backoff for permission re-prompts: that pattern throttles how often you re-ask the user for a permission, whereas this one throttles how often you re-post a payload to your server. They share the formula but protect entirely different resources.
FAQ
Why add jitter to exponential backoff?
Without jitter, every entry that failed at the same moment — which is exactly what happens when connectivity drops for a whole batch — retries at the same instant when the network returns, producing a synchronised spike against your server. Full jitter spreads each entry’s retry across a random window up to its computed delay, smoothing that burst into a manageable trickle and preventing a self-inflicted traffic surge.
Should an AbortError count as a retry?
No. An AbortError means the user deliberately dismissed the native share sheet, which is a cancellation rather than a delivery failure. Counting it would consume the retry budget on an action the user intentionally stopped, potentially marking a perfectly deliverable share as failed. Leave the entry pending and do not increment its attempts counter.
How is this different from permission re-prompt backoff?
Share retry backoff controls how frequently you re-attempt delivering a queued payload to your API after a transient failure. Permission re-prompt backoff controls how frequently you re-ask the user to grant a permission after they have denied it. Both use base * 2 ^ attempts, but one protects your server from a retry storm and the other protects the user from prompt fatigue — see the dedicated permission re-prompt backoff guide for that case.
When should a failed entry be deleted versus kept?
Keep it. Flipping an entry to failed with a failedAt timestamp — rather than deleting it — lets the UI show the user which shares did not go through and offer a manual retry that resets attempts to zero. Delete only after the user acknowledges the failure or a housekeeping sweep purges aged failed records, so a silent delete never hides a lost share.