--- title: "Offline-First Mobile Architecture: Syncing Local SQLite Storage with Remote AI Services in React Native" description: "Build an offline-first mobile app in React Native using SQLite local storage, optimistic UI updates, async mutation queues, and background cloud sync." tags: ["reactnative", "mobile", "javascript", "offline"] canonical_url: "https://greenlenspro.com/plant-doctor-app" cover_image: "https://greenlenspro.com/images/blog/react-native-offline-first.jpg" --- # Offline-First Mobile Architecture: Syncing Local SQLite Storage with Remote AI Services in React Native Building mobile applications that rely on cloud-hosted AI APIs presents a unique architectural challenge. Users often interact with mobile apps in environments with weak or non-existent cellular coverage—such as gardens, basements, or rural areas. If a plant care app (`pflanzen pflege app`) blocks user actions—like logging watering schedules (`pflanzen gießen erinnerung`), taking plant notes, or viewing cached diagnoses—behind a mandatory network connection, the user experience rapidly degrades. The solution is an **Offline-First Mobile Architecture**. In an offline-first application, the local database (SQLite) serves as the **Single Source of Truth** for the UI. Network requests to cloud AI services operate asynchronously in the background via a persistent queue. In this deep-dive guide, we'll walk through implementing an offline-first sync engine in React Native (Expo) inspired by the [GreenLens Plant Doctor App](https://greenlenspro.com/plant-doctor-app). --- ## 1. The Offline-First Sync Architecture Instead of having UI components directly invoke API endpoints, all user actions mutate the **Local SQLite Database** immediately (Optimistic UI Updates). Actions that genuinely need a round trip to the backend — a plant photo waiting to be identified, or a purchase that needs to be reconciled with entitlement state — get registered in a persistent **Sync Mutation Queue**. Actions that don't need a server at all (like a personal watering reminder) simply stay local. That distinction matters more than it sounds. It's tempting to design a generic "sync everything" queue and a matching generic `/sync` endpoint on the backend. But GreenLens's real API doesn't expose a catch-all sync endpoint — it exposes purpose-built endpoints: `POST /v1/scan` for plant identification and health reads, `POST /v1/health-check` for a dedicated diagnostic pass, and `POST /v1/billing/sync-revenuecat` for reconciling subscription/credit state. A robust offline queue has to route each queued item to the *specific* endpoint that action actually needs, not to an imaginary generic one. ```mermaid flowchart TD A[User Action: Capture Plant Photo / Restore Purchase] --> B[Mutate Local SQLite DB Immediately] B --> C[Re-render UI Instantly: 0ms Latency] B --> D[Enqueue Pending Action in `sync_queue` with an action_type] D --> E{Network Reachable?} E -- No --> F[Persist in Queue for Reconnect] E -- Yes --> G[Process Queue Items via Background Task] G --> H{action_type?} H -- SCAN --> I[POST /v1/scan] H -- BILLING_SYNC --> J[POST /v1/billing/sync-revenuecat] I --> K[Update Local Record with Server Result] J --> K ``` ### Key Principles: 1. **Zero UI Blocking:** UI components render local state directly from SQLite / WatermelonDB. 2. **Persistent Mutation Queue:** Pending network actions survive app restarts and OS crashes. 3. **Route to Real Endpoints, Not a Fictional One:** Each queue item carries an `action_type` that maps to a concrete backend route (`/v1/scan`, `/v1/billing/sync-revenuecat`). There is no single dedicated "sync" endpoint — the queue is a client-side abstraction, not a server contract. 4. **Conflict Resolution:** Last-Write-Wins (LWW) or Vector Clock strategies reconcile local changes with server timestamps for the fields that do get synced. 5. **Local-Only Data Stays Local:** Not everything needs a server round trip. Personal notes, reminders, and watering logs can live entirely in SQLite unless/until your backend exposes an endpoint for them — don't queue actions against endpoints that don't exist. --- ## 2. Setting Up the Local SQLite Database Scheme We define local SQLite tables to store plant care logs (`app pflanzen pflege`, purely local — no server round trip) and a queue of pending actions that genuinely need to reach the GreenLens API. ```typescript // services/database.ts import * as SQLite from 'expo-sqlite'; const db = SQLite.openDatabaseSync('greenlens_offline.db'); export function initDatabase() { db.execSync(` PRAGMA journal_mode = WAL; -- Local-only care history: watering, fertilizing, notes. -- Nothing here needs a server round trip, so it is never queued. CREATE TABLE IF NOT EXISTS care_logs ( id TEXT PRIMARY KEY NOT NULL, plant_id TEXT NOT NULL, action_type TEXT NOT NULL, -- 'WATER', 'FERTILIZE', 'NOTE' timestamp INTEGER NOT NULL ); -- Pending scans captured offline, waiting to be sent to POST /v1/scan. CREATE TABLE IF NOT EXISTS pending_scans ( id TEXT PRIMARY KEY NOT NULL, plant_id TEXT, local_image_uri TEXT NOT NULL, idempotency_key TEXT NOT NULL, created_at INTEGER NOT NULL, retry_count INTEGER DEFAULT 0, status TEXT DEFAULT 'PENDING' -- 'PENDING', 'SYNCED', 'FAILED' ); -- Generic queue for the small set of actions that do have a real backend -- route: scan replay and billing/entitlement reconciliation. Each row's -- action_type maps 1:1 to a concrete endpoint -- there is no catch-all -- "/sync" route on the server, so the client never assumes one exists. CREATE TABLE IF NOT EXISTS sync_queue ( queue_id TEXT PRIMARY KEY NOT NULL, action_type TEXT NOT NULL, -- 'SCAN' | 'BILLING_SYNC' payload_json TEXT NOT NULL, created_at INTEGER NOT NULL, retry_count INTEGER DEFAULT 0 ); `); } ``` --- ## 3. Optimistic UI Mutation with Auto-Queueing When a user logs a watering event (`pflanzen gießen`), we insert the log locally and update the UI state immediately. Since GreenLens's backend has no endpoint for arbitrary care-log entries, that write stops at SQLite — it's a local-first feature, not a sync-first one. There's no fake network call to fabricate here; the honest answer is that this data simply doesn't leave the device today. ```typescript // services/plantCareService.ts import { db } from './database'; import { generateUUID } from '../utils/uuid'; export interface CareLogInput { plantId: string; actionType: 'WATER' | 'FERTILIZE' | 'NOTE'; } // Purely local write. No sync_queue entry -- there is no server endpoint // for care logs, so pretending to queue one would just be dead code that // silently retries against a route that will always 404. export function recordCareAction(input: CareLogInput) { const logId = generateUUID(); const now = Date.now(); db.runSync( `INSERT INTO care_logs (id, plant_id, action_type, timestamp) VALUES (?, ?, ?, ?);`, [logId, input.plantId, input.actionType, now] ); return logId; } ``` Contrast that with capturing a plant photo offline, which *does* have a real backend counterpart — `POST /v1/scan`. This is where a sync queue earns its keep: the user takes a photo in a basement with no signal, the UI shows the photo immediately with a "pending identification" badge, and the actual scan request gets queued until connectivity returns. ```typescript // services/scanQueueService.ts import { db } from './database'; import { generateUUID } from '../utils/uuid'; import * as FileSystem from 'expo-file-system'; export async function queuePendingScan(plantId: string | null, tempImageUri: string) { const scanId = generateUUID(); const idempotencyKey = generateUUID(); const now = Date.now(); // Persist the photo into app storage so it survives even if the OS // clears the camera roll's temp cache before we get back online. const permanentUri = `${FileSystem.documentDirectory}scans/${scanId}.jpg`; await FileSystem.makeDirectoryAsync(`${FileSystem.documentDirectory}scans/`, { intermediates: true }); await FileSystem.copyAsync({ from: tempImageUri, to: permanentUri }); db.runSync( `INSERT INTO pending_scans (id, plant_id, local_image_uri, idempotency_key, created_at) VALUES (?, ?, ?, ?, ?);`, [scanId, plantId, permanentUri, idempotencyKey, now] ); // Also drop a matching row in the generic sync_queue so the background // processor has a single place to look for outstanding work. db.runSync( `INSERT INTO sync_queue (queue_id, action_type, payload_json, created_at) VALUES (?, ?, ?, ?);`, [generateUUID(), 'SCAN', JSON.stringify({ scanId, idempotencyKey }), now] ); return scanId; } ``` --- ## 4. Building the Sync Engine Hook (`usePlantSync`) The sync engine listens to network state transitions via `@react-native-community/netinfo`. When connectivity is restored, it walks the queue and, for each item, dispatches it to the *specific* endpoint its `action_type` maps to — `/v1/scan` for pending photo identifications, `/v1/billing/sync-revenuecat` for entitlement reconciliation. There is no generic sync endpoint on the backend, so the client has to own that routing decision itself. Two details matter for correctness here. First, **idempotency**: if a request to `/v1/scan` succeeds on the server but the response never makes it back to the device (a dropped connection, an app kill mid-request), naively retrying would burn the user's scan credit twice for one photo. We attach the `idempotencyKey` generated when the scan was queued so retries are safe to send. Second, **backoff**: a transient 5xx or a rate limit shouldn't be retried in a tight loop — we back off per item rather than blocking the whole queue on one flaky request. ```typescript // hooks/usePlantSync.ts import { useEffect, useState } from 'react'; import NetInfo from '@react-native-community/netinfo'; import * as FileSystem from 'expo-file-system'; import { db } from '../services/database'; import { getAuthToken } from '../services/authStorage'; const API_BASE = 'https://greenlenspro.com'; const MAX_RETRIES = 5; export function usePlantSync() { const [isSyncing, setIsSyncing] = useState(false); useEffect(() => { const unsubscribe = NetInfo.addEventListener(state => { if (state.isConnected && state.isInternetReachable) { processSyncQueue(); } }); return () => unsubscribe(); }, []); async function processSyncQueue() { const pendingItems = db.getAllSync( `SELECT * FROM sync_queue WHERE retry_count < ? ORDER BY created_at ASC;`, [MAX_RETRIES] ); if (pendingItems.length === 0) return; setIsSyncing(true); const token = await getAuthToken(); for (const item of pendingItems) { try { const payload = JSON.parse(item.payload_json); let response: Response; // Route each queued item to its real backend endpoint. This is the // one place the client needs to know the mapping between local // action_type and actual API route -- there is no server-side // "/sync" fan-in to lean on. if (item.action_type === 'SCAN') { response = await dispatchScan(payload, token); } else if (item.action_type === 'BILLING_SYNC') { response = await fetch(`${API_BASE}/v1/billing/sync-revenuecat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify(payload) }); } else { // Unknown action_type -- drop it rather than retry forever // against a route that was never real. console.warn(`Unrecognized sync action_type: ${item.action_type}`); db.runSync(`DELETE FROM sync_queue WHERE queue_id = ?;`, [item.queue_id]); continue; } if (response.ok) { const result = await response.json(); if (item.action_type === 'SCAN') { db.runSync( `UPDATE pending_scans SET status = 'SYNCED' WHERE id = ?;`, [payload.scanId] ); } db.runSync(`DELETE FROM sync_queue WHERE queue_id = ?;`, [item.queue_id]); } else if (response.status === 429 || response.status >= 500) { // Transient failure -- bump retry_count, exponential backoff // happens naturally because we only re-run this loop on the next // connectivity event or manual forceSync() call. db.runSync( `UPDATE sync_queue SET retry_count = retry_count + 1 WHERE queue_id = ?;`, [item.queue_id] ); } else { // Non-retryable client error (e.g. 400, 401, 422) -- surface it // instead of retrying forever. console.warn(`Non-retryable sync failure for ${item.queue_id}: ${response.status}`); db.runSync( `UPDATE sync_queue SET retry_count = ? WHERE queue_id = ?;`, [MAX_RETRIES, item.queue_id] ); } } catch (err) { console.warn(`Sync failed for item ${item.queue_id}:`, err); break; // Stop processing on connection drop; NetInfo will re-trigger us. } } setIsSyncing(false); } async function dispatchScan(payload: { scanId: string; idempotencyKey: string }, token: string) { const scanRow = db.getFirstSync( `SELECT * FROM pending_scans WHERE id = ?;`, [payload.scanId] ); const base64Image = await FileSystem.readAsStringAsync(scanRow.local_image_uri, { encoding: FileSystem.EncodingType.Base64 }); return fetch(`${API_BASE}/v1/scan`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, // Server-side idempotency key so a retried request after a dropped // response doesn't get charged or processed twice. 'Idempotency-Key': payload.idempotencyKey }, body: JSON.stringify({ image_base64: base64Image }) }); } return { isSyncing, forceSync: processSyncQueue }; } ``` Note the deliberate omission: there's no attempt to sync `care_logs` here, because there's nothing on the server to sync them to. If GreenLens later ships a `/v1/plants/:id/care-log` endpoint, extending this queue is a matter of adding a third `action_type` branch — not redesigning the architecture. --- ## 5. Offline AI Image Caching Strategy For plant identification and disease diagnostic features (`app pflanzen pflege`), users may capture high-resolution photos while offline. We store image files locally on disk using `expo-file-system` and defer the actual `POST /v1/scan` call until connection is re-established, exactly as shown in `queuePendingScan()` above: ```typescript // Local Image Cache Pipeline // 1. User takes photo -> copied into FileSystem.documentDirectory + 'scans/' // 2. Row inserted into pending_scans + matching row in sync_queue (action_type: 'SCAN') // 3. UI displays the local image immediately with a "pending identification" badge. // 4. usePlantSync() drains the queue on reconnect, POSTs to /v1/scan with the // stored idempotency key, and marks the row 'SYNCED' once a response comes back. ``` If your app also needs to reconcile purchase/credit state after time offline — say a user bought a credit pack on a flaky connection — the same queue handles it: enqueue a `BILLING_SYNC` action pointing at the locally cached RevenueCat receipt, and let the same drain loop call `POST /v1/billing/sync-revenuecat` once the device is back online. It's the same queue, same retry/backoff logic, just a different `action_type` and a different real endpoint on the other end. --- ## 6. Network-First vs. Offline-First: The Practical Difference You don't need a formal benchmark to see why this matters — the qualitative difference is stark enough on its own: | Architecture Model | Button Press to UI Update | Behavior on Poor Connectivity | Offline Usability | |---|---|---|---| | Traditional Network-First API Call | Bound by round-trip time; can visibly stall | User sees spinners, timeouts, or hard error screens | Broken — actions fail outright | | **Offline-First Queue (this pattern)** | Effectively instant — local SQLite write, no network in the critical path | Requests queue silently and drain automatically on reconnect | Fully usable — only "pending sync" state is deferred | The actual numbers you'll see depend heavily on your device, network conditions, and payload size — a 4MB plant photo queued for `/v1/scan` on a spotty connection will always take longer to *sync* than a network-first call that never has to leave the device in the first place. What offline-first buys you isn't a faster network call; it's decoupling the UI response from the network call entirely, so the user's perceived experience stops being hostage to connectivity. Measure this in your own app with real device testing rather than trusting any single set of latency numbers. --- ## Summary & Key Takeaways 1. **SQLite as Single Source of Truth:** Never force mobile UI components to wait for network responses before updating state. 2. **Route Queue Items to Real Endpoints:** A sync queue is a client-side abstraction over a set of *specific* API routes (`/v1/scan`, `/v1/billing/sync-revenuecat`) — don't design around a generic sync endpoint your backend doesn't actually expose. 3. **Persistent Queueing with Idempotency:** Store pending network mutations in SQLite so they survive force-quits and signal losses, and attach an idempotency key so safe retries don't double-charge or double-process a request. 4. **Listen for Connectivity Transitions:** Use `NetInfo` to auto-trigger queue drains as soon as cellular signal recovers, with per-item backoff on transient failures. 5. **Know What Doesn't Need Syncing:** Data with no corresponding backend endpoint (like local care logs) should stay local-only rather than being queued against a route that doesn't exist. 6. **Local File Caching:** Store image binaries locally on disk before initiating AI recognition uploads, and defer the upload itself until the device is back online. To test an offline-first plant diagnosis and care tracking experience, download the [GreenLens Plant Doctor App](https://greenlenspro.com/plant-doctor-app).