← Blog/Mobile App Development

Building Offline-First React Native Apps — Storage, Sync, and Conflict Resolution

How to architect a React Native app that works without a network connection — local storage, background sync, optimistic updates, and conflict resolution strategies.

·9 min read

Most mobile apps are built with the assumption of a network connection. This is a mistake. Mobile networks are unreliable — users go into tunnels, elevators, and rural areas. An app that becomes unusable without a connection feels broken even when the failure is the network's fault, not yours.

Offline-first architecture means the app reads from and writes to local storage by default, and syncs with the server in the background. The user's operations never wait for a network round-trip.

Storage Options in React Native

The right storage layer depends on your data model:

| Layer | Best for | Options | |---|---|---| | Key-value | Settings, tokens, small state | MMKV, AsyncStorage | | Relational | Structured app data with queries | WatermelonDB, SQLite (op-sqlite) | | File | Binary data, images, documents | Expo FileSystem, RNFS |

MMKV is the correct choice for key-value storage. It's a C++ implementation that's 30× faster than AsyncStorage and synchronous. Use it for user preferences, auth tokens, cached API responses, and anything you want to read synchronously.

import { MMKV } from 'react-native-mmkv'

export const storage = new MMKV()

// Synchronous read — no await needed
const token = storage.getString('auth_token')
const userPrefs = JSON.parse(storage.getString('user_prefs') ?? '{}')

WatermelonDB is the correct choice for relational data. It's built for React Native with lazy loading and reactive queries, and its sync protocol is designed specifically for offline-first use cases.

The Sync Architecture

The core pattern: every write goes to local storage first, then a sync queue processes the writes to the server in the background.

User action
  → Write to local DB immediately (optimistic)
  → Render from local DB (instant feedback)
  → Sync queue picks up pending changes
  → Server accepts or rejects each change
  → Apply server response to local DB

This pattern means the user's interaction is never blocked. The async sync can retry on network recovery.

Implementing a Sync Queue

interface SyncOperation {
  id: string
  type: 'CREATE' | 'UPDATE' | 'DELETE'
  table: string
  recordId: string
  payload: Record<string, unknown>
  createdAt: number
  retryCount: number
}

class SyncQueue {
  private queue: SyncOperation[] = []

  async enqueue(op: SyncOperation) {
    this.queue.push(op)
    storage.set('sync_queue', JSON.stringify(this.queue))
    await this.flush()
  }

  async flush() {
    if (!navigator.onLine) return

    const pending = [...this.queue]
    for (const op of pending) {
      try {
        await this.process(op)
        this.queue = this.queue.filter((q) => q.id !== op.id)
      } catch (error) {
        op.retryCount++
        if (op.retryCount >= 5) this.queue = this.queue.filter((q) => q.id !== op.id)
      }
    }

    storage.set('sync_queue', JSON.stringify(this.queue))
  }

  private async process(op: SyncOperation) {
    const endpoint = `/api/${op.table}/${op.recordId}`
    const method = op.type === 'CREATE' ? 'POST' : op.type === 'UPDATE' ? 'PATCH' : 'DELETE'
    const response = await fetch(endpoint, {
      method,
      headers: { 'Content-Type': 'application/json' },
      body: op.type !== 'DELETE' ? JSON.stringify(op.payload) : undefined,
    })
    if (!response.ok) throw new Error(`Server rejected: ${response.status}`)
  }
}

Listen to network state changes to trigger sync on reconnection:

import NetInfo from '@react-native-community/netinfo'

useEffect(() => {
  const unsubscribe = NetInfo.addEventListener((state) => {
    if (state.isConnected) {
      syncQueue.flush()
    }
  })
  return unsubscribe
}, [])

Optimistic Updates with React Query

React Query (TanStack Query) has first-class support for optimistic updates — update the UI immediately and roll back if the server rejects the change:

const { mutate: updateTask } = useMutation({
  mutationFn: (data: TaskUpdate) => api.updateTask(data),
  onMutate: async (newData) => {
    await queryClient.cancelQueries({ queryKey: ['tasks'] })
    const previous = queryClient.getQueryData(['tasks'])

    // Optimistically update the cache
    queryClient.setQueryData(['tasks'], (old: Task[]) =>
      old.map((t) => (t.id === newData.id ? { ...t, ...newData } : t))
    )

    return { previous }
  },
  onError: (_err, _vars, context) => {
    // Roll back on failure
    queryClient.setQueryData(['tasks'], context?.previous)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['tasks'] })
  },
})

Conflict Resolution

When a user edits data offline and someone else edits the same record online, you have a conflict. The three common strategies:

Last-write wins: The most recent timestamp wins. Simple to implement, loses data when two users edit simultaneously. Acceptable for most user-scoped data (settings, personal records).

Field-level merge: Track which fields changed and merge non-conflicting changes. Two users editing different fields of the same record both win. More complex to implement but appropriate for collaborative data.

Version vectors: Each record has a version counter. The server rejects updates with an outdated version and returns the current state. The client presents a conflict resolution UI. Use this for high-value data where data loss is unacceptable.

For most consumer apps, last-write wins with a updatedAt timestamp is the right call. Build for the 99% case.

Testing Offline Behaviour

Test offline scenarios explicitly — don't assume they work because the online path works:

  • Disable wifi in the iOS Simulator via Device → Network Link Conditioner
  • Use the React Native debugger's network throttling
  • Write explicit tests with jest.mock('react-native-netinfo')

Offline-first is not an afterthought to add before launch — it's an architectural decision that affects your data layer, sync strategy, and UI patterns from the start. If you're planning a mobile app and want offline-first built in from day one, talk to our team.