Skip to main content
โšก Calmops

Offline-First Mobile Architecture: Sync, Conflict Resolution

Offline-First Mobile Architecture: Sync, Conflict Resolution

TL;DR: This guide covers offline-first mobile architecture. Learn local storage, sync strategies, conflict resolution, and building reliable mobile apps.


Introduction

Mobile applications face a fundamental challenge that web applications rarely encounter: unreliable network connectivity. Users access mobile apps on subways, in elevators, in rural areas with poor coverage, and during network outages. An app that only works when connected to the internet provides a frustrating user experience that leads to abandonment and negative reviews.

The offline-first architecture pattern addresses this challenge by designing applications to work primarily with local data, treating network connectivity as an enhancement rather than a requirement. Rather than building apps that require constant server communication, offline-first apps store data locally and synchronize with servers when connectivity is available.

This approach transforms the user experience. Actions complete instantly because they don’t wait for network requests. Apps remain functional during outages, maintaining productivity. Users see immediate feedback on their actions, building confidence in the application. When connectivity returns, synchronization happens automatically in the background.

This comprehensive guide explores the technical foundations of offline-first mobile architecture, covering local storage options, synchronization strategies, conflict resolution approaches, and implementation patterns for building reliable offline-capable mobile applications.

Understanding Offline-First Architecture

The Offline-First Philosophy

Offline-first architecture is more than just local data storageโ€”it’s a fundamental rethinking of how mobile applications interact with data. In traditional online-first applications, the server is the source of truth, and local data is merely a cache. In offline-first applications, local data is the primary store, and the server becomes a synchronization partner.

This philosophical shift has profound implications for application design. Rather than optimizing for server interactions, offline-first apps optimize for local performance. Rather than assuming data is fresh, offline-first apps show local data immediately and update in the background. Rather than treating conflicts as exceptional cases to avoid, offline-first apps embrace conflicts as natural consequences of distributed data.

The benefits extend beyond user experience. Offline-first apps reduce server load by batching synchronization. They improve scalability by doing more computation on devices. They enable new use cases like field work applications where connectivity is unreliable.

Core Principles

The offline-first approach rests on four foundational principles that guide architectural decisions:

Local First: The application reads and writes to local storage by default. All features work without network connectivity. The local database is the primary source of truth for the application’s UI.

Sync Later: Changes made locally are queued for synchronization with the server. This happens automatically when connectivity is available, but also can be triggered manually or scheduled. The user doesn’t need to think about synchronization.

Conflict Resolution: When the same data changes in multiple places, conflicts must be resolved systematically. The architecture must have clear rules for handling these situations, whether through automatic resolution or user involvement.

Eventual Consistency: The system aims for convergenceโ€”over time, all devices should agree on the same data state. This doesn’t happen instantly, but given enough time and connectivity, the data should become consistent across all clients and servers.

Local Storage Options

SQLite and Relational Storage

SQLite provides the most robust local storage option for mobile applications. As a full-featured relational database, SQLite supports complex queries, transactions, and data integrity constraints. The database resides in a single file, making it easy to back up and manage.

For React Native, libraries like react-native-sqlite-storage and expo-sqlite provide access to SQLite. For native iOS development, SQLite integrates directly through the C API or through wrapper libraries like GRDB.swift. Android provides SQLite through the Room persistence library, which adds compile-time verification and migration support.

SQLite excels when applications have structured data with complex relationships. Applications managing contacts, tasks, projects, or any data with many fields benefit from SQLite’s query capabilities. The database handles large datasets efficiently, with indexes for fast lookups and transactions for atomic operations.

Solution Type Use Case Platform Support
SQLite Relational DB Structured data iOS, Android, RN
Realm Object DB Complex objects iOS, Android, RN
WatermelonDB Reactive DB React Native React Native
AsyncStorage Key-value Simple data iOS, Android, RN
SecureStore Encrypted storage Secrets iOS, Android, RN

Realm Database

Realm provides an object database that maps directly to native objects. Rather than converting between database rows and application objects, Realm works with native objects that feel natural in each platform’s language.

For React Native, Realm offers cross-platform object storage with reactive capabilities. The library provides live objects that automatically update when the database changes, enabling reactive UI updates without manual observation.

Realm’s synchronization product, Realm Sync, extends the database with built-in synchronization capabilities. This combines local storage and sync in a single solution, simplifying implementation for applications that can use the managed service.

WatermelonDB for React Native

WatermelonDB is designed specifically for React Native applications requiring offline-first capabilities. The library emphasizes performance with lazy loading and efficient queries that don’t block the UI thread.

WatermelonDB uses an observer pattern for reactive data, making it integrate naturally with React’s component model. Changes to the database automatically propagate to observing components, enabling real-time UI updates without manual state management.

The synchronization layer supports various sync strategies, including pull-only and bidirectional sync. The library handles conflict resolution and provides hooks for custom resolution logic.

Key-Value Storage

For simpler data needs, key-value storage provides straightforward local persistence. React Native’s AsyncStorage offers a simple API for storing string values, with wrappers for handling JSON serialization.

AsyncStorage suits preferences, flags, and simple configuration data. It’s inappropriate for structured data or large datasets due to its simple key-value nature, but valuable for tokens, settings, and cached values that don’t require database queries.

SecureStorage extends key-value storage with encryption. Sensitive data like authentication tokens should use encrypted storage rather than plain AsyncStorage. Both platforms provide secure storage mechanisms: iOS Keychain and Android EncryptedSharedPreferences.

Synchronization Strategies

Change Tracking

Effective synchronization requires knowing what changed since the last sync. Several approaches track changes:

Timestamps mark records with their last modification time. During sync, the client sends its last sync time, and the server returns all records modified since then. This approach is simple but requires reliable clocks and cannot detect deleted records without additional tracking.

Version numbers increment with each change. Sync requests the version number of the client’s data and retrieves records with higher versions. This approach handles deletions by including a “tombstone” record marking the deletion.

Change logs maintain explicit records of every modification. The server records every insert, update, and delete in a log. Clients can request all changes since their last sync point. This approach handles all edge cases but requires more server-side infrastructure.

// Timestamp-based change tracking
class ChangeTracker {
  constructor(database) {
    this.database = database;
    this.lastSyncTime = null;
  }

  async getChanges() {
    const timestamp = await this.getLastSyncTime();
    
    const changes = await this.database.query(
      'SELECT * FROM records WHERE updated_at > ?',
      [timestamp]
    );
    
    const deletions = await this.database.query(
      'SELECT * FROM deletions WHERE deleted_at > ?',
      [timestamp]
    );
    
    return { changes, deletions };
  }

  async getLastSyncTime() {
    const record = await this.database.get(
      'SELECT value FROM metadata WHERE key = ?',
      ['last_sync']
    );
    return record ? record.value : 0;
  }

  async updateLastSyncTime() {
    await this.database.execute(
      'INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)',
      ['last_sync', Date.now()]
    );
  }
}

Pull and Push Synchronization

Synchronization involves two directions: pulling changes from the server and pushing local changes to the server. The order and timing of these operations affects data consistency.

Pull-then-push retrieves server changes first, applies them locally, then pushes local changes. This ensures local changes don’t overwrite server changes, but may overwrite local changes if conflicts occur.

Push-then-pull sends local changes first, then retrieves server changes. This prioritizes local user actions but may cause more conflicts.

Bidirectional sync interleaves operations, often multiple rounds of push and pull, until both sides converge. This handles complex conflict scenarios but requires more sophisticated implementation.

class SyncService {
  constructor(api, database, conflictResolver) {
    this.api = api;
    this.database = database;
    this.conflictResolver = conflictResolver;
  }

  async sync() {
    // Pull changes from server
    await this.pullChanges();
    
    // Push local changes
    await this.pushChanges();
    
    // Update sync timestamp
    await this.updateSyncTimestamp();
  }

  async pullChanges() {
    const serverChanges = await this.api.getChanges(
      this.lastSyncTimestamp
    );

    await this.database.write(async () => {
      for (const serverRecord of serverChanges) {
        const localRecord = await this.findLocal(serverRecord.id);
        
        if (!localRecord) {
          // Record only exists on server - create locally
          await this.createLocal(serverRecord);
        } else {
          // Record exists both places - potentially conflict
          const resolved = await this.conflictResolver.resolve(
            localRecord,
            serverRecord
          );
          await this.updateLocal(resolved);
        }
      }
    });
  }

  async pushChanges() {
    const pendingChanges = await this.getPendingLocalChanges();
    
    for (const localRecord of pendingChanges) {
      try {
        await this.api.updateRecord(localRecord);
        await this.markAsSynced(localRecord);
      } catch (error) {
        if (error.conflict) {
          // Handle push conflict
          const serverRecord = error.serverRecord;
          const resolved = await this.conflictResolver.resolve(
            localRecord,
            serverRecord
          );
          await this.api.updateRecord(resolved);
          await this.updateLocal(resolved);
        } else {
          throw error;
        }
      }
    }
  }
}

Conflict Resolution

Understanding Conflicts

Conflicts occur when the same record changes in multiple places between synchronization. In an offline-first system, this happens frequentlyโ€”users make changes on their devices while offline, and those changes may conflict with changes made by other users or on other devices.

Conflicts aren’t failuresโ€”they’re natural consequences of distributed data. A well-designed system handles conflicts gracefully without data loss or user frustration. The key is having clear resolution strategies and communicating transparently with users when their attention is needed.

Resolution Strategies

Last-Write-Wins (LWW): The simplest approach uses timestamps to determine which change is " newer." The most recent change wins. This approach is easy to implement but may lose data. A change made moments before going offline might be overwritten by an earlier change from another device.

Server-Wins: Always prefer server data over local data. This protects against malicious or buggy client code but may frustrate users who see their offline changes disappear.

Local-Wins: Always prefer local data. This keeps users’ changes but may lose server updates. Useful for ephemeral data like draft content.

Field-Level Merge: Compare individual fields rather than entire records. If fields don’t overlap, keep changes from both. If they do, apply a field-level resolution strategy.

User Resolution: Present conflicts to users for manual resolution. This preserves all data but requires UI for resolution. Best for important data where automated resolution risks data loss.

class ConflictResolver {
  // Last-write-wins strategy
  resolveLastWriteWins(local, server) {
    return local.updatedAt > server.updatedAt ? local : server;
  }

  // Server-wins strategy
  resolveServerWins(local, server) {
    return server;
  }

  // Field-level merge
  resolveFieldMerge(local, server) {
    const merged = { ...server };
    
    for (const key of Object.keys(local)) {
      if (key === 'id' || key === 'updatedAt') continue;
      
      const localValue = local[key];
      const serverValue = server[key];
      
      if (localValue !== serverValue) {
        // Prefer whichever was more recently modified
        if (local.updatedAt > server.updatedAt) {
          merged[key] = localValue;
        }
      }
    }
    
    return merged;
  }

  // Custom resolution based on record type
  resolve(local, server) {
    const resolutionStrategy = this.getStrategyForType(local.type);
    
    switch (resolutionStrategy) {
      case 'last_write_wins':
        return this.resolveLastWriteWins(local, server);
      case 'server_wins':
        return this.resolveServerWins(local, server);
      case 'field_merge':
        return this.resolveFieldMerge(local, server);
      case 'user':
        return { needsResolution: true, local, server };
      default:
        return this.resolveLastWriteWins(local, server);
    }
  }

  getStrategyForType(type) {
    // Different types may use different strategies
    const strategies = {
      'task': 'field_merge',
      'comment': 'last_write_wins',
      'settings': 'server_wins',
      'document': 'user'
    };
    return strategies[type] || 'last_write_wins';
  }
}

Operational Transformation for Real-Time

For applications requiring real-time collaboration, operational transformation (OT) or conflict-free replicated data types (CRDTs) provide sophisticated conflict resolution. These approaches, used by Google Docs and similar applications, allow simultaneous editing without locking.

OT transforms operations so they can be applied in any order and produce the same result. CRDTs are data structures designed for distributed systems where concurrent modifications can be merged automatically.

For most mobile applications, simpler strategies suffice. Real-time collaboration features require significant implementation complexity and are typically provided by specialized services rather than built from scratch.

React Native Implementation

Setting Up WatermelonDB

WatermelonDB provides an excellent foundation for offline-first React Native applications. The setup involves installing dependencies, configuring native modules, and defining the schema.

// Database schema definition
import { appSchema, tableSchema } from '@nozbe/watermelondb';

export const schema = appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'tasks',
      columns: [
        { name: 'title', type: 'string' },
        { name: 'completed', type: 'boolean' },
        { name: 'server_id', type: 'string', isOptional: true },
        { name: 'sync_status', type: 'string' }, // synced, pending, conflict
        { name: 'local_created_at', type: 'number' },
        { name: 'local_updated_at', type: 'number' },
        { name: 'server_updated_at', type: 'number', isOptional: true },
      ],
    }),
  ],
});

Model Definitions

// Task model
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators';

export default class Task extends Model {
  static table = 'tasks';

  @field('title') title;
  @field('completed') completed;
  @field('server_id') serverId;
  @field('sync_status') syncStatus;
  @field('local_created_at') localCreatedAt;
  @field('local_updated_at') localUpdatedAt;
  @field('server_updated_at') serverUpdatedAt;

  async markComplete() {
    await this.update(task => {
      task.completed = true;
      task.localUpdatedAt = Date.now();
      task.syncStatus = 'pending';
    });
  }
}

Sync Implementation

import Sync from '@nozbe/watermelondb/Sync';

class SyncService {
  constructor(database, api) {
    this.database = database;
    this.api = api;
  }

  async sync() {
    try {
      // Pull changes from server
      await this.pullChanges();
      
      // Push local changes
      await this.pushChanges();
      
    } catch (error) {
      console.error('Sync failed:', error);
      throw error;
    }
  }

  async pullChanges() {
    const lastSync = await this.getLastSyncTime();
    const changes = await this.api.getChanges(lastSync);
    
    await this.database.write(async () => {
      for (const record of changes) {
        await this.upsertRecord(record);
      }
    });
  }

  async pushChanges() {
    const pendingRecords = await this.database
      .get('tasks')
      .query(Q.where('sync_status', 'pending'))
      .fetch();
    
    for (const record of pendingRecords) {
      try {
        await this.api.saveRecord(record);
        await this.markAsSynced(record);
      } catch (error) {
        if (error.conflict) {
          await this.handleConflict(record, error.serverRecord);
        } else {
          throw error;
        }
      }
    }
  }

  async handleConflict(local, server) {
    // Use last-write-wins or other strategy
    const winner = local.localUpdatedAt > server.serverUpdatedAt 
      ? local 
      : server;
    
    await this.updateRecord(winner, 'synced');
  }
}

Building Robust Offline Applications

Network Detection

Effective offline-first apps respond to network changes, triggering sync when connectivity returns. Both platforms provide network information APIs.

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

useEffect(() => {
  const unsubscribe = NetInfo.addEventListener(state => {
    if (state.isConnected) {
      // Network available - trigger sync
      triggerSync();
    } else {
      // Network unavailable - UI already shows offline state
    }
  });

  return unsubscribe;
}, []);

Background Sync

For applications requiring background synchronization, both platforms provide background task APIs. These enable sync even when the app isn’t in the foreground.

import { AppState } from 'react-native';

useEffect(() => {
  const handleAppStateChange = (nextAppState) => {
    if (nextAppState === 'active') {
      triggerSync();
    }
  };

  const subscription = AppState.addEventListener(
    'change', 
    handleAppStateChange
  );

  return () => subscription.remove();
}, []);

Offline UI Indicators

Users should understand when they’re working offline and when their changes will sync. Provide clear indicators of connection status and sync progress.

const OfflineIndicator = () => {
  const [isConnected, setIsConnected] = useState(true);
  const [isSyncing, setIsSyncing] = useState(false);

  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener(state => {
      setIsConnected(state.isConnected);
    });
    return unsubscribe;
  }, []);

  if (isSyncing) {
    return <SyncProgressIndicator />;
  }

  if (!isConnected) {
    return <OfflineBadge />;
  }

  return null;
};

Conclusion

Offline-first architecture transforms mobile user experience by ensuring applications work reliably regardless of network conditions. The key components include robust local storage for primary data operations, systematic change tracking for efficient synchronization, thoughtful conflict resolution strategies, and responsive UI that reflects connectivity state.

Implementing offline-first capabilities requires careful consideration of your application’s specific needs. Not all applications require full offline-first implementationโ€”some can benefit from simpler caching strategies. However, for applications where user productivity depends on reliable access, the offline-first approach delivers significant user experience improvements.

The patterns and implementations covered in this guide provide a foundation for building robust offline-capable mobile applications. Start with local storage, add synchronization, implement conflict resolution, and build the UI indicators that keep users informed. The result is an application that works when users need it.

Comments