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.

Reliable form submission state machine A state flow showing a form moving from intercepted to queued to syncing, then either confirmed and removed or retried on failure. Intercept preventDefault Queued IndexedDB + UUID Syncing sync event Confirmed 2xx, delete record Retry / hold backoff, keep in queue

Root Cause Analysis

Three failure modes combine to lose form data:

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

  1. Offline simulation. Open DevTools > Network and set throttling to Offline. Submit the form. Verify Application > 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 Chrome’s automatic event.
  3. Execution monitoring. Inspect the service worker console. Confirm processQueue runs, 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 the retry limit.

Edge Cases & Fallbacks

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