Offline Mutation
Overview
Section titled “Overview”The offline mutation outbox system enables the Merq mobile app to handle mutations (create, update, delete operations) when the device is offline. Instead of failing immediately, mutations are queued to a local MMKV storage and automatically synchronized when connectivity is restored.
Why Needed
Section titled “Why Needed”Field force users operate in areas with unreliable network coverage. The outbox system ensures:
- No data loss — Mutations are stored locally when offline
- Seamless UX — Users receive immediate feedback even without connectivity
- Automatic sync — Queued operations flush on reconnect without user intervention
- Causality preservation — Operations maintain order (e.g., check-in before check-out)
How It Works
Section titled “How It Works”- User triggers a mutation (create visit, submit form, etc.)
- System checks network status via
onlineManager - If offline: Operation is queued to MMKV outbox with
pendingstatus - If online: Operation is sent to API immediately
- Background sync listens for reconnect events and flushes the outbox in FIFO order
- Failed operations retry with exponential backoff (max 5 attempts)
Architecture
Section titled “Architecture”sequenceDiagram
participant UI as UI Component
participant Hook as Mutation Hook
participant Outbox as Outbox Store
participant API as Backend API
UI->>Hook: mutate(data)
alt Offline
Hook->>Outbox: queueOperation(type, data)
Outbox-->>UI: Queued for sync
else Online
Hook->>API: POST/PUT/DELETE
API-->>Hook: Success
end
Note over Outbox,API: Background sync on reconnect
Flow:
- User triggers mutation (create visit, submit form, etc.)
- Check network status via
onlineManager.isOnline() - If offline: Queue to MMKV outbox
- If online: Send to API immediately
- On reconnect: Flush outbox in FIFO order
Data Model
Section titled “Data Model”OutboxEntry
Section titled “OutboxEntry”| Field | Type | Description |
|---|---|---|
| id | string | Unique entry ID (UUID v4, also used as idempotency key) |
| type | string | Mutation type: CHECK_IN, CHECK_OUT, SUBMIT_FORM, LOCATION_BATCH |
| endpoint | string | Full API path (e.g., /api/app/v1/outlet-visits/123/check-in) |
| method | string | HTTP method: POST, PUT, PATCH |
| payload | JSON | Request body as Record<string, unknown> |
| created_at | string | ISO timestamp when queued |
| retry_count | integer | Number of retry attempts (starts at 0) |
| status | string | pending, retrying, or failed |
Storage
Section titled “Storage”MMKV Key
Section titled “MMKV Key”Outbox is stored in MMKV under key: merq_offline_outbox
Storage Structure
Section titled “Storage Structure”Data is stored as a JSON array of OutboxEntry objects:
interface OutboxStore { entries: OutboxEntry[];}Operations
Section titled “Operations”import { appStorage } from '@core/libs/storage';
const OUTBOX_KEY = 'merq_offline_outbox';
// Add to outboxfunction addOutboxEntry( entry: Omit<OutboxEntry, 'id' | 'created_at' | 'retry_count' | 'status'>,): OutboxEntry { const entries = getOutbox();
// Evict oldest if at capacity (max 500) if (entries.length >= MAX_QUEUE_SIZE) { const failedIdx = entries.findIndex(e => e.status === 'failed'); if (failedIdx !== -1) { entries.splice(failedIdx, 1); } else { entries.shift(); // Drop oldest pending } }
const newEntry: OutboxEntry = { ...entry, id: generateUUID(), created_at: new Date().toISOString(), retry_count: 0, status: 'pending', };
entries.push(newEntry); appStorage.set(OUTBOX_KEY, JSON.stringify(entries)); return newEntry;}
// Get all entriesfunction getOutbox(): OutboxEntry[] { try { const raw = appStorage.getString(OUTBOX_KEY); if (!raw) return []; return JSON.parse(raw) as OutboxEntry[]; } catch { return []; }}
// Remove after successfunction removeOutboxEntry(id: string): void { const entries = getOutbox().filter(e => e.id !== id); appStorage.set(OUTBOX_KEY, JSON.stringify(entries));}Integration with TanStack Query
Section titled “Integration with TanStack Query”Mutation Hook Pattern
Section titled “Mutation Hook Pattern”export function useCreateVisitMutation() { const queryClient = useQueryClient(); const { isOnline } = useNetwork();
return useMutation({ mutationFn: async (data: CreateVisitPayload) => { // Check if offline if (!isOnline) { // Queue to outbox await addOutboxEntry({ type: 'CHECK_IN', endpoint: '/app/v1/outlet-visits/check-in', method: 'POST', payload: data, }); return { queued: true }; }
// Send to API return visitService.create(data); },
onSuccess: (response) => { if (!response.queued) { // Invalidate queries queryClient.invalidateQueries({ queryKey: ['visits'] }); showToast('Visit created successfully'); } else { showToast('Saved locally. Will sync when online.'); } }, });}Query Invalidation
Section titled “Query Invalidation”After successful sync from flushOutbox():
queryClient.invalidateQueries({ queryKey: ['outlet-visits'],});Retry Logic
Section titled “Retry Logic”Exponential Backoff
Section titled “Exponential Backoff”function getRetryDelay(retryCount: number): number { // Exponential backoff: 1s, 2s, 4s, 8s, 16s — capped at 60s return Math.min(Math.pow(2, retryCount) * 1000, 60_000);}Delay progression:
| Retry Count | Delay |
|---|---|
| 0 | 0s (immediate) |
| 1 | 1s |
| 2 | 2s |
| 3 | 4s |
| 4 | 8s |
| 5+ | 60s (cap) |
Max Retries
Section titled “Max Retries”Default: 5 attempts
After max retries exceeded:
- Entry status set to
failed - Entry remains in outbox (not removed)
- User can manually retry or clear failed entries
Non-Retryable Errors
Section titled “Non-Retryable Errors”HTTP 4xx errors (except 429 Too Many Requests) are marked as failed immediately without retry:
if (status && status >= 400 && status < 500 && status !== 429) { updateOutboxEntry(entry.id, { status: 'failed' }); return;}Flush Loop
Section titled “Flush Loop”async function flushOutbox(): Promise<void> { const pending = getPendingOutboxEntries(); if (pending.length === 0) return;
Logger.log('[Outbox] Flushing', pending.length, 'entries');
// Sequential processing to preserve causality for (const entry of pending) { await flushEntry(entry); }}
async function flushEntry(entry: OutboxEntry): Promise<void> { if (shouldSkipDueToBackoff(entry)) { return; }
updateOutboxEntry(entry.id, { status: 'retrying' });
try { await apiClient.request({ method: entry.method, url: entry.endpoint, data: entry.payload, headers: { 'X-Idempotency-Key': entry.id, // Use UUID as idempotency key }, });
removeOutboxEntry(entry.id); // Success: remove from outbox } catch (err: any) { const newRetryCount = entry.retry_count + 1; if (newRetryCount >= MAX_RETRIES) { updateOutboxEntry(entry.id, { status: 'failed', retry_count: newRetryCount }); return; }
updateOutboxEntry(entry.id, { status: 'pending', retry_count: newRetryCount, }); }}Eviction Policy
Section titled “Eviction Policy”Max Queue Size
Section titled “Max Queue Size”Limit: 500 entries
When capacity is reached:
if (entries.length >= MAX_QUEUE_SIZE) { const failedIdx = entries.findIndex(e => e.status === 'failed'); if (failedIdx !== -1) { entries.splice(failedIdx, 1); // Remove failed entry first } else { entries.shift(); // Drop oldest pending (FIFO) }}Eviction priority:
- Failed entries are removed first (oldest failure)
- If no failed entries: oldest pending entry is dropped
No Time-Based Eviction
Section titled “No Time-Based Eviction”Unlike some implementations, the Merq outbox does not automatically evict entries based on age. Entries remain until:
- Successfully synced
- Max retries exceeded (marked as
failed) - Manually cleared by user/admin
- Evicted due to queue capacity
User Feedback
Section titled “User Feedback”Outbox Sync Hook
Section titled “Outbox Sync Hook”The useOutboxSync hook runs in the app root and triggers flush on:
- Network reconnect (
onlineManager.isOnline()) - App foreground (
AppState === 'active') - Initial mount
export function useOutboxSync(): void { const isFlushing = useRef(false);
useEffect(() => { const unsubOnline = onlineManager.subscribe(() => { if (onlineManager.isOnline()) { triggerFlush(); } });
const appStateSub = AppState.addEventListener('change', (state) => { if (state === 'active') { triggerFlush(); } });
triggerFlush(); // Initial flush on mount
return () => { unsubOnline(); appStateSub.remove(); }; }, []);}Toast Messages
Section titled “Toast Messages”| Scenario | Message |
|---|---|
| Mutation queued offline | ”Saved locally. Will sync when online.” |
| Flush success | ”All changes synced” |
| Flush failed (max retries) | “Failed to sync. Please check connection.” |
Pending Count Indicator
Section titled “Pending Count Indicator”function getPendingOutboxCount(): number { return getOutbox().filter( e => e.status === 'pending' || e.status === 'retrying' ).length;}Related Topics
Section titled “Related Topics”- Offline-First Architecture
- Location Tracking (uses same outbox for batch uploads)
- Idempotency (prevents duplicate mutations via X-Idempotency-Key header)