Implementing Reliable Background Sync for Form Submissions

Problem Statement: Form Data Loss on Network Interruption

Standard fetch() calls terminate immediately upon network drops, tab closures, or SPA navigations. Unreliable client-side retry loops frequently trigger duplicate submissions or unhandled promise rejections, corrupting server state. Persistent state gaps require structured queueing before network restoration. For broader architectural context on state persistence, consult Offline Sync Strategies & Background Workflows.

Root Cause Analysis

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) {
 event.preventDefault();
 const form = event.target;

 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
 form.reset();
 form.querySelector('[type="submit"]').disabled = true;
 } catch (err) {
 console.error('Form interception failed:', err);
 // Fallback: re-enable submit or show error toast
 }
}

2. Queue Payload in IndexedDB

Use a dedicated object store with explicit transaction commits. Handle QuotaExceededError and InvalidStateError explicitly.

async function storeInQueue(item) {
 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.result;
 if (!db.objectStoreNames.contains(STORE_NAME)) {
 db.createObjectStore(STORE_NAME, { keyPath: 'id' });
 }
 };

 request.onsuccess = (e) => {
 const db = e.target.result;
 const tx = db.transaction(STORE_NAME, 'readwrite');
 const store = tx.objectStore(STORE_NAME);

 tx.oncomplete = () => resolve();
 tx.onerror = () => reject(tx.error);
 
 try {
 store.add(item);
 } catch (err) {
 if (err.name === 'QuotaExceededError') {
 console.warn('Storage quota exceeded. Purging oldest entries recommended.');
 reject(err);
 } else {
 reject(err);
 }
 }
 };

 request.onerror = (e) => reject(e.target.error);
 });
}

3. Register Background Sync Tag

Request sync permission with capability detection. Implement a fallback to immediate fetch for browsers lacking SyncManager (e.g., iOS Safari).

async function registerSync(tag) {
 // Check for Background Sync API support
 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
 await processQueueFallback();
 }
}

async function processQueueFallback() {
 if (!navigator.onLine) {
 console.warn('Offline. Fallback queue processing deferred until online.');
 return;
 }
 // Import or inline the SW queue logic for main-thread execution
 await import('./sync-processor.js').then(m => m.processQueue());
}

4. Service Worker Sync Handler

Listen for the sync event. Drain the queue sequentially with explicit retry logic and exponential backoff. Tag lifecycle management is detailed in Background Sync API Implementation.

// sw.js
self.addEventListener('sync', (event) => {
 if (event.tag === 'form-sync') {
 event.waitUntil(processQueue());
 }
});

async function processQueue() {
 const DB_NAME = 'sync-queue';
 const STORE_NAME = 'pending';
 const MAX_RETRIES = 3;

 const db = await openDB(DB_NAME, 1);
 const items = await db.getAll(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 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 success
 await db.delete(STORE_NAME, item.id);
 } catch (err) {
 console.warn(`Attempt ${attempt + 1} failed for ${item.id}:`, err);
 attempt++;
 if (attempt < MAX_RETRIES) {
 // Exponential backoff: 2s, 4s, 8s
 await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
 } else {
 console.error(`Max retries reached for ${item.id}. Item remains in queue.`);
 }
 }
 }
 }
}

// Helper to reuse IDB opening in SW context
function openDB(name, version) {
 return new Promise((resolve, reject) => {
 const req = indexedDB.open(name, version);
 req.onsuccess = () => resolve(req.result);
 req.onerror = () => reject(req.error);
 });
}

Validation & Testing Protocol

  1. Offline Simulation: Open DevTools > Network > set to Offline. Submit the form. Verify IndexedDB > sync-queue > pending contains the serialized payload with a valid UUID.
  2. Network Restoration: Switch back to Online. Manually trigger navigator.serviceWorker.ready.then(r => r.sync.register('form-sync')) or wait for the automatic event.
  3. Execution Monitoring: Inspect the Service Worker console. Confirm processQueue executes, logs successful 200 OK responses, and removes processed items from the pending store.
  4. Idempotency Verification: Check server logs or database constraints. Replaying the same Idempotency-Key must return 200 OK or 409 Conflict without creating duplicate records.
  5. Fallback Audit: Disable SyncManager via DevTools or test on iOS Safari. Verify the main-thread fallback executes only when navigator.onLine === true and respects retry limits.