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);

      store.add(item);
    };

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

3. Register Background Sync Tag

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

async function registerSync(tag) {
  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() {
  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. 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 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 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, version) {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(name, version);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

function getAll(db, storeName) {
  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, storeName, id) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readwrite');
    tx.objectStore(storeName).delete(id);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.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 in Chrome.
  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: Test on Firefox or iOS Safari where SyncManager is absent. Verify the main-thread fallback executes only when navigator.onLine === true and respects retry limits.