Skip to content

Offline Mutation

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.

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)
  1. User triggers a mutation (create visit, submit form, etc.)
  2. System checks network status via onlineManager
  3. If offline: Operation is queued to MMKV outbox with pending status
  4. If online: Operation is sent to API immediately
  5. Background sync listens for reconnect events and flushes the outbox in FIFO order
  6. Failed operations retry with exponential backoff (max 5 attempts)
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:

  1. User triggers mutation (create visit, submit form, etc.)
  2. Check network status via onlineManager.isOnline()
  3. If offline: Queue to MMKV outbox
  4. If online: Send to API immediately
  5. On reconnect: Flush outbox in FIFO order
FieldTypeDescription
idstringUnique entry ID (UUID v4, also used as idempotency key)
typestringMutation type: CHECK_IN, CHECK_OUT, SUBMIT_FORM, LOCATION_BATCH
endpointstringFull API path (e.g., /api/app/v1/outlet-visits/123/check-in)
methodstringHTTP method: POST, PUT, PATCH
payloadJSONRequest body as Record<string, unknown>
created_atstringISO timestamp when queued
retry_countintegerNumber of retry attempts (starts at 0)
statusstringpending, retrying, or failed

Outbox is stored in MMKV under key: merq_offline_outbox

Data is stored as a JSON array of OutboxEntry objects:

interface OutboxStore {
entries: OutboxEntry[];
}
import { appStorage } from '@core/libs/storage';
const OUTBOX_KEY = 'merq_offline_outbox';
// Add to outbox
function 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 entries
function getOutbox(): OutboxEntry[] {
try {
const raw = appStorage.getString(OUTBOX_KEY);
if (!raw) return [];
return JSON.parse(raw) as OutboxEntry[];
} catch {
return [];
}
}
// Remove after success
function removeOutboxEntry(id: string): void {
const entries = getOutbox().filter(e => e.id !== id);
appStorage.set(OUTBOX_KEY, JSON.stringify(entries));
}
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.');
}
},
});
}

After successful sync from flushOutbox():

queryClient.invalidateQueries({
queryKey: ['outlet-visits'],
});
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 CountDelay
00s (immediate)
11s
22s
34s
48s
5+60s (cap)

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

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;
}
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,
});
}
}

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:

  1. Failed entries are removed first (oldest failure)
  2. If no failed entries: oldest pending entry is dropped

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

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();
};
}, []);
}
ScenarioMessage
Mutation queued offline”Saved locally. Will sync when online.”
Flush success”All changes synced”
Flush failed (max retries)“Failed to sync. Please check connection.”
function getPendingOutboxCount(): number {
return getOutbox().filter(
e => e.status === 'pending' || e.status === 'retrying'
).length;
}