Back to Tutorials
Last edited September 4, 2026
September 4, 2026
8 min read
Intermediate
Engineering Patterns

Building an Offline-First React Native App

A practical architecture for React Native apps that remain useful without a connection: local source of truth, migrations, sync queues, conflicts, and honest failure handling.

Offline-first does not mean putting a few values in AsyncStorage. It means a person can open the app with no connection, understand its state, make meaningful changes, and trust those changes will be handled correctly later.

PassportPlate is a useful product example: its core recipe and product experience is designed to work offline, without an account or a backend dependency. That is a simpler offline-first model than a collaborative cloud product, but it illustrates the central decision: make the core loop independent of the network instead of treating offline as an error screen.


Local-First, Offline-Capable, and Offline-First

These labels are often mixed together.

  • Offline-capable apps cache enough content to survive brief network loss.
  • Offline-first apps make local reads and writes work by default, then synchronize where needed.
  • Local-first is a stronger product philosophy: user data lives locally and cloud synchronization is optional or secondary.

Choose the model per feature. A recipe catalogue that ships with the app can be local-first. A shared shopping list needs a synchronization design. A live payment balance should not pretend to be offline-authoritative.

The first design question is not “which storage library?” It is “what must be available on a plane, in a tunnel, or during a service outage?” Write that list before adding a cache.


Make the Local Store the Read Model

For data that should work offline, screens read from a local database. A background process imports bundled data, applies local mutations, and synchronizes remote changes when a connection is available.

TEXT
UI ──read/write──> Local database ──> Outbox / sync worker ──> API
                         ^                                      │
                         └──────── remote changes / reconciliation┘

This avoids a fragile split where some screens read the network and others read a cache. The UI has one source of truth. Network state affects freshness and synchronization status, not whether an ordinary screen can render.

For a fully bundled catalogue, the “sync worker” may not exist at all. That is a legitimate design, not an incomplete one. PassportPlate's account-free core can keep recipes and product behavior on-device because it does not promise cross-device collaboration.


Choose Storage by Data Shape

StorageGood forPoor fit
AsyncStoragesmall preferences, migration markers, simple non-sensitive flagsqueryable records, transactions, queues, secrets
SQLiterelational/domain data, indexes, local queries, migrations, durable outboxopaque large media blobs
File storagedownloaded images, exports, attachmentsrecords you need to filter, join, or transact on
Secure storagesmall credentials and session materialnormal application databases

AsyncStorage is valuable, but it does not provide transactions, relational queries, or a dependable queue. Use SQLite once data has relationships, needs indexing, or must survive a partially completed write. Store files by stable IDs and keep their metadata in the database; do not bury your whole domain model in JSON blobs.

⚠️

Never treat a local cache as a safe place for access tokens, payment data, or sensitive health/personal data merely because the device belongs to the user. Minimize sensitive local data, use platform-secure storage for credentials, and consider encryption, OS backups, device compromise, and logout cleanup in the threat model.


Schema Versions Are Product Versions

Existing installs do not receive an empty database when you release an update. Every schema change needs a migration that is ordered, retry-safe, and tested against a realistic old database.

TypeScript
type Migration = { version: number; run: () => Promise<void> };

const migrations: Migration[] = [
  { version: 1, run: createInitialTables },
  { version: 2, run: addRecipeSavedAtIndex },
  { version: 3, run: addSyncOutboxTable },
];

export async function migrate(fromVersion: number) {
  for (const migration of migrations.filter((item) => item.version > fromVersion)) {
    await database.transaction(async () => {
      await migration.run();
      await setSchemaVersion(migration.version);
    });
  }
}

Use a transaction where your database supports it, so an interrupted migration does not leave “half a column” and a version number that says it succeeded. Back up or export irreplaceable local data before destructive transformations. For derived caches, wiping and rebuilding may be safer than a clever migration; for user-created data, it is usually unacceptable.

First launch also needs a path: create the schema, import bundled seed data if applicable, and only then render features that rely on it. A reinstall is a new local state unless the OS backup mechanism or an account sync restores data; do not promise recovery you have not designed.


Writes, Queues, and Eventual Consistency

When a cloud-backed user edits something, write locally first, show the result immediately, and append an idempotent operation to an outbox.

TypeScript
async function renameList(id: string, title: string) {
  const operationId = crypto.randomUUID();
  const now = new Date().toISOString();

  await database.transaction(async () => {
    await lists.update({ id, title, updatedAt: now, syncState: "pending" });
    await outbox.insert({ operationId, type: "list.rename", payload: { id, title, now } });
  });
}

The server must accept retries without applying the same change twice. A client-generated operation ID, stable record IDs, and timestamps make that possible. Do not use an array index or device-local incremental integer as a global identifier; two offline devices will eventually collide.

When the network returns, send operations in a defined order, mark confirmed work as synchronized, and retain enough detail to display or recover a failed operation. Retry transient failures with exponential backoff and jitter. Do not retry validation failures forever: surface them as action-needed state.

“Eventually consistent” means the server and device may briefly disagree. It does not mean silently losing edits is acceptable.


Conflict Resolution Must Match the Data

There is no universal conflict rule.

  • A read-only recipe catalogue can replace an old local copy with a versioned new copy.
  • A completed habit can be merged by a stable day/ritual key if the action is naturally idempotent.
  • A note may use last-write-wins only if a visible “edited elsewhere” tradeoff is acceptable.
  • Collaborative rich text may need field-level merging or a CRDT, which is a major complexity commitment.

Timestamps alone are not truth: device clocks drift and users can edit offline for days. Prefer server sequence/version values for ordering remote writes. When a user decision could be overwritten, preserve both values or ask them to resolve it. The right answer is often a product decision, not a database trick.


Freshness, Invalidation, and Network State

Every cached response should have metadata: when it was fetched, which content version it represents, and whether it is stale. A cache without invalidation rules turns into a second, undocumented backend.

Network reachability is a signal, not proof that your API works. A device can have Wi-Fi while DNS, authentication, or your service is unavailable. Use it to decide when to attempt sync, then handle the actual request result. Show modest, useful status: “Saved on this device”, “Syncing”, “Last synced yesterday”, or “Needs attention”. Avoid an alarming global offline banner when the current feature works perfectly.

Data that should generally not be cached includes service-role credentials, raw payment details, short-lived one-time secrets, authorization decisions that must be current, and data your privacy policy does not justify storing locally. On logout, clear user-scoped local data or cryptographically separate it by account; otherwise the next person using the device may see it.


Test the Failure Paths on Purpose

Airplane mode is a start, not a test plan. Test a fresh install offline, a relaunch with cached data, a write made offline, a partial sync failure after the server accepts one operation, duplicate retry delivery, an expired session during sync, and an app update from an old schema.

For each core feature, answer:

  1. Does it render with no network?
  2. Can the user make a change?
  3. Where is that change stored?
  4. What happens if synchronization fails permanently?
  5. What happens when the same record changed on another device?

If the answer to the second question is “it spins forever,” the feature is online-first with a cache, not offline-first. That can still be appropriate. The useful part is being honest about the promise the product makes.

Summary

The offline-first architecture is local source of truth, explicit schema evolution, durable pending work, and deliberate reconciliation—not a storage API. PassportPlate can keep its core experience resilient by placing it entirely on-device. Account-backed apps need more machinery, but the principle remains the same: design the no-connection experience as a normal product state, then make synchronization visible, recoverable, and testable.