AboutBlogContact
Mobile DevelopmentJune 30, 2025 9 min read 105Updated: May 18, 2026

Offline-First Mobile Architecture: Building Apps That Work Without the Internet

AunimedaAunimeda
📋 Table of Contents

Offline-First Mobile Architecture: Building Apps That Work Without the Internet

Most mobile apps are built network-first: the app requests data from the server, shows it, and expects the user to always be online. When network is unavailable - tunnel, subway, weak signal, roaming off - the app either shows an error screen or freezes.

Offline-first inverts this model: the app's source of truth is local storage. Every read comes from the local database. Every write goes to local storage first, then syncs to the server when connected. The app works identically with or without network - sync happens in the background.

This is how apps like Notion, Linear, Figma, and WhatsApp work. Here's the architecture.


When offline-first is necessary

Not every app needs offline-first. The complexity is real. Consider it when:

  • Your users have unreliable connectivity (field workers, travelers, rural areas, Central Asia)
  • App failure during network unavailability causes significant user friction
  • Users expect to continue working mid-task without interruption
  • Data creation (notes, forms, orders) must not be lost during connectivity gaps

For a read-heavy app (news, social feed, ecommerce browsing): cache aggressively, but full offline-first may be overkill. For a write-heavy app (task management, forms, messaging): offline-first is essential.


The core model: local database as source of truth

User interacts with app
        ↓
Write to local database (SQLite / WatermelonDB / MMKV)
        ↓
UI re-renders from local database (instant, no network needed)
        ↓
Sync queue picks up change
        ↓
When network available → sync to server
        ↓
Server responds → merge server changes into local database

The key shift: there's no "waiting for server response" in the user flow. The write is instant because it goes to local storage first. The network sync is a background concern.


Storage options

WatermelonDB (complex relational data)

The best choice for apps with relational data that needs offline-first. SQLite-backed, lazy-loaded, designed specifically for offline sync.

// schema.js
import { appSchema, tableSchema } from '@nozbe/watermelondb';

export const schema = appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'tasks',
      columns: [
        { name: 'title', type: 'string' },
        { name: 'description', type: 'string', isOptional: true },
        { name: 'is_completed', type: 'boolean' },
        { name: 'project_id', type: 'string', isIndexed: true },
        { name: 'server_id', type: 'string', isOptional: true },
        { name: 'sync_status', type: 'string' }, // 'synced' | 'created' | 'updated' | 'deleted'
        { name: 'created_at', type: 'number' },
        { name: 'updated_at', type: 'number' },
      ],
    }),
    tableSchema({
      name: 'projects',
      columns: [
        { name: 'name', type: 'string' },
        { name: 'server_id', type: 'string', isOptional: true },
        { name: 'sync_status', type: 'string' },
        { name: 'created_at', type: 'number' },
        { name: 'updated_at', type: 'number' },
      ],
    }),
  ],
});

// models/Task.js
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly, relation } from '@nozbe/watermelondb/decorators';

export class Task extends Model {
  static table = 'tasks';
  static associations = {
    projects: { type: 'belongs_to', key: 'project_id' },
  };
  
  @field('title') title;
  @field('description') description;
  @field('is_completed') isCompleted;
  @field('sync_status') syncStatus;
  @readonly @date('created_at') createdAt;
  @date('updated_at') updatedAt;
  @relation('projects', 'project_id') project;
}

MMKV (simple key-value, ultra-fast)

For app state, user preferences, small data sets. 10x faster than AsyncStorage.

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

const storage = new MMKV({
  id: 'app-storage',
  encryptionKey: 'your-encryption-key', // Encrypt sensitive data
});

// Synchronous reads - no await needed
const userId = storage.getString('userId');
const preferences = JSON.parse(storage.getString('preferences') || '{}');

// Sync writes
storage.set('lastSync', Date.now().toString());
storage.set('draft_post', JSON.stringify(draftContent));

Write path: optimistic updates

// Task creation: write locally first, sync later
async function createTask(title, projectId) {
  // Generate a local ID immediately
  const localId = `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
  
  // Write to local database synchronously
  await database.write(async () => {
    await database.get('tasks').create(task => {
      task.title = title;
      task.projectId = projectId;
      task.isCompleted = false;
      task.syncStatus = 'created'; // Flagged for sync
    });
  });
  
  // UI updates immediately - user sees the new task
  // No spinner, no waiting
  
  // Add to sync queue (background)
  syncQueue.add({ type: 'CREATE_TASK', localId, data: { title, projectId } });
}

// Task completion: optimistic update
async function completeTask(taskId) {
  await database.write(async () => {
    const task = await database.get('tasks').find(taskId);
    await task.update(t => {
      t.isCompleted = true;
      t.syncStatus = t.syncStatus === 'created' ? 'created' : 'updated';
      t.updatedAt = Date.now();
    });
  });
  
  syncQueue.add({ type: 'UPDATE_TASK', id: taskId, data: { isCompleted: true } });
}

The sync engine

class SyncEngine {
  private isSyncing = false;
  private syncInterval: ReturnType<typeof setInterval> | null = null;
  
  start() {
    // Sync on network state changes
    NetInfo.addEventListener(state => {
      if (state.isConnected) this.sync();
    });
    
    // Periodic sync every 30 seconds when connected
    this.syncInterval = setInterval(async () => {
      const { isConnected } = await NetInfo.fetch();
      if (isConnected) this.sync();
    }, 30_000);
    
    // Sync on app foreground
    AppState.addEventListener('change', state => {
      if (state === 'active') this.sync();
    });
  }
  
  async sync() {
    if (this.isSyncing) return;
    this.isSyncing = true;
    
    try {
      // 1. Push local changes to server
      await this.pushLocalChanges();
      
      // 2. Pull server changes
      await this.pullServerChanges();
      
    } catch (err) {
      console.warn('Sync failed:', err);
      // Will retry on next sync cycle
    } finally {
      this.isSyncing = false;
    }
  }
  
  private async pushLocalChanges() {
    const unsyncedRecords = await database.get('tasks')
      .query(Q.where('sync_status', Q.notEq('synced')))
      .fetch();
    
    for (const record of unsyncedRecords) {
      try {
        if (record.syncStatus === 'created') {
          const serverRecord = await api.post('/tasks', record.toJSON());
          await database.write(async () => {
            await record.update(r => {
              r.serverId = serverRecord.id;
              r.syncStatus = 'synced';
            });
          });
        } else if (record.syncStatus === 'updated') {
          await api.put(`/tasks/${record.serverId}`, record.toJSON());
          await database.write(async () => {
            await record.update(r => { r.syncStatus = 'synced'; });
          });
        } else if (record.syncStatus === 'deleted') {
          await api.delete(`/tasks/${record.serverId}`);
          await database.write(async () => { await record.destroyPermanently(); });
        }
      } catch (err) {
        if (err.status === 409) {
          // Conflict - server has a newer version
          await this.resolveConflict(record, err.serverRecord);
        }
        // Other errors: leave sync_status as-is, retry next cycle
      }
    }
  }
  
  private async pullServerChanges() {
    const lastSync = storage.getString('lastSyncTimestamp') || '1970-01-01T00:00:00Z';
    
    const { changes, serverTimestamp } = await api.get('/sync', {
      since: lastSync,
      userId: currentUserId,
    });
    
    await database.write(async () => {
      for (const change of changes) {
        const existing = await database.get(change.table)
          .query(Q.where('server_id', change.id))
          .fetch();
        
        if (change.deleted) {
          existing[0] && await existing[0].destroyPermanently();
        } else if (existing[0]) {
          // Only apply if server version is newer than local
          if (change.updatedAt > existing[0].updatedAt) {
            await existing[0].update(r => Object.assign(r, change.data));
          }
        } else {
          await database.get(change.table).create(r => {
            Object.assign(r, change.data, { serverId: change.id, syncStatus: 'synced' });
          });
        }
      }
    });
    
    storage.set('lastSyncTimestamp', serverTimestamp);
  }
}

Conflict resolution

When two clients modify the same record offline and both sync:

Last-write-wins (simplest): whichever update has the later updated_at timestamp wins. Fast to implement, loses the other change. Acceptable for most fields (task title, status).

Field-level merging (safer): merge non-conflicting field changes, flag conflicting fields for user resolution.

async function resolveConflict(localRecord, serverRecord) {
  // Determine which fields conflict
  const conflicts = [];
  const merged = { ...serverRecord };
  
  for (const field of editableFields) {
    if (localRecord[field] !== serverRecord[field]) {
      if (localRecord.updatedAt > serverRecord.updatedAt) {
        // Local is newer - use local value
        merged[field] = localRecord[field];
      } else {
        // Server is newer - use server value, but note conflict
        conflicts.push({ field, localValue: localRecord[field], serverValue: serverRecord[field] });
      }
    }
  }
  
  await database.write(async () => {
    await localRecord.update(r => Object.assign(r, merged, { syncStatus: 'synced' }));
  });
  
  // If there were meaningful conflicts, notify user
  if (conflicts.some(c => importantFields.includes(c.field))) {
    notifyUser('Some of your changes conflicted with changes made on another device.');
  }
}

For CRDTs (conflict-free replicated data types) which handle merging mathematically without conflicts: see Yjs or Automerge for collaborative use cases. Adds significant complexity - only warranted for truly collaborative real-time editing.


UX patterns for offline state

The app should always communicate network and sync state without being annoying:

function SyncStatusBar() {
  const { isConnected } = useNetInfo();
  const { pendingChanges, isSyncing } = useSyncStatus();
  
  if (isConnected && !pendingChanges && !isSyncing) return null;
  
  return (
    <View style={styles.statusBar}>
      {!isConnected && (
        <Text>Offline - changes saved locally</Text>
      )}
      {isConnected && isSyncing && (
        <Text>Syncing...</Text>
      )}
      {isConnected && pendingChanges > 0 && !isSyncing && (
        <TouchableOpacity onPress={syncEngine.sync}>
          <Text>{pendingChanges} changes pending - tap to sync</Text>
        </TouchableOpacity>
      )}
    </View>
  );
}

Show sync status, not loading spinners. In an offline-first app, there should be no loading states for reading data - it comes from the local database instantly. The spinner should only appear during explicit sync operations the user initiates.

Optimistic deletes. Mark as deleted locally and hide from UI immediately. Only delete from server on sync. If sync fails for the delete, the item reappears - acceptable, since the user sees a sync error.


Testing offline behavior

// In tests: simulate offline state
it('creates task while offline and syncs on reconnect', async () => {
  // Go offline
  mockNetInfo.setConnectionState({ isConnected: false });
  
  // Create task
  await createTask('Test task', projectId);
  
  // Task is in local DB with sync_status = 'created'
  const localTasks = await getTasks();
  expect(localTasks).toHaveLength(1);
  expect(localTasks[0].syncStatus).toBe('created');
  
  // Server has nothing yet
  expect(mockApi.calls('/tasks')).toHaveLength(0);
  
  // Come back online
  mockNetInfo.setConnectionState({ isConnected: true });
  await syncEngine.sync();
  
  // Task was synced
  expect(mockApi.calls('POST /tasks')).toHaveLength(1);
  const syncedTask = await getTasks();
  expect(syncedTask[0].syncStatus).toBe('synced');
  expect(syncedTask[0].serverId).toBeTruthy();
});

Test the conflict scenario explicitly. Offline conflicts are rare in testing and common in production. Simulate two clients modifying the same record, then sync both, and verify the result is correct.


The server sync endpoint

Your server needs a dedicated sync endpoint:

// GET /sync?since=2025-01-01T00:00:00Z&userId=123
app.get('/sync', requireAuth, async (req, res) => {
  const { since } = req.query;
  const userId = req.user.id;
  
  const changes = await db.query(`
    SELECT 
      table_name as "table",
      record_id as id,
      operation as type, -- 'insert', 'update', 'delete'
      record_data as data,
      changed_at as "updatedAt"
    FROM change_log
    WHERE 
      user_id = $1 
      AND changed_at > $2
    ORDER BY changed_at ASC
    LIMIT 1000
  `, [userId, since]);
  
  res.json({
    changes,
    serverTimestamp: new Date().toISOString(),
  });
});

Maintain a change_log table that records every insert/update/delete via database triggers. This is simpler and more reliable than trying to query multiple tables for changes.

Offline-first is an investment. The sync engine, conflict resolution, and UX patterns add 2-3 weeks to initial development. The return: an app that works reliably for all users, regardless of connectivity - which in markets like Central Asia with variable mobile data is not a nice-to-have, but a requirement for real-world usability.


Aunimeda develops mobile applications for iOS and Android - from MVP to production-ready apps with full backend integration.

Contact us to discuss your mobile project. See also: Mobile App Development, Mobile Game Development

Read Also

Flutter vs React Native in 2026: Which Should You Choose?aunimeda
Mobile Development

Flutter vs React Native in 2026: Which Should You Choose?

Flutter or React Native for your mobile app in 2026? A practical comparison of performance, ecosystem, developer experience, and which framework wins for different project types.

React Native Push Notifications in 2026: Complete Guide (Expo + Firebase)aunimeda
Mobile Development

React Native Push Notifications in 2026: Complete Guide (Expo + Firebase)

Push notifications are the single highest-ROI feature in mobile apps. Open rates are 7x higher than email. Here's how to implement them correctly in React Native - including background handling, deep linking, and analytics.

Flutter vs React Native in 2026 - An Honest Comparisonaunimeda
Mobile Development

Flutter vs React Native in 2026 - An Honest Comparison

Flutter or React Native for your next mobile app? We've built production apps with both. Here's what actually matters when choosing in 2026.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Mobile App Development

Get Consultation All articles