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);
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
- 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. - 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: Disable
SyncManagervia DevTools or test on iOS Safari. Verify the main-thread fallback executes only whennavigator.onLine === trueand respects retry limits.