Skip to content
← Blog
Technicalby Goodspeed Team

Building Offline-First Mobile Apps

How to build mobile apps that work without internet. Covers sync strategies, conflict resolution, queue management, and local storage patterns.

## Why Offline-First Matters

Your users aren't always online. They're on the subway, in a building with bad signal, on a plane, or in a rural area with spotty coverage. An app that shows a spinner and waits for the network is an app that doesn't work for a significant chunk of usage time.

Offline-first means your app works fully (or mostly) without a network connection. Data is stored locally. Actions are queued. When the connection returns, everything syncs. The user never has to think about whether they're online or not.

## The Architecture

An offline-first app has four components:

1. **Local storage** for data persistence 2. **An action queue** for operations performed offline 3. **A sync engine** that reconciles local and remote state 4. **Conflict resolution** for when the same data changes in multiple places

Let's walk through each one.

## Local Storage Options in React Native

### AsyncStorage

The simplest option. Key-value storage that persists across app launches. Good for small amounts of data: user preferences, auth tokens, cached API responses.

```typescript import AsyncStorage from '@react-native-async-storage/async-storage';

// Store await AsyncStorage.setItem('user_profile', JSON.stringify(profile));

// Retrieve const stored = await AsyncStorage.getItem('user_profile'); const profile = stored ? JSON.parse(stored) : null; ```

**Limits**: Not suitable for large datasets. Reads and writes block the JS thread. No query capability. You're essentially managing a JSON file.

### SQLite (expo-sqlite)

For structured data that needs querying, SQLite is the right choice. It's a full relational database running on the device.

```typescript import * as SQLite from 'expo-sqlite';

const db = await SQLite.openDatabaseAsync('myapp.db');

await db.execAsync(` CREATE TABLE IF NOT EXISTS threads ( id TEXT PRIMARY KEY, title TEXT NOT NULL, score INTEGER DEFAULT 0, synced INTEGER DEFAULT 0, updated_at TEXT ) `); ```

SQLite handles thousands of records without breaking a sweat. It supports indexes, joins, and transactions. For most offline-first apps, this is the sweet spot.

### MMKV

For high-performance key-value storage (think: state that changes frequently), MMKV is significantly faster than AsyncStorage. It uses memory-mapped files and is synchronous.

Good for: theme preferences, feature flags, small caches that update frequently.

## The Action Queue

When a user performs an action offline, you can't just drop it. You need to queue it and replay it when the connection returns.

```typescript interface QueuedAction { id: string; type: 'CREATE' | 'UPDATE' | 'DELETE'; table: string; payload: Record<string, unknown>; createdAt: string; retryCount: number; }

class OfflineQueue { private queue: QueuedAction[] = [];

async enqueue(action: Omit<QueuedAction, 'id' | 'createdAt' | 'retryCount'>) { const entry: QueuedAction = { ...action, id: generateId(), createdAt: new Date().toISOString(), retryCount: 0, };

this.queue.push(entry); await this.persistQueue(); }

async processQueue() { const pending = [...this.queue];

for (const action of pending) { try { await this.executeAction(action); this.queue = this.queue.filter(a => a.id !== action.id); } catch (error) { action.retryCount++; if (action.retryCount > 5) { // Move to dead letter queue this.queue = this.queue.filter(a => a.id !== action.id); await this.reportFailedAction(action); } } }

await this.persistQueue(); } } ```

Important details:

- **Persist the queue** to local storage. If the app is killed, the queue survives. - **Process in order.** Actions must replay in the order they were created. A delete before a create would fail. - **Retry with limits.** Network errors should retry. Business logic errors (like a 409 conflict) need different handling. - **Dead letter queue.** After max retries, save the failed action for debugging. Don't silently drop it.

## Sync Strategies

### Last-Write-Wins

The simplest sync strategy. When syncing, the most recent change wins. Compare timestamps, and the newer version overwrites the older one.

```typescript async function sync(localRecord: Record, remoteRecord: Record) { if (localRecord.updatedAt > remoteRecord.updatedAt) { await pushToServer(localRecord); } else { await saveLocally(remoteRecord); } } ```

This works well for user-specific data where conflicts are unlikely (personal notes, preferences, single-user settings). It breaks down when multiple users edit the same record.

### Field-Level Merging

Instead of replacing the entire record, merge at the field level. If User A changed the title and User B changed the description, keep both changes.

```typescript function mergeRecords(local: Record, remote: Record): Record { const merged = { ...remote };

for (const field of Object.keys(local)) { if (local[field].updatedAt > remote[field].updatedAt) { merged[field] = local[field]; } }

return merged; } ```

This requires tracking timestamps per field, not just per record. More complex, but fewer lost changes.

### Conflict Detection and Manual Resolution

For collaborative data where conflicts truly matter (shared documents, collaborative lists), detect conflicts and let the user resolve them:

1. Store a version number or hash with each record 2. On sync, compare versions 3. If the versions diverge, show both versions to the user 4. Let them choose or merge manually

This is the most work to implement, but it's the only correct approach for collaborative editing.

## Network Detection

React Native provides `NetInfo` for monitoring connectivity:

```typescript import NetInfo from '@react-native-community/netinfo';

// Subscribe to changes const unsubscribe = NetInfo.addEventListener(state => { if (state.isConnected && state.isInternetReachable) { offlineQueue.processQueue(); } }); ```

Don't rely solely on `isConnected`. A device can be connected to WiFi without internet access. Check `isInternetReachable` too.

Also consider: just because the network is available doesn't mean every request will succeed. Your sync engine should handle partial failures gracefully.

## Cache Invalidation

Local caches go stale. You need a strategy for refreshing cached data:

- **Time-based**: Refetch data older than N minutes/hours - **Event-based**: Refetch when the user navigates to a screen - **Push-based**: Use push notifications or websockets to signal when data changes - **ETag-based**: Send the ETag with requests and only download if data changed

Most apps combine time-based and event-based. Refetch on screen focus, but only if the cached data is older than 5 minutes.

## Practical Patterns

### Optimistic Updates

Show the result of an action immediately, before the server confirms it:

```typescript async function likeThread(threadId: string) { // Update UI immediately updateLocalThread(threadId, { liked: true, likeCount: count + 1 });

// Queue the server request try { await api.likeThread(threadId); } catch { // Revert if the server rejects updateLocalThread(threadId, { liked: false, likeCount: count }); } } ```

This makes your app feel instant. Users don't wait for server round trips.

### Loading States

When offline, don't show loading spinners. Show cached data with a subtle indicator that the data might be stale:

```typescript <View> {isStale && ( <Text style={{ color: colors.textSecondary, fontSize: 12 }}> Last updated {formatRelative(lastSyncTime)} </Text> )} {/* Render cached data */} </View> ```

### Offline-Aware Forms

If a user fills out a form while offline, save it locally and sync later. But tell them:

"Your changes have been saved and will sync when you're back online."

This is better than blocking the form submission or showing an error.

## Testing Offline Mode

Testing offline behavior is often skipped, and that's exactly why offline features break in production.

1. **Airplane mode test**: Enable airplane mode and use every feature. Which ones break? 2. **Slow network test**: Throttle to 2G speed. Do requests time out gracefully? 3. **Connection toggle**: Go offline, perform actions, go online. Do they sync? 4. **Kill and restart**: Go offline, perform actions, kill the app, restart with network. Does the queue survive? 5. **Conflict test**: Edit the same data on two devices. What happens?

Our [build pipeline](/features/building) includes offline capability as one of the cross-cutting features evaluated for every app. When the app type calls for it, we generate the full offline stack: local storage, action queue, sync engine, and network detection.

For the backend side of the sync story, see how we use [Supabase](/tech/supabase) for real-time sync and conflict-aware API endpoints.

Ready to build?

Score your first idea free. See the pipeline in action.