Fixing Background Sync Not Firing on iOS Safari
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.
You wired up registration.sync.register('flush-share-queue'), verified it on Android Chrome, shipped it — and on iPhone the queue never drains. The share sits in IndexedDB forever. The cause is not a bug in your code: iOS Safari does not implement the Background Sync API at all, so the sync event you are waiting for is never dispatched. The fix is not to patch Background Sync but to detect its absence and drive the queue from foreground events instead.
Feature Detection Gate
The single check that determines your entire code path is whether the sync property exists on the registration prototype. On WebKit it does not, so registration.sync is undefined and any call against it throws. Gate on the prototype rather than an instance so you get a synchronous, cache-able answer.
// ios-sync-gate.js
export function hasBackgroundSync() {
return (
'serviceWorker' in navigator &&
typeof ServiceWorkerRegistration !== 'undefined' &&
'sync' in ServiceWorkerRegistration.prototype
);
}
export function requiresForegroundDrain() {
// True on iOS Safari, all iOS browsers, and Firefox — anywhere sync is absent.
return window.isSecureContext && !hasBackgroundSync();
}
On iOS Safari, hasBackgroundSync() returns false, so requiresForegroundDrain() returns true and you install the polling path below. On Android Chrome it returns true, and you leave delivery to the service worker’s sync handler — never run both, or the queue is processed twice.
Solution Walkthrough
Step 1 — Confirm the diagnosis in the console
Before changing anything, prove that Background Sync is the missing piece. On the iOS device (or Safari’s Responsive Design Mode pointed at a real iPhone via Web Inspector), run:
// Paste into Safari Web Inspector console on your page
const reg = await navigator.serviceWorker.ready;
console.log('sync' in reg); // false on iOS
console.log('sync' in ServiceWorkerRegistration.prototype); // false on iOS
console.log(typeof reg.sync); // "undefined" on iOS
If all three report the WebKit values above, the sync event will never fire and no amount of re-registration will help. This rules out the common false leads — a mistyped tag, an unregistered worker, or a rejected waitUntil promise — that would apply on a browser that does support the API.
Step 2 — Drain on visibilitychange and online
Replace the browser-managed trigger with the two foreground signals that reliably fire on iOS: the page becoming visible again, and the network coming back while the tab is open. Both must additionally check navigator.onLine, because a visibilitychange can fire while still offline.
// ios-foreground-drain.js
import { requiresForegroundDrain } from './ios-sync-gate.js';
import { drainShareQueue } from './drain-share-queue.js';
export function installIosDrain() {
if (!requiresForegroundDrain()) return; // Chromium handles it in the worker
const attempt = () => {
if (document.visibilityState === 'visible' && navigator.onLine) {
drainShareQueue().catch((err) =>
console.error('iOS queue drain failed:', err.message)
);
}
};
document.addEventListener('visibilitychange', attempt);
window.addEventListener('online', attempt);
window.addEventListener('pageshow', attempt); // covers bfcache restores
attempt(); // drain immediately on load if already visible + online
}
pageshow matters on iOS specifically: Safari aggressively serves pages from the back/forward cache, and a bfcache restore does not re-run your module top level. Listening for pageshow guarantees a drain attempt when the user navigates back to your app.
Step 3 — Add a foreground safety timer
Events can be missed. A connection can recover without firing online if it was never reported as fully offline, and visibilitychange will not fire if the user simply keeps the tab open while a flaky signal comes and goes. A low-frequency interval, active only while the page is visible, closes that gap without draining the battery.
// ios-safety-timer.js
import { requiresForegroundDrain } from './ios-sync-gate.js';
import { drainShareQueue } from './drain-share-queue.js';
export function installSafetyTimer(intervalMs = 60_000) {
if (!requiresForegroundDrain()) return;
setInterval(() => {
if (document.visibilityState === 'visible' && navigator.onLine) {
drainShareQueue().catch(() => { /* logged inside drainShareQueue */ });
}
}, intervalMs);
}
Keep the interval coarse — 60 seconds is ample. The timer is a safety net behind the event listeners, not the primary mechanism, so a tighter interval buys nothing but wasted wake-ups. The shared drainShareQueue() routine posts each pending entry to your API exactly as the service worker would; because navigator.share() cannot run in a background context anyway, the foreground drain and the Chromium sync handler do identical server-side work, which is why a single implementation serves both.
Failure Modes and Recovery
| Symptom | Root cause | Fix |
|---|---|---|
TypeError: undefined is not an object (reg.sync) on iPhone |
Code assumes registration.sync exists |
Gate every call behind hasBackgroundSync() |
| Queue never drains on iOS, no errors | Waiting for a sync event that WebKit never fires |
Install visibilitychange + online foreground drain |
| Queue drains twice, duplicate server records | Both the SW sync handler and the foreground poll ran |
Run foreground drain only when hasBackgroundSync() is false |
| Drain fires but posts nothing | Triggered while navigator.onLine is false |
Check navigator.onLine inside every trigger before draining |
| Share never delivers until app reopened | Expected on iOS — no background execution | Communicate queued state in the UI; drain on pageshow/visibilitychange |
The duplicate-delivery row is the most common regression: teams add the iOS fallback but forget to gate it, so on Android both paths run. The requiresForegroundDrain() guard is what keeps the two mutually exclusive.
Browser and Platform Caveat
This fallback applies to every WebKit-backed browser: Safari on iOS and iPadOS, and Chrome, Edge, and Firefox on those platforms, since Apple mandates the WebKit engine for all of them. It also covers desktop Safari and Firefox, which likewise lack Background Sync. The hard limitation is that none of these can execute code while the page is backgrounded, so a queued share on iOS is delivered on the next foreground visit with connectivity — not while the phone is in the user’s pocket. Set user expectations accordingly and always surface the pending count, a pattern detailed in connectivity-aware share UX. For the Chromium side of this same design, where the sync event does the work automatically, see the parent guide on Background Sync for deferred shares.
FAQ
Does any iOS browser support Background Sync?
No. Apple requires every iOS browser to use the WebKit engine, and WebKit has never implemented the Background Sync API. Chrome, Edge, and Firefox on iPhone and iPad all report 'sync' in registration as false and can never fire a sync event, so the foreground polling fallback is the only reliable option across the entire platform.
Will the queue drain while the iOS app is backgrounded?
No. Without Background Sync, your code only runs while the page is in the foreground. The queue drains the next time the user brings the app to the front with a live connection, triggered by the visibilitychange or pageshow listener. There is genuinely no mechanism to deliver a queued share while the tab is fully backgrounded on iOS, so design the UX around foreground delivery.
Why does registration.sync.register() throw on iPhone?
Because registration.sync is undefined on WebKit, and calling .register() on undefined raises a TypeError. Always place the call behind the hasBackgroundSync() feature check so the missing API becomes a clean fallback branch instead of an unhandled exception that aborts your enqueue flow.