Offline-First Strategy
Offline-First Architecture
Section titled “Offline-First Architecture”The Merq mobile app is designed with an offline-first approach to ensure field teams can work without a stable internet connection.
Why Offline-First?
Section titled “Why Offline-First?”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
Architecture Overview
Section titled “Architecture Overview”┌──────────────────────────────────┐│ UI Components ││ (Screens & Components) │└────────────┬─────────────────────┘ │┌────────────▼─────────────────────┐│ TanStack Query Cache ││ (Memory + Persistence) ││ networkMode: "offlineFirst" │└────────────┬─────────────────────┘ │ ┌─────┴─────┐ │ │ ┌───▼──┐ ┌──▼────┐ │ MMKV │ │ API │ │Storage│ │Service│ └──────┘ └───┬───┘ │ ┌──────▼──────┐ │ Network? │ └──────┬──────┘ │ ┌────────┴────────┐ │ │ Online Offline │ │ ┌─────▼─────┐ ┌─────▼─────┐ │ Backend │ │ Queue │ │ API │ │ Request │ └───────────┘ └───────────┘Core Technologies
Section titled “Core Technologies”MMKV Storage
Section titled “MMKV Storage”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 datastorage.set('user_profile', JSON.stringify(profile));
// Read dataconst cached = storage.getString('user_profile');const profile = cached ? JSON.parse(cached) : null;
// Delete datastorage.delete('user_profile');
// Clear allstorage.clearAll();TanStack Query Offline Mode
Section titled “TanStack Query Offline Mode”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:
| Mode | Behavior |
|---|---|
online (default) | Only fetch when online, fail when offline |
always | Always try to fetch, ignore network status |
offlineFirst | Return cache first, sync in background when online |
Persistence with persistQueryClient
Section titled “Persistence with persistQueryClient”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:
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); }, };}Mutation Outbox System
Section titled “Mutation Outbox System”For write operations (mutations), Merq uses an outbox pattern to ensure no data is lost when offline.
Architecture
Section titled “Architecture”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]
How It Works
Section titled “How It Works”- Mutation triggered (create visit, submit form, etc.)
- Check network status
- Online: Send to API immediately
- Offline: Queue to MMKV outbox
- On reconnect: Flush outbox in FIFO order
- Retry logic: Exponential backoff (1s, 2s, 4s, 8s, 16s, 32s, 60s cap)
- Max retries: 5 attempts, then mark as failed
Storage
Section titled “Storage”- MMKV Key:
merq_offline_outbox - Structure: JSON array of
OutboxEntryobjects - Max entries: 500 (FIFO eviction)
- Persistence: Until successful sync or manual clear
OutboxEntry Structure
Section titled “OutboxEntry Structure”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';}Integration with TanStack Query
Section titled “Integration with TanStack Query”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.
Location Buffer
Section titled “Location Buffer”For background GPS tracking, Merq uses a dedicated location buffer separate from the mutation outbox.
Why Separate Buffer?
Section titled “Why Separate Buffer?”- High frequency: GPS points every 5-15 minutes
- Large volume: Hundreds of points per day
- Batch upload: Send multiple points at once for efficiency
Architecture
Section titled “Architecture”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
Storage
Section titled “Storage”- MMKV Key:
merq_location_buffer - Structure: Array of
LocationEntryobjects - Max entries: 1000 (evict oldest first)
LocationEntry Structure
Section titled “LocationEntry Structure”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';}Background Fetch Frequency
Section titled “Background Fetch Frequency”| State | Interval | Battery |
|---|---|---|
| Foreground | Every 5 minutes | Normal |
| Background | Every 15 minutes | Normal |
| Battery Saver | Every 30 minutes | <15% |
Battery Saver Mode
Section titled “Battery Saver Mode”Activated when battery level < 15%:
- Reduces GPS frequency from 15min to 30min
- Disables background fetch if battery critically low (<5%)
- User notified via banner
Batch Upload Endpoint
Section titled “Batch Upload Endpoint”POST /app/v1/locations/batchContent-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.
Idempotency System
Section titled “Idempotency System”To prevent duplicate mutations when retrying failed requests, Merq uses an idempotency key system.
How It Works
Section titled “How It Works”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
Client-Side
Section titled “Client-Side”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;});Server-Side
Section titled “Server-Side”Middleware:
- Checks
idempotency_keystable 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);Use Cases
Section titled “Use Cases”- Offline mutation retry — Same key ensures no duplicates
- Network timeout — Retry with same key returns cached response
- Double-tap prevention — UI glitches don’t create duplicates
See Idempotency for detailed implementation.
Data Sync Strategy
Section titled “Data Sync Strategy”Read Operations
Section titled “Read Operations”1. User opens screen2. TanStack Query checks cache ├─ Cache available → Render immediately └─ No cache → Show loading3. If online: Fetch from API in background4. Update cache with fresh data5. Re-render with latest dataWrite Operations
Section titled “Write Operations”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] }); },});Offline Detection
Section titled “Offline Detection”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> );}Auth State Persistence
Section titled “Auth State Persistence”import { atomWithStorage } from 'jotai/utils';import { storage } from '@core/storage';
// Create MMKV-backed atomfunction 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);Read-Only Mode
Section titled “Read-Only Mode”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.
Sync Queue
Section titled “Sync Queue”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:
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 onlineonlineManager.subscribe((isOnline) => { if (isOnline) { syncQueue.processQueue(); }});Best Practices
Section titled “Best Practices”For Mobile Developers
Section titled “For Mobile Developers”- Always use
networkMode: 'offlineFirst'on all queries - Gate queries on auth:
enabled: !!profile - Use outbox for mutations — Don’t implement custom queue logic
- Handle idempotency automatically — Header interceptor does it for you
- Show sync status — Use
NetworkIndicatorcomponent - Test offline flow — Use
onlineManager.setOnline(false)in dev
For Backend Developers
Section titled “For Backend Developers”- All list endpoints must be workspace-scoped
- Idempotency middleware on all write endpoints
- Batch endpoints for bulk operations (locations, submissions)
- Graceful degradation — Return empty data if dependency unavailable
- 24h TTL for idempotency keys — Don’t change without mobile coordination
Testing Offline Behavior
Section titled “Testing Offline Behavior”Simulate Offline Mode
Section titled “Simulate Offline Mode”// In developmentimport { onlineManager } from '@tanstack/react-query';
// Force offlineonlineManager.setOnline(false);
// Force onlineonlineManager.setOnline(true);Test Cases
Section titled “Test Cases”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'); });});Conflict Resolution
Section titled “Conflict Resolution”When data modified both offline and online:
// Simple: Last Write Winsfunction resolveConflict(local: Visit, remote: Visit): Visit { return local.updatedAt > remote.updatedAt ? local : remote;}
// Advanced: Merge Strategyfunction 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, };}Performance Optimization
Section titled “Performance Optimization”1. Selective Persistence
Section titled “1. Selective Persistence”// Don't persist everythingpersistQueryClient({ 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); }, },});2. Batch Sync
Section titled “2. Batch Sync”// Instead of syncing each mutation individuallyfunction batchSync(operations: QueuedOperation[]) { return api.post('/sync/batch', { operations: operations.map(op => ({ type: op.type, resource: op.resource, payload: op.payload, })), });}Related Documentation
Section titled “Related Documentation”- Offline Mutation — Outbox implementation details
- Location Tracking — GPS buffer and batch upload
- Idempotency — Duplicate prevention system
- Sales Order — Offline-first mutation example