I with built-in indexing and live query support. We define a schema that tracks work orders, their payload, and synchronization state.
// lib/fieldSyncDb.ts
import Dexie, { Table } from 'dexie';
export interface WorkOrder {
id?: number;
stationId: string;
payload: Record<string, unknown>;
createdAt: number;
syncStatus: 'queued' | 'syncing' | 'confirmed' | 'failed';
retryCount: number;
}
export class FieldSyncDB extends Dexie {
workOrders!: Table<WorkOrder>;
constructor() {
super('FieldSyncDB');
this.version(1).stores({
workOrders: '++id, stationId, syncStatus, createdAt'
});
}
}
export const db = new FieldSyncDB();
Architecture Rationale:
stationId is indexed to enable fast filtering by location or device.
syncStatus allows the UI to reactively display pending operations.
retryCount enables exponential backoff logic without external state management.
- Dexie's versioning system ensures safe schema migrations as your data model evolves.
Step 2: Optimistic Write Interface
When a user submits a form, the application writes directly to IndexedDB. This operation completes in milliseconds, regardless of network state. The UI immediately reflects success, and the record enters the synchronization queue.
// hooks/useWorkOrderSubmit.ts
import { useCallback } from 'react';
import { db, WorkOrder } from '@/lib/fieldSyncDb';
export function useWorkOrderSubmit() {
const submitOrder = useCallback(async (stationId: string, payload: Record<string, unknown>) => {
const record: Omit<WorkOrder, 'id'> = {
stationId,
payload,
createdAt: Date.now(),
syncStatus: 'queued',
retryCount: 0
};
await db.workOrders.add(record);
return true;
}, []);
return { submitOrder };
}
Architecture Rationale:
- Separating the write logic into a custom hook keeps components clean and testable.
- Using
Omit<WorkOrder, 'id'> enforces type safety while allowing Dexie to auto-generate the primary key.
- The function returns immediately after local persistence, enabling optimistic UI updates without blocking on network calls.
Step 3: Background Synchronization Engine
A synchronization engine continuously monitors the queue and pushes confirmed records to the backend. It handles network state detection, retry logic, and status updates.
// lib/syncEngine.ts
import { db } from '@/lib/fieldSyncDb';
const MAX_RETRIES = 5;
const BACKOFF_BASE = 1000;
async function syncQueue() {
if (!navigator.onLine) return;
const pending = await db.workOrders
.where('syncStatus')
.equals('queued')
.or('failed')
.sortBy('createdAt');
for (const record of pending) {
if (record.retryCount >= MAX_RETRIES) {
await db.workOrders.update(record.id!, { syncStatus: 'failed' });
continue;
}
await db.workOrders.update(record.id!, { syncStatus: 'syncing' });
try {
const response = await fetch('/api/work-orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(record.payload)
});
if (!response.ok) throw new Error('Server rejected payload');
await db.workOrders.update(record.id!, { syncStatus: 'confirmed' });
} catch {
await db.workOrders.update(record.id!, {
syncStatus: 'failed',
retryCount: record.retryCount + 1
});
}
}
}
export { syncQueue };
Architecture Rationale:
- The engine filters by
queued or failed status, ensuring only unresolved records are processed.
retryCount prevents infinite retry loops on permanently broken endpoints.
- Status transitions (
queued β syncing β confirmed/failed) provide granular UI feedback.
- The engine is designed to be triggered by network events, intervals, or service workers.
Step 4: Reactive UI and Status Awareness
Dexie's useLiveQuery hook bridges the local database and React's rendering cycle. Components automatically re-render when queue state changes, enabling real-time sync indicators without manual state synchronization.
// components/SyncStatusIndicator.tsx
"use client";
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/fieldSyncDb';
export function SyncStatusIndicator() {
const pendingCount = useLiveQuery(
() => db.workOrders.where('syncStatus').anyOf(['queued', 'syncing', 'failed']).count()
);
if (!pendingCount || pendingCount === 0) return null;
const statusLabel = pendingCount > 0 ? `${pendingCount} record(s) pending sync` : 'All synced';
const colorClass = pendingCount > 0 ? 'text-amber-700 bg-amber-50' : 'text-emerald-700 bg-emerald-50';
return (
<div className={`px-3 py-1.5 rounded-md text-sm font-medium ${colorClass}`}>
{statusLabel}
</div>
);
}
Architecture Rationale:
anyOf efficiently queries multiple statuses in a single indexed lookup.
- The component remains lightweight and only renders when the count changes.
- Visual feedback reduces user anxiety about data persistence and provides clear operational transparency.
Pitfall Guide
1. Ignoring Schema Versioning
Explanation: Raw IndexedDB requires manual version bumps for schema changes. Dexie simplifies this, but developers often skip version increments when adding fields, causing silent migration failures or corrupted stores.
Fix: Always increment this.version(n) when modifying stores. Use .stores() to declare new indexes, and test migrations in isolated browser profiles before deployment.
2. Blocking the Main Thread with Large Payloads
Explanation: Serializing massive JSON objects directly into IndexedDB can trigger main-thread jank, especially on low-end devices.
Fix: Chunk large payloads or use structuredClone before insertion. Consider Web Workers for heavy serialization, and monitor performance.now() during write operations.
3. Assuming Linear Sync Order
Explanation: Network conditions cause out-of-order arrivals. If two devices submit updates to the same entity, naive last-write-wins logic can overwrite critical changes.
Fix: Implement vector clocks or operational transformation for multi-device scenarios. For single-user workflows, attach a lastModified timestamp and validate against server-side revision numbers.
4. Missing Conflict Resolution Strategy
Explanation: Sync failures often stem from schema mismatches or server-side validation rejections. Without explicit conflict handling, records stall in failed state indefinitely.
Fix: Log rejection reasons, expose a manual retry UI, and implement server-side idempotency keys to prevent duplicate processing during retries.
5. Neglecting Storage Quota Management
Explanation: Browsers enforce storage limits (typically 50-80% of available disk space). Unbounded queue growth can trigger QuotaExceededError, crashing the sync engine.
Fix: Monitor navigator.storage.estimate(). Implement a retention policy that archives or compresses confirmed records older than a configurable threshold.
6. Overlooking Offline/Online Event Debouncing
Explanation: Network flapping triggers rapid online/offline events, causing the sync engine to fire repeatedly and waste bandwidth.
Fix: Debounce network state changes using a 2-3 second delay. Verify actual connectivity with a lightweight HEAD request before initiating queue processing.
7. Forgetting to Purge Resolved Records
Explanation: Confirmed records accumulate indefinitely, bloating the database and slowing indexed queries.
Fix: Schedule a periodic cleanup job that removes confirmed records older than 7-14 days, or move them to an archive table for audit compliance.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-user field data entry | Local-First (IndexedDB + Dexie) | Guarantees zero data loss, instant writes, simple sync queue | Low infrastructure cost, moderate frontend complexity |
| Multi-user collaborative editing | CRDTs / Operational Transform | Handles concurrent edits without central lock | High implementation cost, requires specialized libraries |
| Read-heavy dashboards | Cloud-First + SW Cache | Reduces local storage footprint, leverages CDN | Low frontend cost, higher API bandwidth |
| Compliance/Audit requirements | Local-First + Server Archive | Maintains offline resilience while satisfying retention policies | Moderate storage cost, requires archival pipeline |
Configuration Template
// lib/syncConfig.ts
export const SYNC_CONFIG = {
maxRetries: 5,
backoffMultiplier: 1.5,
initialDelayMs: 1000,
cleanupThresholdDays: 14,
networkCheckUrl: '/api/health',
networkCheckTimeoutMs: 3000,
storageWarningPercent: 75,
enabled: true
};
// lib/syncScheduler.ts
import { syncQueue } from './syncEngine';
import { SYNC_CONFIG } from './syncConfig';
let syncInterval: NodeJS.Timeout | null = null;
export function startSyncScheduler() {
if (!SYNC_CONFIG.enabled) return;
window.addEventListener('online', () => {
setTimeout(syncQueue, 2000);
});
syncInterval = setInterval(() => {
if (navigator.onLine) syncQueue();
}, 30000);
}
export function stopSyncScheduler() {
if (syncInterval) clearInterval(syncInterval);
}
Quick Start Guide
- Install Dependencies: Run
npm install dexie dexie-react-hooks in your Next.js project.
- Initialize Database: Create
lib/fieldSyncDb.ts with your schema definition and export a singleton instance.
- Wire Optimistic Writes: Replace direct
fetch() calls in your forms with db.workOrders.add() and trigger the sync engine afterward.
- Add Status UI: Import
useLiveQuery into a header or sidebar component to display pending sync counts.
- Test Resilience: Open Chrome DevTools β Network tab β set throttling to "Offline". Submit a form, verify local persistence, restore connectivity, and confirm background sync completes.