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
- Lifecycle Volatility: JavaScript execution halts on tab unload, dropping pending HTTP payloads without transactional persistence.
- Missing Idempotency: Form payloads lack deterministic keys, causing server-side duplication when clients retry blindly.
- SW/IDB Boundary Misalignment: Service Worker
syncevents often execute outside IndexedDB transaction boundaries, resulting in partial queue drains or orphaned records on failure.
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
- Offline Simulation: Open DevTools > Network > set to
Offline. Submit the form. VerifyIndexedDB>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 the automatic event in Chrome. - Execution Monitoring: Inspect the Service Worker console. Confirm
processQueueexecutes, 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 retry limits.