Skip to main content

Building Offline-First Mobile Apps: Strategies and Implementation

Created: February 23, 2026 Larry Qu 15 min read

Introduction

Offline-first apps work without internet and sync when connectivity is restored. This architectural approach prioritizes local data as the primary source of truth, treating the server as a synchronization target rather than the authoritative data store. In 2026, offline-first has become essential for mobile applications serving users in areas with unreliable connectivity, on airplanes, in subways, or in remote locations. This guide covers architecture patterns, storage options, sync strategies, conflict resolution, and implementation approaches for building robust offline-capable mobile applications.

Offline-First Architecture

Core Principles

The offline-first approach is built on several key principles that guide architectural decisions:

  1. Local is primary — The local database is the source of truth. The UI reads from and writes to local storage.
  2. Network is optional — Remote APIs are called asynchronously when connectivity is available.
  3. Sync is eventual — Data converges across devices over time; immediate consistency is not guaranteed.
  4. Conflict is expected — The system must handle concurrent edits without data loss.
  5. User is in control — Users should understand sync status and have recourse for conflicts.

Architecture Flow

flowchart LR
    UI[UI Layer] --> LS[Local Store]
    LS --> SE[Sync Engine]
    SE --> RC[Remote API]
    RC --> SE
    SE --> LS
    LS --> UI
    
    NC[Network Monitor] --> SE
    
    style LS fill:#e1f5fe
    style SE fill:#fff3e0
    style RC fill:#f3e5f5

Data Flow Sequence

sequenceDiagram
    participant U as User
    participant UI as UI Layer
    participant LS as Local Store
    participant SE as Sync Engine
    participant RA as Remote API
    
    U->>UI: Create Post
    UI->>LS: Save locally
    LS-->>UI: Confirm saved
    UI-->>U: Show in UI
    
    Note over SE: Background sync
    SE->>LS: Read pending changes
    LS-->>SE: Pending posts
    SE->>RA: Sync to server
    RA-->>SE: Server IDs
    SE->>LS: Update sync status
    LS-->>UI: Updated data

Local Storage Options

AsyncStorage (React Native)

AsyncStorage provides simple key-value persistence for React Native. It is unencrypted and asynchronous, suitable for small amounts of non-sensitive data:

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

const STORAGE_KEYS = {
  USER_PREFERENCES: '@user_preferences',
  RECENT_SEARCHES: '@recent_searches',
  DRAFT_POST: '@draft_post',
};

// Save with JSON serialization
async function saveUserPreferences(prefs: UserPreferences): Promise<void> {
  try {
    await AsyncStorage.setItem(
      STORAGE_KEYS.USER_PREFERENCES,
      JSON.stringify(prefs)
    );
  } catch (error) {
    console.error('Failed to save preferences:', error);
  }
}

// Load with JSON parsing
async function loadUserPreferences(): Promise<UserPreferences | null> {
  try {
    const json = await AsyncStorage.getItem(STORAGE_KEYS.USER_PREFERENCES);
    return json ? JSON.parse(json) : null;
  } catch (error) {
    console.error('Failed to load preferences:', error);
    return null;
  }
}

// Batch operations for performance
async function saveRecentSearches(searches: string[]): Promise<void> {
  const pairs = searches.map((search, index) => [
    `${STORAGE_KEYS.RECENT_SEARCHES}_${index}`,
    search,
  ]);
  await AsyncStorage.multiSet(pairs);
}

// Remove stale items
async function clearExpiredData(): Promise<void> {
  const keys = await AsyncStorage.getAllKeys();
  const expiredKeys = keys.filter((key) => key.startsWith('@draft_'));
  await AsyncStorage.multiRemove(expiredKeys);
}

SQLite with expo-sqlite

SQLite provides a full relational database for complex offline apps. Expo’s expo-sqlite offers a modern async API:

import * as SQLite from 'expo-sqlite';
import { drizzle } from 'drizzle-orm/expo-sqlite';
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';

// Define schema with Drizzle ORM
const posts = sqliteTable('posts', {
  id: text('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content').notNull(),
  status: text('status').default('draft').notNull(),
  createdAt: text('created_at').notNull(),
  updatedAt: text('updated_at').notNull(),
  syncedAt: text('synced_at'),
  serverId: text('server_id'),
  version: integer('version').default(1),
});

// Initialize database
async function initializeDatabase() {
  const db = await SQLite.openDatabaseAsync('app.db');

  await db.execAsync(`
    CREATE TABLE IF NOT EXISTS posts (
      id TEXT PRIMARY KEY,
      title TEXT NOT NULL,
      content TEXT NOT NULL,
      status TEXT DEFAULT 'draft' CHECK(status IN ('draft', 'published', 'archived')),
      created_at TEXT NOT NULL,
      updated_at TEXT NOT NULL,
      synced_at TEXT,
      server_id TEXT UNIQUE,
      version INTEGER DEFAULT 1
    );

    CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
    CREATE INDEX IF NOT EXISTS idx_posts_synced ON posts(synced_at);
  `);

  return db;
}

// CRUD operations
async function createPost(post: NewPost): Promise<Post> {
  const db = await SQLite.openDatabaseAsync('app.db');
  const id = generateUUID();
  const now = new Date().toISOString();

  await db.runAsync(
    `INSERT INTO posts (id, title, content, status, created_at, updated_at)
     VALUES (?, ?, ?, 'draft', ?, ?)`,
    [id, post.title, post.content, now, now]
  );

  return { id, ...post, status: 'draft', createdAt: now, updatedAt: now, version: 1 };
}

async function getUnsyncedPosts(): Promise<Post[]> {
  const db = await SQLite.openDatabaseAsync('app.db');
  const result = await db.getAllAsync<Post>(
    'SELECT * FROM posts WHERE synced_at IS NULL ORDER BY created_at ASC'
  );
  return result;
}

async function markAsSynced(localId: string, serverId: string): Promise<void> {
  const db = await SQLite.openDatabaseAsync('app.db');
  await db.runAsync(
    'UPDATE posts SET synced_at = ?, server_id = ? WHERE id = ?',
    [new Date().toISOString(), serverId, localId]
  );
}

WatermelonDB (React Native)

WatermelonDB is a reactive database designed specifically for offline-first apps. It uses SQLite under the hood with lazy loading and observable queries:

import { Model } from '@nozbe/watermelondb';
import { field, date, readonly, relation, children } from '@nozbe/watermelondb/decorators';
import { Q } from '@nozbe/watermelondb';

// Define models
class Post extends Model {
  static table = 'posts';
  static associations = {
    comments: { type: 'has_many' as const, foreignKey: 'post_id' },
  };

  @field('title') title!: string;
  @field('content') content!: string;
  @field('status') status!: string;
  @date('created_at') createdAt!: Date;
  @date('updated_at') updatedAt!: Date;
  @field('server_id') serverId?: string;
  @readonly @date('created_at') createdAt!: Date;

  @children('comments') comments!: Query<Comment>;
}

class Comment extends Model {
  static table = 'comments';
  static associations = {
    post: { type: 'belongs_to' as const, key: 'post_id' },
  };

  @field('body') body!: string;
  @field('author') author!: string;
  @relation('posts', 'post_id') post!: Relation<Post>;
}

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

export const schema = appSchema({
  version: 3,
  tables: [
    tableSchema({
      name: 'posts',
      columns: [
        { name: 'title', type: 'string' },
        { name: 'content', type: 'string' },
        { name: 'status', type: 'string' },
        { name: 'created_at', type: 'number' },
        { name: 'updated_at', type: 'number' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
    tableSchema({
      name: 'comments',
      columns: [
        { name: 'body', type: 'string' },
        { name: 'author', type: 'string' },
        { name: 'post_id', type: 'string', isIndexed: true },
        { name: 'created_at', type: 'number' },
      ],
    }),
  ],
});

// Reactive queries
import { withObservables } from '@nozbe/with-observables';
import { database } from './database';

function PostList() {
  return (
    <ObservablePostList />
  );
}

const enhance = withObservables([], () => ({
  posts: database.get('posts')
    .query(Q.where('status', Q.notEq('archived')), Q.sortBy('created_at', Q.desc))
    .observe(),
}));

const ObservablePostList = enhance(({ posts }: { posts: Post[] }) => (
  <FlatList
    data={posts}
    renderItem={({ item }) => <PostItem post={item} />}
  />
));

Flutter Storage Options

Flutter provides sqflite for SQLite and drift (formerly Moor) for reactive database access:

import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

// Define tables
class Posts extends Table {
  TextColumn get id => text().withLength(min: 36, max: 36)();
  TextColumn get title => text().withLength(min: 1, max: 500)();
  TextColumn get content => text()();
  TextColumn get status => text().withLength(min: 1, max: 20)();
  DateTimeColumn get createdAt => dateTime()();
  DateTimeColumn get updatedAt => dateTime()();
  TextColumn get serverId => text().nullable()();
  IntColumn get version => integer().withDefault(const Constant(1))();

  @override
  Set<Column> get primaryKey => {id};
}

// Generate database code
@DriftDatabase(tables: [Posts])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  // Reactive queries
  Stream<List<Post>> watchUnsyncedPosts() {
    return (select(posts)..where((t) => t.serverId.isNull())).watch();
  }

  Future<void> markSynced(String localId, String serverId) async {
    await (update(posts)
      ..where((t) => t.id.equals(localId))
    ).write(PostsCompanion(
      serverId: Value(serverId),
      updatedAt: Value(DateTime.now()),
    ));
  }
}

LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    final dir = await getApplicationDocumentsDirectory();
    final file = File(p.join(dir.path, 'app.db'));
    return NativeDatabase(file);
  });
}

Storage Comparison

Feature AsyncStorage SQLite WatermelonDB Drift
Type Key-value Relational ORM over SQLite ORM over SQLite
Query capability None Full SQL Chainable queries SQL/Composable
Reactivity Manual Polling Observable Streams
Batch operations multiSet/multiRemove Transactions Batch Batch
Encryption No sqlcipher Optional No
Bundle size ~14 KB ~200 KB ~150 KB ~100 KB
Ideal use case Settings, cache Full data Complex offline Flutter apps

Sync Strategies

Optimistic Updates

Save changes locally first, then sync to the server in the background. This provides instant feedback to users:

async function createPost(title: string, content: string) {
  // 1. Generate local ID
  const localId = generateUUID();
  const now = new Date().toISOString();

  // 2. Save to local DB immediately
  await database.write(async (writer) => {
    await writer.create('posts', (post) => {
      post._raw.id = localId;
      post.title = title;
      post.content = content;
      post.status = 'syncing';
      post.createdAt = now;
      post.updatedAt = now;
    });
  });

  // 3. Update UI immediately with optimistic data
  addPostToUI({ id: localId, title, content, status: 'syncing' });

  // 4. Attempt server sync
  try {
    const response = await api.createPost({ title, content });
    
    // 5. Update local with server data
    await database.write(async (writer) => {
      const post = await writer.find('posts', localId);
      await post.update((p) => {
        p.serverId = response.id;
        p.status = 'synced';
        p.updatedAt = new Date().toISOString();
      });
    });

    // 6. Patch UI with server ID
    updatePostInUI(localId, { serverId: response.id, status: 'synced' });
  } catch (error) {
    // 7. Mark as pending for retry
    await database.write(async (writer) => {
      const post = await writer.find('posts', localId);
      await post.update((p) => {
        p.status = 'pending';
        p.errorCount++;
      });
    });

    updatePostInUI(localId, { status: 'pending' });
  }
}

Background Sync

Use background tasks to sync data when the app is in the background or between sessions:

import * as BackgroundFetch from 'expo-background-fetch';
import * as TaskManager from 'expo-task-manager';
import NetInfo from '@react-native-community/netinfo';

const SYNC_TASK = 'BACKGROUND_SYNC';

TaskManager.defineTask(SYNC_TASK, async () => {
  try {
    const netState = await NetInfo.fetch();
    if (!netState.isConnected) {
      return BackgroundFetch.BackgroundFetchResult.NoData;
    }

    const unsyncedItems = await getUnsyncedItems();
    if (unsyncedItems.length === 0) {
      return BackgroundFetch.BackgroundFetchResult.NoData;
    }

    await processSyncQueue(unsyncedItems);
    return BackgroundFetch.BackgroundFetchResult.NewData;
  } catch (error) {
    console.error('Background sync failed:', error);
    return BackgroundFetch.BackgroundFetchResult.Failed;
  }
});

async function registerBackgroundSync() {
  const status = await BackgroundFetch.getStatusAsync();
  if (status === BackgroundFetch.BackgroundFetchStatus.Available) {
    await BackgroundFetch.registerTaskAsync(SYNC_TASK, {
      minimumInterval: 15 * 60, // 15 minutes
      stopOnTerminate: false,
      startOnBoot: true,
    });
  }
}

Push-Based Sync

For near-real-time sync, use push notifications or WebSocket connections to trigger sync when the server has new data:

import { useEffect } from 'react';
import io from 'socket.io-client';

function useRealtimeSync(userId: string) {
  useEffect(() => {
    const socket = io(SYNC_SERVER_URL, {
      auth: { token: authToken },
      transports: ['websocket'],
    });

    socket.on('connect', () => {
      socket.emit('subscribe', { userId });
    });

    socket.on('data_changed', async (payload: SyncPayload) => {
      // Trigger incremental sync for changed data
      await incrementalSync(payload.collection, payload.ids);
    });

    socket.on('conflict', (conflict: ConflictEvent) => {
      // Handle conflict resolution
      handleConflict(conflict);
    });

    return () => {
      socket.disconnect();
    };
  }, [userId]);
}

async function incrementalSync(collection: string, ids: string[]) {
  try {
    const response = await api.getChanges(collection, ids);
    await database.write(async (writer) => {
      for (const change of response.changes) {
        const record = await writer.find(collection, change.id);
        if (record) {
          await record.update((r) => Object.assign(r, change.data));
        } else {
          await writer.create(collection, (r) => Object.assign(r, change));
        }
      }
    });
  } catch (error) {
    console.error('Incremental sync failed:', error);
  }
}

Conflict Resolution

Conflict Strategies

When both local and server state have changed since the last sync, conflicts must be resolved:

Strategy Behavior Use Case Data Loss Risk
Last Write Wins (LWW) Most recent timestamp wins Non-critical data like view count Low
Client Wins Local changes override server Drafts, user preferences Low
Server Wins Server changes override local Inventory counts, banned content Medium
Three-Way Merge Merge based on original base Collaborative documents Low (with CRDT)
Custom Merge Application-specific logic Complex financial data Configurable
Manual Resolution User chooses which version wins Legal documents, contracts None

Implementation of Conflict Resolution

interface SyncConflict<T> {
  localRecord: T;
  serverRecord: T;
  baseRecord: T;
  collection: string;
}

// Three-way merge using original base state
function threeWayMerge<T extends Record<string, any>>(
  conflict: SyncConflict<T>
): T {
  const { localRecord, serverRecord, baseRecord } = conflict;
  const merged = { ...serverRecord };

  for (const key of Object.keys(localRecord)) {
    // Skip metadata fields
    if (['updatedAt', 'version', 'syncedAt'].includes(key)) continue;

    const localChanged = localRecord[key] !== baseRecord[key];
    const serverChanged = serverRecord[key] !== baseRecord[key];

    if (localChanged && !serverChanged) {
      // Only local changed — use local value
      merged[key] = localRecord[key];
    } else if (localChanged && serverChanged) {
      // Both changed — mark for manual resolution or use strategy
      merged[key] = resolveConflict(key, localRecord[key], serverRecord[key]);
    }
    // If only server changed, keep server value (default)
  }

  return merged;
}

// Strategy-based resolver
function resolveConflict(
  key: string,
  localValue: any,
  serverValue: any
): any {
  const conflictRules = {
    title: 'latest', // Use most recent timestamp
    quantity: 'sum', // Add both changes
    status: 'priority', // Most restrictive wins
  };

  switch (conflictRules[key] || 'latest') {
    case 'latest':
      return localValue; // Local wins
    case 'sum':
      return localValue + serverValue;
    case 'priority':
      return getHighestPriority(localValue, serverValue);
    default:
      return serverValue;
  }
}

CRDT (Conflict-Free Replicated Data Types)

CRDTs provide mathematical guarantees that concurrent edits can be merged without conflicts, making them ideal for collaborative offline-first apps:

// CmRDT (Operation-based) counter
class GCounter {
  private counts: Map<string, number> = new Map();

  increment(nodeId: string): void {
    this.counts.set(nodeId, (this.counts.get(nodeId) || 0) + 1);
  }

  merge(other: GCounter): void {
    for (const [node, count] of other.counts) {
      const current = this.counts.get(node) || 0;
      this.counts.set(node, Math.max(current, count));
    }
  }

  get value(): number {
    let total = 0;
    for (const count of this.counts.values()) {
      total += count;
    }
    return total;
  }
}

// LWW-Register (Last Writer Wins Register)
class LWWRegister<T> {
  private value: T;
  private timestamp: number;
  private nodeId: string;

  constructor(value: T, nodeId: string) {
    this.value = value;
    this.timestamp = Date.now();
    this.nodeId = nodeId;
  }

  set(newValue: T): void {
    this.value = newValue;
    this.timestamp = Date.now();
  }

  merge(other: LWWRegister<T>): void {
    if (other.timestamp > this.timestamp ||
        (other.timestamp === this.timestamp && other.nodeId > this.nodeId)) {
      this.value = other.value;
      this.timestamp = other.timestamp;
    }
  }
}

Network State Monitoring

Monitor connectivity changes to trigger sync operations and update the UI:

import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { useEffect, useState } from 'react';

interface NetworkState {
  isConnected: boolean;
  isInternetReachable: boolean | null;
  connectionType: string;
}

function useNetworkStatus() {
  const [networkState, setNetworkState] = useState<NetworkState>({
    isConnected: true,
    isInternetReachable: true,
    connectionType: 'unknown',
  });

  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
      setNetworkState({
        isConnected: !!state.isConnected,
        isInternetReachable: state.isInternetReachable,
        connectionType: state.type,
      });

      // Trigger sync when connectivity is restored
      if (state.isConnected && state.isInternetReachable) {
        performSync();
      }
    });

    return () => unsubscribe();
  }, []);

  return networkState;
}

// Show offline indicator
function OfflineBanner() {
  const { isConnected } = useNetworkStatus();

  if (isConnected) return null;

  return (
    <SafeAreaView style={styles.bannerContainer}>
      <View style={styles.banner}>
        <Icon name="wifi-off" size={16} color="#FFF" />
        <Text style={styles.bannerText}>
          You're offline. Changes will sync when connected.
        </Text>
      </View>
    </SafeAreaView>
  );
}

Sync Queue Management

Manage a queue of pending operations that need to be synced to the server:

interface SyncOperation {
  id: string;
  type: 'create' | 'update' | 'delete';
  collection: string;
  recordId: string;
  data: any;
  timestamp: number;
  retryCount: number;
  maxRetries: number;
}

class SyncQueue {
  private queue: SyncOperation[] = [];
  private isProcessing = false;

  async enqueue(operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> {
    const entry: SyncOperation = {
      ...operation,
      id: generateUUID(),
      timestamp: Date.now(),
      retryCount: 0,
      maxRetries: 5,
    };

    await AsyncStorage.setItem(
      `sync_${entry.id}`,
      JSON.stringify(entry)
    );
  }

  async processQueue(): Promise<void> {
    if (this.isProcessing) return;
    this.isProcessing = true;

    try {
      const operations = await this.loadQueue();
      
      for (const operation of operations) {
        try {
          await this.executeOperation(operation);
          await this.removeFromQueue(operation.id);
        } catch (error) {
          if (operation.retryCount >= operation.maxRetries) {
            await this.markAsFailed(operation);
          } else {
            await this.scheduleRetry(operation);
          }
        }
      }
    } finally {
      this.isProcessing = false;
    }
  }

  private async executeOperation(operation: SyncOperation): Promise<void> {
    switch (operation.type) {
      case 'create':
        await api.create(operation.collection, operation.data);
        break;
      case 'update':
        await api.update(operation.collection, operation.recordId, operation.data);
        break;
      case 'delete':
        await api.delete(operation.collection, operation.recordId);
        break;
    }
  }
}

// Exponential backoff retry
async function retryWithBackoff(operation: SyncOperation): Promise<void> {
  const delay = Math.min(
    1000 * Math.pow(2, operation.retryCount),
    60000 // Max 60 seconds
  );

  await AsyncStorage.setItem(
    `sync_retry_${operation.id}`,
    JSON.stringify({
      operationId: operation.id,
      retryAt: Date.now() + delay,
    })
  );
}

Data Integrity and Validation

Ensure data consistency during sync operations:

// Version-based conflict detection
async function updateWithVersionCheck(
  recordId: string,
  updates: Partial<Record>,
  expectedVersion: number
): Promise<boolean> {
  const currentState = await database.get('records', recordId);

  if (currentState.version !== expectedVersion) {
    throw new ConflictError(
      'Version mismatch',
      currentState,
      updates
    );
  }

  await database.write(async (writer) => {
    const record = await writer.find('records', recordId);
    await record.update((r) => ({
      ...r,
      ...updates,
      version: expectedVersion + 1,
    }));
  });

  return true;
}

// Checksum validation
async function validateSyncPayload(payload: SyncPayload): Promise<boolean> {
  const checksum = await computeChecksum(payload.data);
  return checksum === payload.checksum;
}

async function computeChecksum(data: any): Promise<string> {
  const json = JSON.stringify(data, Object.keys(data).sort());
  const encoder = new TextEncoder();
  const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(json));
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}

Testing Offline-First Apps

Unit Testing Sync Logic

import { renderHook, act } from '@testing-library/react-hooks';
import { SyncQueue } from './syncQueue';

describe('SyncQueue', () => {
  it('processes operations in order', async () => {
    const queue = new SyncQueue();
    const mockApi = jest.spyOn(api, 'create');

    await queue.enqueue({
      type: 'create',
      collection: 'posts',
      recordId: 'local-1',
      data: { title: 'Test' },
    });

    await queue.processQueue();

    expect(mockApi).toHaveBeenCalledWith('posts', { title: 'Test' });
  });

  it('retries failed operations with backoff', async () => {
    const queue = new SyncQueue();
    jest.spyOn(api, 'create').mockRejectedValue(new Error('Network error'));

    await queue.enqueue({
      type: 'create',
      collection: 'posts',
      recordId: 'local-1',
      data: { title: 'Test' },
    });

    await queue.processQueue();

    // Should have scheduled retry
    const retryData = await AsyncStorage.getItem('sync_retry_local-1');
    expect(retryData).not.toBeNull();
  });
});

Integration Testing

import { render, waitFor, fireEvent } from '@testing-library/react-native';

describe('Offline Post Creation', () => {
  it('creates post locally and syncs when online', async () => {
    jest.useFakeTimers();
    
    const { getByText, getByPlaceholderText } = render(<App />);

    // Simulate offline
    jest.spyOn(NetInfo, 'fetch').mockResolvedValue({
      isConnected: false,
      isInternetReachable: false,
    });

    // Create post while offline
    fireEvent.changeText(getByPlaceholderText('Title'), 'Offline Post');
    fireEvent.press(getByText('Save'));

    // Post should appear in UI immediately
    await waitFor(() => {
      expect(getByText('Offline Post')).toBeTruthy();
    });

    // Simulate coming back online
    jest.spyOn(NetInfo, 'fetch').mockResolvedValue({
      isConnected: true,
      isInternetReachable: true,
    });

    // Should sync automatically
    jest.advanceTimersByTime(5000);

    await waitFor(() => {
      expect(getByText('Synced')).toBeTruthy();
    });
  });
});

Performance Optimization

Debouncing Sync Operations

Batch rapid changes to reduce sync frequency:

import { debounce } from 'lodash';

const debouncedSync = debounce(async () => {
  const unsyncedItems = await getUnsyncedItems();
  if (unsyncedItems.length > 0) {
    await syncEngine.sync(unsyncedItems);
  }
}, 2000, { maxWait: 10000 });

// Call on any local data change
function onDataChanged() {
  debouncedSync();
}

Selective Sync

Only sync data that the user needs immediately:

async function selectiveSync(syncScope: SyncScope): Promise<void> {
  const syncConfig = {
    [SyncScope.RECENT]: {
      fetchLimit: 50,
      timeRange: '7d',
    },
    [SyncScope.FULL]: {
      fetchLimit: 10000,
      timeRange: 'all',
    },
    [SyncScope.MINIMAL]: {
      fetchLimit: 10,
      timeRange: '1d',
    },
  };

  const config = syncConfig[syncScope];
  const changes = await api.getChanges(config);
  
  await database.write(async (writer) => {
    for (const change of changes) {
      // Apply selective sync with priority
      await applyChange(writer, change);
    }
  });
}

Conclusion

Offline-first architecture is essential for modern mobile applications that need to work reliably regardless of connectivity. By treating local storage as the primary source of truth, applications provide instant feedback, seamless offline experiences, and robust sync capabilities.

Key takeaways for building offline-first applications:

  • Choose the right storage layer for your data complexity — AsyncStorage for simple data, SQLite/WatermelonDB for complex relational data
  • Implement optimistic updates for instant user feedback
  • Use background sync with exponential backoff for reliable data transfer
  • Choose a conflict resolution strategy appropriate for your data sensitivity
  • Monitor network state and provide clear offline/online indicators
  • Test thoroughly — simulate offline scenarios, connectivity transitions, and sync failures

The investment in offline-first architecture pays dividends in user satisfaction, engagement, and reliability. Users expect applications to work regardless of connectivity, and meeting this expectation differentiates great applications from frustrating ones.

Resources

Comments

👍 Was this article helpful?