Skip to content

Offline-First Strategy

The Merq mobile app is designed with an offline-first approach to ensure field teams can work without a stable internet connection.

Field merchandisers and sales teams often work in:

  • Retail stores with weak WiFi
  • Remote areas with limited coverage
  • Underground malls with poor signal
  • Environments with unreliable connectivity

Offline-first ensures:

  • ✅ No data loss
  • ✅ Continuous productivity
  • ✅ Better user experience
  • ✅ Reduced server load
┌──────────────────────────────────┐
│ UI Components │
│ (Screens & Components) │
└────────────┬─────────────────────┘
┌────────────▼─────────────────────┐
│ TanStack Query Cache │
│ (Memory + Persistence) │
│ networkMode: "offlineFirst" │
└────────────┬─────────────────────┘
┌─────┴─────┐
│ │
┌───▼──┐ ┌──▼────┐
│ MMKV │ │ API │
│Storage│ │Service│
└──────┘ └───┬───┘
┌──────▼──────┐
│ Network? │
└──────┬──────┘
┌────────┴────────┐
│ │
Online Offline
│ │
┌─────▼─────┐ ┌─────▼─────┐
│ Backend │ │ Queue │
│ API │ │ Request │
└───────────┘ └───────────┘

Why MMKV?

  • 10x faster than AsyncStorage
  • Synchronous API (no async overhead)
  • Built-in encryption
  • Small footprint (~50KB)
  • Cross-platform (iOS + Android)

Usage:

import { storage } from '@core/storage';
// Save data
storage.set('user_profile', JSON.stringify(profile));
// Read data
const cached = storage.getString('user_profile');
const profile = cached ? JSON.parse(cached) : null;
// Delete data
storage.delete('user_profile');
// Clear all
storage.clearAll();

offlineFirst Network Mode:

export function useOutletsQuery(params: OutletParams) {
const profile = useAtomValue(profileAtom);
return useQuery({
queryKey: ['outlets', params],
queryFn: () => outletService.getList(params),
// CRITICAL: offlineFirst network mode
networkMode: 'offlineFirst',
// Cache for 5 minutes when online
staleTime: 5 * 60 * 1000,
// Cache indefinitely when offline
cacheTime: Infinity,
// Don't fetch until authenticated
enabled: !!profile?.id,
});
}

Network Modes Explained:

ModeBehavior
online (default)Only fetch when online, fail when offline
alwaysAlways try to fetch, ignore network status
offlineFirstReturn cache first, sync in background when online
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createMMKVPersister } from '@core/storage/queryPersister';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'offlineFirst',
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
const persister = createMMKVPersister();
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
{/* App content */}
</PersistQueryClientProvider>
);
}

MMKV Persister Implementation:

src/core/storage/queryPersister.ts
import { storage } from './storage';
import { PersistedClient, Persister } from '@tanstack/react-query-persist-client';
const QUERY_CACHE_KEY = 'REACT_QUERY_OFFLINE_CACHE';
export function createMMKVPersister(): Persister {
return {
persistClient: async (client: PersistedClient) => {
storage.set(QUERY_CACHE_KEY, JSON.stringify(client));
},
restoreClient: async () => {
const cached = storage.getString(QUERY_CACHE_KEY);
return cached ? JSON.parse(cached) : undefined;
},
removeClient: async () => {
storage.delete(QUERY_CACHE_KEY);
},
};
}

For write operations (mutations), Merq uses an outbox pattern to ensure no data is lost when offline.

graph LR
    A[User Action] --> B{Online?}
    B -->|Yes| C[API Call]
    B -->|No| D[MMKV Outbox]
    D --> E{Reconnect?}
    E -->|Yes| F[Flush Loop]
    F --> C
    E -->|No| G[Wait]
  1. Mutation triggered (create visit, submit form, etc.)
  2. Check network status
    • Online: Send to API immediately
    • Offline: Queue to MMKV outbox
  3. On reconnect: Flush outbox in FIFO order
  4. Retry logic: Exponential backoff (1s, 2s, 4s, 8s, 16s, 32s, 60s cap)
  5. Max retries: 5 attempts, then mark as failed
  • MMKV Key: merq_offline_outbox
  • Structure: JSON array of OutboxEntry objects
  • Max entries: 500 (FIFO eviction)
  • Persistence: Until successful sync or manual clear
interface OutboxEntry {
id: string; // UUID
mutation_type: string; // "create_visit", "submit_form", etc.
endpoint: string; // API endpoint
method: string; // POST/PUT/DELETE
payload: any; // Request body
created_at: string; // ISO timestamp
retry_count: number; // Attempts made
max_retries: number; // Default: 5
status: 'pending' | 'processing' | 'failed';
}
export function useCreateVisitMutation() {
return useMutation({
mutationFn: async (data) => {
if (!isOnline) {
// Queue to outbox
await outbox.add({
mutation_type: 'create_visit',
endpoint: '/app/v1/outlet-visits',
method: 'POST',
payload: data,
});
return { queued: true };
}
return visitService.create(data);
},
});
}

See Offline Mutation for detailed implementation.

For background GPS tracking, Merq uses a dedicated location buffer separate from the mutation outbox.

  • High frequency: GPS points every 5-15 minutes
  • Large volume: Hundreds of points per day
  • Batch upload: Send multiple points at once for efficiency
sequenceDiagram
    participant App as Mobile App
    participant BG as BackgroundFetch
    participant Buffer as Location Buffer
    participant API as Backend
    
    BG->>App: Trigger (every 15min)
    App->>App: Get GPS location
    App->>Buffer{Online?}
    Buffer->>API: Upload immediately
    Buffer->>Buffer: Store in MMKV
    Note over Buffer: Sync on reconnect
  • MMKV Key: merq_location_buffer
  • Structure: Array of LocationEntry objects
  • Max entries: 1000 (evict oldest first)
interface LocationEntry {
id: string; // UUID
latitude: number;
longitude: number;
accuracy: number; // Meters
timestamp: string; // ISO timestamp
battery_level: number;// 0.0-1.0
signal_quality: 'good' | 'fair' | 'poor';
}
StateIntervalBattery
ForegroundEvery 5 minutesNormal
BackgroundEvery 15 minutesNormal
Battery SaverEvery 30 minutes<15%

Activated when battery level < 15%:

  • Reduces GPS frequency from 15min to 30min
  • Disables background fetch if battery critically low (<5%)
  • User notified via banner
POST /app/v1/locations/batch
Content-Type: application/json
{
"locations": [
{
"latitude": -6.2088,
"longitude": 106.8456,
"accuracy": 10.5,
"timestamp": "2026-03-05T10:30:00Z",
"battery_level": 0.85
}
]
}

See Location Tracking for detailed implementation.

To prevent duplicate mutations when retrying failed requests, Merq uses an idempotency key system.

sequenceDiagram
    participant Client as Mobile Client
    participant MW as Idempotency Middleware
    participant API as Backend API
    
    Client->>MW: POST with X-Idempotency-Key
    MW->>MW: Check key (24h TTL)
    alt Key exists
        MW-->>Client: 409 Conflict (cached)
    else Key new
        MW->>API: Process request
        API-->>MW: Success
        MW->>MW: Store key
        MW-->>Client: 200 OK
    end

UUID Generation:

function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}

Header Injection:

apiClient.interceptors.request.use((config) => {
if (['POST', 'PUT', 'DELETE'].includes(config.method)) {
config.headers['X-Idempotency-Key'] = generateUUID();
}
return config;
});

Middleware:

  • Checks idempotency_keys table for existing key
  • TTL: 24 hours (lazy eviction)
  • Returns cached response for duplicate keys

Database Schema:

CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
key VARCHAR(255) NOT NULL UNIQUE,
workspace_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
  1. Offline mutation retry — Same key ensures no duplicates
  2. Network timeout — Retry with same key returns cached response
  3. Double-tap prevention — UI glitches don’t create duplicates

See Idempotency for detailed implementation.

1. User opens screen
2. TanStack Query checks cache
├─ Cache available → Render immediately
└─ No cache → Show loading
3. If online: Fetch from API in background
4. Update cache with fresh data
5. Re-render with latest data

Simple Approach (Current):

const createVisitMutation = useMutation({
mutationFn: (data: CreateVisitPayload) => visitService.create(data),
onSuccess: (response) => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['visits'] });
// Show success message
showToast('Visit created successfully');
},
onError: (error) => {
// Queue for retry when online
if (isNetworkError(error)) {
queueForRetry('create_visit', data);
showToast('Saved locally. Will sync when online.');
} else {
showToast('Failed to create visit');
}
},
// Retry automatically
retry: (failureCount, error) => {
if (isNetworkError(error) && failureCount < 3) {
return true;
}
return false;
},
});

Advanced Approach (Optimistic Updates):

const updateVisitMutation = useMutation({
mutationFn: ({ id, data }: UpdateVisitParams) =>
visitService.update(id, data),
onMutate: async ({ id, data }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['visits', id] });
// Snapshot current value
const previous = queryClient.getQueryData(['visits', id]);
// Optimistically update
queryClient.setQueryData(['visits', id], (old: Visit) => ({
...old,
...data,
}));
// Return context for rollback
return { previous };
},
onError: (error, variables, context) => {
// Rollback on error
if (context?.previous) {
queryClient.setQueryData(['visits', variables.id], context.previous);
}
},
onSettled: (data, error, variables) => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: ['visits', variables.id] });
},
});
src/core/hooks/useNetwork.ts
import NetInfo from '@react-native-community/netinfo';
import { useEffect, useState } from 'react';
import { onlineManager } from '@tanstack/react-query';
export function useNetwork() {
const [isOnline, setIsOnline] = useState(true);
const [isInternetReachable, setIsInternetReachable] = useState(true);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsOnline(state.isConnected ?? false);
setIsInternetReachable(state.isInternetReachable ?? false);
// Notify TanStack Query
onlineManager.setOnline(state.isConnected ?? false);
});
return unsubscribe;
}, []);
return { isOnline, isInternetReachable };
}

Usage in UI:

import { useNetwork } from '@core/hooks/useNetwork';
function VisitListScreen() {
const { isOnline } = useNetwork();
return (
<View>
{!isOnline && (
<Banner variant="warning">
You are offline. Changes will sync when connection is restored.
</Banner>
)}
<VisitList />
</View>
);
}
src/core/atoms/auth.atom.ts
import { atomWithStorage } from 'jotai/utils';
import { storage } from '@core/storage';
// Create MMKV-backed atom
function atomWithMMKV<T>(key: string, initialValue: T) {
return atomWithStorage<T>(key, initialValue, {
getItem: (key) => {
const value = storage.getString(key);
return value ? JSON.parse(value) : initialValue;
},
setItem: (key, value) => {
storage.set(key, JSON.stringify(value));
},
removeItem: (key) => {
storage.delete(key);
},
});
}
export const accessTokenAtom = atomWithMMKV<string | null>('access_token', null);
export const refreshTokenAtom = atomWithMMKV<string | null>('refresh_token', null);
export const profileAtom = atomWithMMKV<UserProfile | null>('user_profile', null);

When the app is offline AND the auth token has expired:

  • User can still view cached data (outlets, visits, forms)
  • All write operations are queued (mutations, check-ins, submissions)
  • User sees “Offline - Read Only” banner at top of screen
  • Network indicator shows pending sync count
  • User cannot log out (would lose all cached data)
  • Re-authentication happens automatically when online again

This ensures field agents can continue working even without network access, as long as they’ve logged in at least once.

The sync queue described in this section has been replaced by the Mutation Outbox System as of Plan 13. The code examples are kept for reference only.

For critical operations that must succeed:

src/core/storage/syncQueue.ts
import { storage } from './storage';
interface QueuedOperation {
id: string;
type: 'create' | 'update' | 'delete';
resource: string;
payload: any;
timestamp: number;
retryCount: number;
}
const QUEUE_KEY = 'SYNC_QUEUE';
export const syncQueue = {
add(operation: Omit<QueuedOperation, 'id' | 'timestamp' | 'retryCount'>) {
const queue = this.getAll();
const newOp: QueuedOperation = {
...operation,
id: Date.now().toString(),
timestamp: Date.now(),
retryCount: 0,
};
queue.push(newOp);
storage.set(QUEUE_KEY, JSON.stringify(queue));
},
getAll(): QueuedOperation[] {
const data = storage.getString(QUEUE_KEY);
return data ? JSON.parse(data) : [];
},
remove(id: string) {
const queue = this.getAll().filter(op => op.id !== id);
storage.set(QUEUE_KEY, JSON.stringify(queue));
},
async processQueue() {
const queue = this.getAll();
for (const op of queue) {
try {
await executeOperation(op);
this.remove(op.id);
} catch (error) {
op.retryCount++;
if (op.retryCount > 3) {
// Move to failed queue
this.remove(op.id);
}
}
}
},
};
// Process queue when coming online
onlineManager.subscribe((isOnline) => {
if (isOnline) {
syncQueue.processQueue();
}
});
  1. Always use networkMode: 'offlineFirst' on all queries
  2. Gate queries on auth: enabled: !!profile
  3. Use outbox for mutations — Don’t implement custom queue logic
  4. Handle idempotency automatically — Header interceptor does it for you
  5. Show sync status — Use NetworkIndicator component
  6. Test offline flow — Use onlineManager.setOnline(false) in dev
  1. All list endpoints must be workspace-scoped
  2. Idempotency middleware on all write endpoints
  3. Batch endpoints for bulk operations (locations, submissions)
  4. Graceful degradation — Return empty data if dependency unavailable
  5. 24h TTL for idempotency keys — Don’t change without mobile coordination
// In development
import { onlineManager } from '@tanstack/react-query';
// Force offline
onlineManager.setOnline(false);
// Force online
onlineManager.setOnline(true);
describe('Offline Behavior', () => {
it('should return cached data when offline', async () => {
// Populate cache
queryClient.setQueryData(['outlets'], mockOutlets);
// Go offline
onlineManager.setOnline(false);
// Query should return cached data
const { result } = renderHook(() => useOutletsQuery({}));
expect(result.current.data).toEqual(mockOutlets);
expect(result.current.isFetching).toBe(false);
});
it('should queue mutations when offline', async () => {
onlineManager.setOnline(false);
const { result } = renderHook(() => useCreateVisitMutation());
await act(async () => {
result.current.mutate(mockVisit);
});
const queue = syncQueue.getAll();
expect(queue).toHaveLength(1);
expect(queue[0].type).toBe('create');
});
});

When data modified both offline and online:

// Simple: Last Write Wins
function resolveConflict(local: Visit, remote: Visit): Visit {
return local.updatedAt > remote.updatedAt ? local : remote;
}
// Advanced: Merge Strategy
function mergeVisit(local: Visit, remote: Visit): Visit {
return {
...remote,
// Keep local changes for specific fields
status: local.status,
notes: local.notes,
// Use remote for system fields
approvedBy: remote.approvedBy,
approvedAt: remote.approvedAt,
};
}
// Don't persist everything
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
// Only persist specific queries
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
const queryKey = query.queryKey[0];
// Only persist outlets, visits, not analytics
return ['outlets', 'visits', 'profile'].includes(queryKey as string);
},
},
});
// Instead of syncing each mutation individually
function batchSync(operations: QueuedOperation[]) {
return api.post('/sync/batch', {
operations: operations.map(op => ({
type: op.type,
resource: op.resource,
payload: op.payload,
})),
});
}