Build a resilient, local-first offline synchronization engine for a municipal field-inspection application using browser-native IndexedDB and a durable Outbox Queue.
You will engineer a client-side architecture that:
Survives Complete Disconnection: Allows inspectors to conduct inspections, draft notes, and record safety violation verdicts in offline basements with 0ms local latency.
Maintains Transactional Integrity: Commits domain record updates and outbox synchronization commands atomically using IndexedDB transactions.
Recovers Seamlessly Across Browser Reboots: Persists queued operations across page reloads, tab crashes, and device restarts.
Guarantees Exactly-Once Server Processing: Enforces client-generated Idempotency-Key headers to prevent duplicate record creation during network dropouts and retries.
Classifies and Recovers from Failures: Distinguishes retriable transient errors (HTTP 503 / dropped sockets) with exponential backoff from permanent validation rejections (HTTP 422), surfacing actionable conflict states to the user.
Workspace Setup
Create a dedicated TypeScript practical directory:
Directly interacting with the callback-based indexedDB API is error-prone. Wrap database initialization and transactions in clean Promise boundaries:
// src/db.ts
import{FieldInspection,OutboxOperation}from'./types';constDB_NAME='MunicipalInspectionDB';constDB_VERSION=1;exportasyncfunctionopenInspectionDatabase():Promise<IDBDatabase>{returnnewPromise((resolve,reject)=>{constrequest=indexedDB.open(DB_NAME,DB_VERSION);request.onerror=()=>reject(request.error);request.onsuccess=()=>resolve(request.result);request.onupgradeneeded=(event)=>{constdb=(event.targetasIDBOpenDBRequest).result;// 1. Domain records store: indexed by localId
if(!db.objectStoreNames.contains('inspections')){constinspectionStore=db.createObjectStore('inspections',{keyPath:'localId'});inspectionStore.createIndex('syncStatus','syncStatus',{unique:false});inspectionStore.createIndex('serverId','serverId',{unique:false});}// 2. Durable Outbox store: indexed by operationId
if(!db.objectStoreNames.contains('outbox')){constoutboxStore=db.createObjectStore('outbox',{keyPath:'operationId'});outboxStore.createIndex('status','status',{unique:false});outboxStore.createIndex('createdAt','createdAt',{unique:false});}};});}
Requirement: Atomic Local Commit
When an inspector finishes a review, write both the updated inspection record and the new outbox command within the same atomic database transaction. If storage quota is exceeded or writing fails halfway through, the entire transaction rolls back, preventing orphaned outbox tasks:
Construct src/outboxEngine.ts. The synchronization engine continuously observes the outbox queue, executing items sequentially while respecting backoff rules:
// src/outboxEngine.ts
import{openInspectionDatabase}from'./db';import{OutboxOperation,ServerAcknowledgment}from'./types';exportclassOutboxEngine{privateisProcessing=false;constructor(privatereadonlymaxRetries=4,privatereadonlybaseDelayMs=500,privatereadonlymaxDelayMs=8000){}/**
* Drain pending outbox operations sequentially.
*/asyncprocessQueue(mockNetworkFetch:(op: OutboxOperation)=>Promise<ServerAcknowledgment>):Promise<void>{if(this.isProcessing)return;this.isProcessing=true;try{constdb=awaitopenInspectionDatabase();constoperations=awaitthis.getQueuedOperations(db);for(constopofoperations){// Enforce exponential backoff delay before re-attempting
if(op.attempts>0&&op.lastAttemptAt){constdelay=Math.min(this.maxDelayMs,this.baseDelayMs*Math.pow(2,op.attempts-1));constelapsed=Date.now()-op.lastAttemptAt;if(elapsed<delay){continue;// Not ready for next retry window
}}try{awaitthis.markOperationStatus(db,op.operationId,'syncing');constack=awaitmockNetworkFetch(op);// Success: finalize domain record and clear outbox entry
awaitthis.completeOperation(db,op.entityLocalId,op.operationId,ack);}catch(error: unknown){constisTransient=this.evaluateTransientError(error);constnextAttempts=op.attempts+1;if(isTransient&&nextAttempts<this.maxRetries){awaitthis.updateOperationRetry(db,op.operationId,nextAttempts,String(error));}else{// Permanent failure or retry limit exceeded
awaitthis.markOperationFailed(db,op.entityLocalId,op.operationId,String(error));}}}}finally{this.isProcessing=false;}}privateevaluateTransientError(error: unknown):boolean{consterrStr=String(error);// 5xx Server Errors and network drops are transient; 4xx are permanent
returnerrStr.includes('503')||errStr.includes('500')||errStr.includes('NetworkError');}// Database helper methods for status transitions...
privateasyncgetQueuedOperations(db: IDBDatabase):Promise<OutboxOperation[]>{returnnewPromise((resolve)=>{consttx=db.transaction('outbox','readonly');constrequest=tx.objectStore('outbox').getAll();request.onsuccess=()=>{constops=(request.resultasOutboxOperation[]).filter(o=>o.status==='queued'||o.status==='syncing').sort((a,b)=>a.createdAt-b.createdAt);resolve(ops);};});}privateasynccompleteOperation(db: IDBDatabase,localId: string,operationId: string,ack: ServerAcknowledgment):Promise<void>{returnnewPromise((resolve,reject)=>{consttx=db.transaction(['inspections','outbox'],'readwrite');tx.oncomplete=()=>resolve();tx.onerror=()=>reject(tx.error);// Remove from outbox
tx.objectStore('outbox').delete(operationId);// Reconcile domain record with server ID and synced status
constinspStore=tx.objectStore('inspections');constgetReq=inspStore.get(localId);getReq.onsuccess=()=>{if(getReq.result){inspStore.put({...getReq.result,serverId: ack.serverId,serverVersion: ack.serverVersion,syncStatus:'synced',});}};});}privateasyncupdateOperationRetry(db: IDBDatabase,operationId: string,attempts: number,errorMsg: string):Promise<void>{returnnewPromise((resolve,reject)=>{consttx=db.transaction('outbox','readwrite');tx.oncomplete=()=>resolve();tx.onerror=()=>reject(tx.error);conststore=tx.objectStore('outbox');constreq=store.get(operationId);req.onsuccess=()=>{if(req.result){store.put({...req.result,status:'queued',attempts,lastAttemptAt: Date.now(),lastError: errorMsg,});}};});}privateasyncmarkOperationStatus(db: IDBDatabase,operationId: string,status:'syncing'|'queued'):Promise<void>{returnnewPromise((resolve)=>{consttx=db.transaction('outbox','readwrite');conststore=tx.objectStore('outbox');constreq=store.get(operationId);req.onsuccess=()=>{if(req.result){store.put({...req.result,status});}};tx.oncomplete=()=>resolve();});}privateasyncmarkOperationFailed(db: IDBDatabase,localId: string,operationId: string,errorMsg: string):Promise<void>{returnnewPromise((resolve,reject)=>{consttx=db.transaction(['inspections','outbox'],'readwrite');tx.oncomplete=()=>resolve();tx.onerror=()=>reject(tx.error);constoutStore=tx.objectStore('outbox');constoutReq=outStore.get(operationId);outReq.onsuccess=()=>{if(outReq.result){outStore.put({...outReq.result,status:'failed',lastError: errorMsg});}};constinspStore=tx.objectStore('inspections');constinspReq=inspStore.get(localId);inspReq.onsuccess=()=>{if(inspReq.result){inspStore.put({...inspReq.result,syncStatus:'failed'});}};});}}
Stage 3: Connectivity Probing & Triggers
Never rely solely on window.addEventListener('online') or navigator.onLine. Browsers frequently report navigator.onLine = true when trapped behind a public Wi-Fi portal or when connected to a router with no internet uplink.
When an inspector reconciles an inspection report, the municipal server checks whether another supervisory officer modified the same record while the field inspector was offline:
Verification and Testing Matrix
Validate your implementation against these required failure and recovery test scenarios: