Implementing Reliable Background Sync for Form Submissions
Standard fetch() calls terminate immediately on a network drop, tab closure, or single-page-app navigation, and naive client-side retry loops frequently trigger duplicate submissions or unhandled promise rejections that corrupt server state. The durable fix is to persist the submission before any network attempt, then let the browser drive delivery. This page is the practical companion to the Background Sync API Implementation guide, and for the wider architectural picture it lives within Offline Sync Strategies & Background Workflows.
Root Cause Analysis
Three failure modes combine to lose form data:
- Lifecycle volatility. JavaScript execution halts on tab unload, dropping pending HTTP payloads that were never written to durable storage. The HTML standard makes no promise that an in-flight
fetchsurvives navigation. - Missing idempotency. Form payloads lacking deterministic keys cause server-side duplication when a client retries blindly, because the server cannot tell a replay from a genuinely new submission.
- Service-worker / IndexedDB boundary misalignment. A
syncevent often executes outside the IndexedDB transaction boundary that queued the record, resulting in partial queue drains or orphaned records when delivery fails partway through.
The fix addresses all three: write the payload inside a committed transaction first, attach a UUID at creation, and only delete a record after a confirmed 2xx. Tag lifecycle and the underlying API surface are covered in Background Sync API Implementation.
Step-by-Step Implementation
1. Intercept & Serialize Form Data
Prevent native submission, extract structured data, and attach a cryptographically secure UUID for idempotency.
async function interceptFormSubmit(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = event.target as HTMLFormElement;
try {
const payload = {
id: crypto.randomUUID(),
data: Object.fromEntries(new FormData(form)),
timestamp: Date.now(),
};
await storeInQueue(payload);
await registerSync('form-sync');
// Provide immediate UI feedback — the submission is durably queued.
form.reset();
(form.querySelector('[type="submit"]') as HTMLButtonElement).disabled = true;
} catch (err) {
console.error('Form interception failed:', err);
// Fallback: re-enable submit or show an error toast.
}
}
2. Queue Payload in IndexedDB
Use a dedicated object store with explicit transaction commits. Handle QuotaExceededError and InvalidStateError explicitly.
function storeInQueue(item: { id: string; data: unknown; timestamp: number }): Promise<void> {
const DB_NAME = 'sync-queue';
const STORE_NAME = 'pending';
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
store.add(item);
};
request.onerror = (e) => reject((e.target as IDBOpenDBRequest).error);
});
}
3. Register Background Sync Tag
Request sync with capability detection. Fall back to an immediate foreground drain for browsers lacking SyncManager (Firefox, Safari).
async function registerSync(tag: string): Promise<void> {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
try {
const reg = await navigator.serviceWorker.ready;
await reg.sync.register(tag);
} catch (err) {
console.warn('Sync registration failed, queuing fallback:', err);
await processQueueFallback();
}
} else {
// Fallback for unsupported environments (Firefox, Safari, etc.).
await processQueueFallback();
}
}
async function processQueueFallback(): Promise<void> {
if (!navigator.onLine) {
console.warn('Offline. Fallback queue processing deferred until online.');
window.addEventListener('online', processQueueFallback, { once: true });
return;
}
// Import or inline the SW queue logic for main-thread execution.
const { processQueue } = await import('./sync-processor.js');
await processQueue();
}
4. Drain the Queue in the Service Worker
Listen for the sync event and drain the queue sequentially with explicit retry logic and exponential backoff. If several tabs share the tag, serialize the drain with the Web Locks API for Cross-Tab Coordination so two contexts never submit the same record.
// sw.js
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'form-sync') {
event.waitUntil(processQueue());
}
});
async function processQueue(): Promise<void> {
const DB_NAME = 'sync-queue';
const STORE_NAME = 'pending';
const MAX_RETRIES = 3;
const db = await openDB(DB_NAME, 1);
const items = await getAll(db, STORE_NAME);
for (const item of items) {
let attempt = 0;
let success = false;
while (attempt < MAX_RETRIES && !success) {
try {
const res = await fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.id,
},
body: JSON.stringify(item.data),
});
if (!res.ok) {
// 4xx errors are usually fatal; 5xx warrant a retry.
if (res.status >= 400 && res.status < 500) {
throw new Error(`Client error ${res.status}: ${res.statusText}`);
}
throw new Error(`Server error ${res.status}`);
}
success = true;
// Remove only on confirmed success.
await deleteRecord(db, STORE_NAME, item.id);
} catch (err) {
console.warn(`Attempt ${attempt + 1} failed for ${item.id}:`, err);
attempt++;
if (attempt < MAX_RETRIES) {
// Exponential backoff: 2 s, 4 s, 8 s.
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
} else {
console.error(`Max retries reached for ${item.id}. Item remains in queue.`);
}
}
}
}
}
// Minimal native IDB helpers (no wrapper library needed in SW context).
function openDB(name: string, version: number): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(name, version);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function getAll(db: IDBDatabase, storeName: string): Promise<any[]> {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const req = tx.objectStore(storeName).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function deleteRecord(db: IDBDatabase, storeName: string, id: string): Promise<void> {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
The Idempotency-Key header is the linchpin: the server keys de-duplication on it, so a replayed submission returns the original result instead of creating a second record. This is the same idempotency contract described in Background Sync API Implementation, applied to a concrete form endpoint.
Validation & Testing Protocol
- Offline simulation. Open DevTools > Network and set throttling to
Offline. Submit the form. VerifyApplication > IndexedDB > sync-queue > pendingcontains the serialized payload with a valid UUID. - Network restoration. Switch back to
Online. Manually triggernavigator.serviceWorker.ready.then((r) => r.sync.register('form-sync'))or wait for Chrome’s automatic event. - Execution monitoring. Inspect the service worker console. Confirm
processQueueruns, logs successful200 OKresponses, and removes processed items from thependingstore. - Idempotency verification. Check server logs or database constraints. Replaying the same
Idempotency-Keymust return200 OKor409 Conflictwithout creating duplicate records. - Fallback audit. Test on Firefox or iOS Safari where
SyncManageris absent. Verify the main-thread fallback executes only whennavigator.onLine === trueand respects the retry limit.
Edge Cases & Fallbacks
- No
SyncManager(Firefox, Safari). Step 3 already routes these toprocessQueueFallback, which drains from the main thread onceonline. Because iOS Safari may also evict the queue store under Intelligent Tracking Prevention, design the server to accept a re-submission cleanly rather than assuming the local queue is durable for days. - Concurrent drains. Without a lock, a foreground fallback and a service-worker
synccan fire together and double-post a record before either deletes it. Wrap the drain in a Web Locks request so the second caller waits or bails — the idempotency key is the safety net, the lock is the prevention. - Permanently failing records. A
4xxthat is not a transient validation hiccup will retry to the limit and then sit in the queue forever. Track an attempt counter on each record and move records past a threshold into a dead-letter store so they no longer block the drain.
Frequently Asked Questions
What happens to a queued submission if the user closes the tab?
Nothing is lost. The payload was committed to IndexedDB in step 2 before any network attempt, and the registered sync tag persists in the browser. When connectivity returns, the browser wakes the service worker and processQueue drains the record even though the original tab is gone.
How do I stop the same form from being submitted twice?
Send a stable per-submission UUID as an Idempotency-Key so the server deduplicates replays, and serialize the drain with the Web Locks API for Cross-Tab Coordination so two tabs or a foreground fallback cannot post the same record concurrently.
Does this work without the Background Sync API at all?
Yes. The IndexedDB queue is the source of truth; background sync is only one of the triggers that drains it. On Firefox and Safari the processQueueFallback path drains the same queue from the main thread on the online event, so submissions still survive an offline period. See Background Sync API Implementation for the full fallback matrix.
Related
- Background Sync API Implementation — the parent guide covering tags, the sync event, quotas, and fallbacks.
- Periodic Background Sync vs One-Off Sync — choose the right sync primitive for one-shot form flushes versus recurring refreshes.
- Web Locks API for Cross-Tab Coordination — prevent two tabs from draining and double-posting the same queued submission.
- Conflict Resolution Algorithms — reconcile state when queued submissions reach the server out of order.
- Offline Sync Strategies & Background Workflows — the parent guide for offline durability and sync architecture.