Offline-First Mobile Apps Strategy
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:
- Choose appropriate local database (SQLite, Realm, WatermelonDB)
- Implement network state monitoring
- Use queue system for pending operations
- Add timestamps to all entities for conflict detection
- Implement optimistic UI updates for better UX
- Provide clear sync status indicators
- Handle conflicts gracefully (auto-resolve or user input)
- Use delta sync to minimize data transfer
- Implement proper error handling and retry logic
- Cache images and assets locally
- Test extensively in offline/poor network conditions
- Provide manual sync option for users
- Clear old data to manage storage efficiently
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.