← Back to Guides

Offline-First Mobile Apps Strategy

📖 12 min read | 📅 Updated: January 2025 | 🏷️ Mobile Development

Introduction

Offline-first mobile apps provide seamless experiences regardless of network connectivity. This guide covers local database implementation, synchronization strategies, conflict resolution, and best practices for building robust offline-capable applications.

1. Local Database Solutions

SQLite with Room (Android)

// Entity definition
@Entity(tableName = "tasks")
data class Task(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val title: String,
    val description: String,
    val completed: Boolean = false,
    val createdAt: Long = System.currentTimeMillis(),
    val updatedAt: Long = System.currentTimeMillis(),
    val syncStatus: SyncStatus = SyncStatus.PENDING
)

enum class SyncStatus {
    SYNCED, PENDING, CONFLICT
}

// DAO
@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks ORDER BY createdAt DESC")
    fun getAllTasks(): Flow>
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTask(task: Task)
    
    @Update
    suspend fun updateTask(task: Task)
    
    @Delete
    suspend fun deleteTask(task: Task)
    
    @Query("SELECT * FROM tasks WHERE syncStatus = :status")
    suspend fun getTasksBySync Status(status: SyncStatus): List
}

// Database
@Database(entities = [Task::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
}

Realm Database (iOS/Android)

// Swift - Realm model
import RealmSwift

class Task: Object {
    @Persisted(primaryKey: true) var id: String = UUID().uuidString
    @Persisted var title: String = ""
    @Persisted var taskDescription: String = ""
    @Persisted var completed: Bool = false
    @Persisted var createdAt: Date = Date()
    @Persisted var updatedAt: Date = Date()
    @Persisted var syncStatus: String = "pending"
    @Persisted var deleted: Bool = false
}

class TaskRepository {
    private let realm: Realm
    
    init() throws {
        realm = try Realm()
    }
    
    func getAllTasks() -> Results {
        return realm.objects(Task.self)
            .filter("deleted == false")
            .sorted(byKeyPath: "createdAt", ascending: false)
    }
    
    func addTask(_ task: Task) throws {
        try realm.write {
            realm.add(task)
        }
    }
    
    func updateTask(_ task: Task, with updates: (Task) -> Void) throws {
        try realm.write {
            updates(task)
            task.updatedAt = Date()
            task.syncStatus = "pending"
        }
    }
    
    func getPendingTasks() -> Results {
        return realm.objects(Task.self)
            .filter("syncStatus == 'pending'")
    }
}

WatermelonDB (React Native)

// Model definition
import { Model } from '@nozbe/watermelondb'
import { field, date, readonly } from '@nozbe/watermelondb/decorators'

export class Task extends Model {
  static table = 'tasks'
  
  @field('title') title
  @field('description') description
  @field('completed') completed
  @readonly @date('created_at') createdAt
  @date('updated_at') updatedAt
  @field('sync_status') syncStatus
}

// Schema
export const schema = {
  version: 1,
  tables: [
    {
      name: 'tasks',
      columns: [
        { name: 'title', type: 'string' },
        { name: 'description', type: 'string' },
        { name: 'completed', type: 'boolean' },
        { name: 'created_at', type: 'number' },
        { name: 'updated_at', type: 'number' },
        { name: 'sync_status', type: 'string' }
      ]
    }
  ]
}

// Usage
import { Database } from '@nozbe/watermelondb'
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'

const adapter = new SQLiteAdapter({
  schema,
  dbName: 'myapp',
  jsi: true
})

const database = new Database({
  adapter,
  modelClasses: [Task]
})

// CRUD operations
const createTask = async (title, description) => {
  await database.write(async () => {
    await database.get('tasks').create(task => {
      task.title = title
      task.description = description
      task.completed = false
      task.syncStatus = 'pending'
    })
  })
}

2. Network Detection & Queue Management

Network Connectivity Monitor

// React Native - Network state management
import NetInfo from '@react-native-community/netinfo';
import { useEffect, useState } from 'react';

export const useNetworkStatus = () => {
  const [isConnected, setIsConnected] = useState(true);
  const [connectionType, setConnectionType] = useState('unknown');
  
  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener(state => {
      setIsConnected(state.isConnected ?? false);
      setConnectionType(state.type);
    });
    
    return () => unsubscribe();
  }, []);
  
  return { isConnected, connectionType };
};

// Sync queue manager
class SyncQueue {
  private queue: SyncOperation[] = [];
  private processing = false;
  
  async addOperation(operation: SyncOperation) {
    this.queue.push(operation);
    await this.processQueue();
  }
  
  async processQueue() {
    if (this.processing || this.queue.length === 0) return;
    
    const netInfo = await NetInfo.fetch();
    if (!netInfo.isConnected) return;
    
    this.processing = true;
    
    while (this.queue.length > 0) {
      const operation = this.queue[0];
      
      try {
        await this.executeOperation(operation);
        this.queue.shift(); // Remove successful operation
      } catch (error) {
        console.error('Sync operation failed:', error);
        
        // Retry logic
        operation.retryCount = (operation.retryCount || 0) + 1;
        if (operation.retryCount >= 3) {
          this.queue.shift(); // Remove after 3 failures
          await this.markAsConflict(operation);
        } else {
          break; // Stop processing, will retry later
        }
      }
    }
    
    this.processing = false;
  }
  
  private async executeOperation(operation: SyncOperation) {
    switch (operation.type) {
      case 'CREATE':
        return await api.createTask(operation.data);
      case 'UPDATE':
        return await api.updateTask(operation.id, operation.data);
      case 'DELETE':
        return await api.deleteTask(operation.id);
    }
  }
}

3. Synchronization Strategies

Last-Write-Wins (LWW)

// Simple timestamp-based sync
class LWWSyncService {
    async syncTask(localTask: Task, remoteTask: Task): Promise {
        if (localTask.updatedAt > remoteTask.updatedAt) {
            // Local is newer, push to server
            await api.updateTask(localTask.id, localTask);
            return localTask;
        } else {
            // Remote is newer, update local
            await database.updateTask(remoteTask);
            return remoteTask;
        }
    }
    
    async fullSync() {
        const localTasks = await database.getAllTasks();
        const remoteTasks = await api.getAllTasks();
        
        // Create maps for efficient lookup
        const localMap = new Map(localTasks.map(t => [t.id, t]));
        const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
        
        // Sync existing tasks
        for (const [id, localTask] of localMap) {
            const remoteTask = remoteMap.get(id);
            
            if (remoteTask) {
                await this.syncTask(localTask, remoteTask);
            } else {
                // Local only, push to server
                await api.createTask(localTask);
            }
        }
        
        // Pull remote-only tasks
        for (const [id, remoteTask] of remoteMap) {
            if (!localMap.has(id)) {
                await database.createTask(remoteTask);
            }
        }
    }
}

Operational Transformation (OT)

// Advanced conflict resolution with OT
interface Operation {
    type: 'insert' | 'delete' | 'update';
    position?: number;
    value?: any;
    field?: string;
    timestamp: number;
    clientId: string;
}

class OTSyncEngine {
    transform(op1: Operation, op2: Operation): Operation {
        // Transform op1 against op2
        if (op1.type === 'insert' && op2.type === 'insert') {
            if (op1.position <= op2.position) {
                return op1;
            } else {
                return { ...op1, position: op1.position + 1 };
            }
        }
        
        if (op1.type === 'delete' && op2.type === 'insert') {
            if (op1.position < op2.position) {
                return op1;
            } else {
                return { ...op1, position: op1.position + 1 };
            }
        }
        
        // Add more transformation rules
        return op1;
    }
    
    async applyOperation(operation: Operation, document: any): Promise {
        switch (operation.type) {
            case 'insert':
                return this.applyInsert(document, operation);
            case 'delete':
                return this.applyDelete(document, operation);
            case 'update':
                return this.applyUpdate(document, operation);
        }
    }
}

CRDT (Conflict-free Replicated Data Types)

// Using Yjs for CRDT-based sync
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';

class CRDTSyncService {
    private ydoc: Y.Doc;
    private provider: IndexeddbPersistence;
    
    constructor() {
        this.ydoc = new Y.Doc();
        this.provider = new IndexeddbPersistence('myapp-db', this.ydoc);
        
        // Setup sync with server
        this.setupServerSync();
    }
    
    getSharedArray(name: string): Y.Array {
        return this.ydoc.getArray(name);
    }
    
    addTask(task: Task) {
        const tasks = this.getSharedArray('tasks');
        tasks.push([task]);
    }
    
    updateTask(index: number, updates: Partial) {
        const tasks = this.getSharedArray('tasks');
        const task = tasks.get(index);
        Object.assign(task, updates);
    }
    
    private setupServerSync() {
        // Periodic sync with server
        setInterval(async () => {
            if (navigator.onLine) {
                await this.syncWithServer();
            }
        }, 30000); // Every 30 seconds
    }
    
    private async syncWithServer() {
        // Send local updates to server
        const stateVector = Y.encodeStateVector(this.ydoc);
        const diff = await api.getStateDiff(stateVector);
        
        if (diff) {
            Y.applyUpdate(this.ydoc, diff);
        }
        
        // Send local updates to server
        const update = Y.encodeStateAsUpdate(this.ydoc);
        await api.pushUpdate(update);
    }
}

4. Conflict Resolution UI

User-Driven Conflict Resolution

// React Native - Conflict resolution component
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';

interface ConflictResolverProps {
    localVersion: Task;
    remoteVersion: Task;
    onResolve: (resolved: Task) => void;
}

export const ConflictResolver: React.FC = ({
    localVersion,
    remoteVersion,
    onResolve
}) => {
    const handleKeepLocal = () => {
        onResolve(localVersion);
    };
    
    const handleKeepRemote = () => {
        onResolve(remoteVersion);
    };
    
    const handleMerge = () => {
        const merged = {
            ...localVersion,
            // Custom merge logic
            title: remoteVersion.title, // Example: prefer remote title
            description: localVersion.description // prefer local description
        };
        onResolve(merged);
    };
    
    return (
        
            Sync Conflict Detected
            
            
                Your Version (Local)
                {localVersion.title}
                {localVersion.description}
                Modified: {new Date(localVersion.updatedAt).toLocaleString()}
            
            
            
                Server Version
                {remoteVersion.title}
                {remoteVersion.description}
                Modified: {new Date(remoteVersion.updatedAt).toLocaleString()}
            
            
            
                
                    Keep My Version
                
                
                
                    Use Server Version
                
                
                
                    Merge Both
                
            
        
    );
};

5. Optimistic UI Updates

Instant Feedback Pattern

// Optimistic update with rollback
class OptimisticTaskService {
    async createTask(task: Task) {
        // 1. Update UI immediately
        await database.insertTask(task);
        uiStore.addTask(task);
        
        try {
            // 2. Sync with server
            const serverTask = await api.createTask(task);
            
            // 3. Update with server response
            await database.updateTask({ ...task, id: serverTask.id, syncStatus: 'synced' });
            uiStore.updateTask(serverTask);
        } catch (error) {
            // 4. Rollback on failure
            await database.deleteTask(task);
            uiStore.removeTask(task);
            
            // Show error to user
            showError('Failed to create task. Please try again.');
        }
    }
    
    async updateTask(id: string, updates: Partial) {
        // Store original for rollback
        const original = await database.getTask(id);
        
        // Optimistic update
        await database.updateTask({ ...original, ...updates });
        uiStore.updateTask({ ...original, ...updates });
        
        try {
            await api.updateTask(id, updates);
            await database.markSynced(id);
        } catch (error) {
            // Rollback
            await database.updateTask(original);
            uiStore.updateTask(original);
            showError('Failed to update task');
        }
    }
}

6. Delta Sync & Pagination

Efficient Incremental Sync

// Delta sync - only fetch changes since last sync
class DeltaSyncService {
    async performDeltaSync() {
        const lastSyncTimestamp = await storage.getLastSyncTime();
        
        // Fetch only changes since last sync
        const changes = await api.getChangesSince(lastSyncTimestamp);
        
        for (const change of changes) {
            switch (change.type) {
                case 'created':
                    await database.insertTask(change.data);
                    break;
                case 'updated':
                    await database.updateTask(change.data);
                    break;
                case 'deleted':
                    await database.deleteTask(change.data.id);
                    break;
            }
        }
        
        // Update last sync timestamp
        await storage.setLastSyncTime(Date.now());
        
        // Push local changes
        await this.pushLocalChanges();
    }
    
    async pushLocalChanges() {
        const pendingTasks = await database.getPendingTasks();
        
        for (const task of pendingTasks) {
            try {
                await api.updateTask(task.id, task);
                await database.markSynced(task.id);
            } catch (error) {
                console.error('Failed to push task:', task.id, error);
            }
        }
    }
}

7. Best Practices

✓ Offline-First Checklist:

Conclusion

Building offline-first mobile apps requires careful planning of data architecture, synchronization strategy, and conflict resolution. By implementing robust local storage, intelligent sync mechanisms, and clear user feedback, you can create apps that work seamlessly regardless of network conditions, providing superior user experience and reliability.

💡 Pro Tip: Start with simple Last-Write-Wins strategy for most use cases. Only implement complex OT or CRDT solutions when you have real-time collaborative editing requirements. Test your sync logic thoroughly with various network conditions using tools like Charles Proxy or Network Link Conditioner.