Skip to content

Offline Mode and Sync

Neemias adopts a backend-primary, offline-resilient architecture: the backend is the definitive source of truth, but the frontend works fully offline using IndexedDB as an operational cache.

Architecture

┌──────────────┐     online      ┌──────────────┐
│   IndexedDB  │ ←──────────────→│  D1 (Cloudflare) │
│  (Dexie.js)  │   sync queue    │   Workers API    │
└──────────────┘                 └──────────────┘
      │                                │
  useLiveQuery                    Event Sourcing
      ↓                                ↓
   React UI                      Audit Logs

When offline, all mutations are recorded locally in the syncQueue and sent to the backend when connectivity is restored.

OfflineContext

OfflineContext (app/src/app/context/OfflineContext.tsx) is the heart of offline mode. It exposes:

PropertyTypeDescription
isOnlinebooleanCurrent connectivity state (navigator.onLine)
pendingCountnumberItems pending sync (PENDING + RETRYING)
pendingEntriesSyncQueueEntry[]Full list of pending entries
autoSyncingbooleanWhether automatic draining is in progress
markAllSynced()() => Promise<void>Marks all as synced (used when there is no backend)

Connectivity Detection

The provider reacts to window online and offline events:

typescript
window.addEventListener("online", () => setIsOnline(true));
window.addEventListener("offline", () => setIsOnline(false));

When it detects that the device has come back online, the retry counter is reset to zero.

Sync Queue (syncQueueRepository)

The repository manages the syncQueue table in Dexie with the following operations:

  • listPending() — lists entries with syncState: "PENDING" or "RETRYING"
  • drainQueue(processor) — processes each pending entry, calling syncQueueEntryWithBackend()
  • markAllSynced() — marks all as synced (used when BACKEND_URL is not configured)
  • purgeSyncedEvents(days) — removes synced events older than N days (quota management)

Exponential Backoff

In case of sync failure, the system applies exponential backoff:

AttemptDelay
05 seconds
110 seconds
220 seconds
330 seconds
4+30 seconds (cap)

After 6 attempts (MAX_RETRY_ATTEMPTS), the system stops trying automatically — the user can force a new attempt by toggling the connectivity state.

Expired Session During Sync

If the backend returns an authentication error (SyncAuthExpiredError), the context attempts to renew the token via refreshAccessToken(). If renewal fails, the session is marked as expired and sync is interrupted.

No Backend Configured

When the BACKEND_URL environment variable is empty, automatic draining simply marks all items as locally synced — useful for offline development and testing.

StatusIndicator

The StatusIndicator component displays the current sync state in the interface:

  • Online + empty queue — green indicator
  • Online + syncing — animated indicator with pending count
  • Offline — gray/yellow indicator

Conflict Resolution

During sync, conflicts between local and remote events are resolved on the server by the EventReplay class with two strategies:

StrategyDescription
ADMIN_PRECEDENCEEvents from roles with higher weight (ADMIN > CADASTRO > CHAMADOR > RELATORIOS) prevail
LAST_TIMESTAMP_WINSIn case of a role tie, the event with the most recent timestamp wins

The losing event is marked with isConflictLoser: true and the conflictSupersededBy field points to the winning event.

Full Lifecycle

  1. User performs offline action → EventWriter records event + syncQueue entry (atomic transaction)
  2. UI reacts immediately via useLiveQuery
  3. Upon reconnection → OfflineContext detects online and fires drainQueue()
  4. Each entry is sent via POST /api/v1/sync/events with Idempotency-Key header
  5. Backend processes, resolves conflicts, and returns confirmation
  6. Entry marked as SYNCED or RETRYING (in case of transient failure)
  7. Every 30 days, old synced entries are purged

Source: app/CONTEXT.md + OfflineContext.tsx

Distributed under MIT License.