MobileEngineeringMENA

Offline-First Mobile Apps with React Native and Expo

How to design React Native and Expo apps that keep users productive when connectivity is unstable — and sync cleanly when it returns.

BahrTech Team
February 18, 2026 · 5 min read

A doctor in a rural clinic opens the app between patients. The 4G signal in that building sits at one bar. She creates an appointment, adds a note, and taps save. The spinner runs. Nothing happens. She taps again. Two appointments are created when the connection recovers — or none, because the second tap threw a network error and the state is now inconsistent.

This scenario is not exotic. In MENA markets especially, mobile users move between strong Wi-Fi, weak mobile data, and complete dead zones within the same workflow. An app that treats offline as an error state will fail the users who need it most.

Offline is a product requirement, not a nice-to-have

Offline-first design starts from one question: what should users be able to do when there is no network? The answer shapes the entire architecture.

For a field-sales rep: log a customer visit, attach a photo, mark an item as ordered. For a clinic receptionist: check an appointment, update its status, take a payment. For a warehouse picker: scan items, confirm quantities, flag discrepancies.

None of these require a server response to be useful. The work happens locally and syncs when connectivity returns. The alternative — blocking users on connectivity — destroys the tool's value in exactly the environments where it should be indispensable.

Store intent, not just state

The most important offline object is not a cached screen. It is the user's intent: create this appointment, mark this invoice paid, upload this document. Represent pending work as a durable queue of operations that can be retried, inspected, and displayed to the user.

type PendingOperation =
  | {
      id: string; // stable client-generated ID (cuid or uuid)
      type: "appointment.create";
      payload: { patientId: string; startsAt: string; notes?: string };
      createdAt: string;
      retryCount: number;
      lastError?: string;
    }
  | {
      id: string;
      type: "invoice.markPaid";
      payload: { invoiceId: string; paidAt: string };
      createdAt: string;
      retryCount: number;
      lastError?: string;
    };

Persisting this queue to device storage — rather than keeping it in React state — means operations survive app restarts and device reboots. The user does not lose work because they closed the app.

Library choices: MMKV, SQLite, and WatermelonDB

MMKV (react-native-mmkv) is a fast key-value store backed by memory-mapped files. Use it for small, frequently-read values: user preferences, auth tokens, the pending operations queue when it is small. It serializes with JSON, which keeps the API simple.

SQLite via Expo SQLite is the right layer for relational offline data — patient records, appointment history, inventory. Expo's managed SQLite works reliably across both platforms without native build steps, which matters in Expo-managed workflows.

WatermelonDB builds on SQLite and adds reactive queries, lazy loading, and a built-in sync protocol. It is worth the setup cost for apps with complex relational data and many simultaneous observers (think a dashboard that updates live as sync happens in the background).

For most clinic and field-ops apps we build, SQLite for structured data and MMKV for the operations queue covers the full offline surface.

Make every operation idempotent

Offline sync breaks when retries create duplicate records. The appointment created twice. The payment logged twice. Every write operation needs a stable client-generated ID that the server uses as an idempotency key:

// Client generates the ID before the first attempt
const operationId = createId(); // cuid2

// Server implementation
async function createAppointment(input: CreateInput, idempotencyKey: string) {
  const existing = await prisma.appointment.findUnique({
    where: { clientId: idempotencyKey },
  });
  if (existing) return existing; // safe to return — already created

  return prisma.appointment.create({
    data: { ...input, clientId: idempotencyKey },
  });
}

The client sends the same idempotencyKey on every retry. The server always returns the same result. Duplicate network calls become harmless.

Conflict resolution needs product decisions, not just technical ones

When two users edit the same record offline, last-write-wins is simple to implement and wrong for most business data. Medical notes, inventory counts, and financial records all need something more careful.

Options in increasing sophistication:

Last-write-wins by field — each field tracks its own updatedAt. Changes to different fields merge; simultaneous changes to the same field use the latest timestamp. Works for most appointment metadata (status, notes, doctor).

Operational transforms or CRDTs — for collaborative editing in real time. Overkill for most field-ops apps; relevant for shared documents or whiteboards.

Manual review queue — conflicting writes are flagged and routed to a supervisor or the user. The right answer for financial data and medical records where silent overwrite is not acceptable.

Decide the conflict policy per record type during design, not after your first sync incident in production.

Design the UI around uncertainty

When a record is saved locally but not yet synced, the user should know. When sync fails, the user should know and have a clear recovery path. Vague spinners that eventually disappear are the worst outcome — they leave users wondering whether their work was saved.

Useful patterns:

  • A small sync indicator in the header: green when current, yellow when pending operations exist, red on persistent failure.
  • Per-record status: "Saved locally — syncing" on records with pending operations.
  • Retry button with the count of pending operations and the last sync timestamp.
  • Conflict notice when a record was modified on another device while the user was offline.

Expo makes the low-level implementation straightforward: NetInfo for connectivity state, expo-background-fetch or expo-task-manager for background sync, and expo-sqlite for durable storage. The UX decisions matter as much as the library choices.

Background sync with Expo task manager

import * as BackgroundFetch from "expo-background-fetch";
import * as TaskManager from "expo-task-manager";

const SYNC_TASK = "background-sync";

TaskManager.defineTask(SYNC_TASK, async () => {
  const queue = await loadPendingOperations();
  if (queue.length === 0) return BackgroundFetch.BackgroundFetchResult.NoData;

  for (const op of queue) {
    await retryOperation(op);
  }
  return BackgroundFetch.BackgroundFetchResult.NewData;
});

// Register on app startup
await BackgroundFetch.registerTaskAsync(SYNC_TASK, {
  minimumInterval: 60, // seconds
  stopOnTerminate: false,
  startOnBoot: true,
});

Background tasks on iOS have strict time limits (~30 seconds) and may not run on schedule. Design the sync to be incremental — process a few operations per background run rather than draining the full queue — so partial progress is useful.

Offline-first mobile apps succeed when they preserve user intent, retry operations safely with idempotency keys, communicate sync state honestly, and make conflict resolution a product decision rather than an afterthought.

For the server-side patterns that receive these synced operations reliably, the clinic use case is covered in What It Takes to Build Clinic Software for MENA Healthcare.

Tags
MobileEngineeringMENA