Tejasbyte Technologies
Start Project
Tejasbyte
HomeServicesPortfolioBlogAboutContactStart Project
Mobile

React Native Offline-First Architecture for Unreliable Networks

How we built offline-first mobile apps for fleet drivers in remote areas — conflict resolution, sync queues, and background flushing that works.

MobileJune 15, 20267 min read

We built a fleet management app for drivers operating in mountainous Nepal where connectivity drops to zero for hours at a time. Here's the offline-first architecture that handles it reliably.

The Core Principle: Local-First

In an offline-first app, every write goes to the local database first. The network sync is a background process, not a blocking operation. Users can work indefinitely without connectivity.

// Every mutation goes through this pattern
async function createInspection(data: InspectionData) {
  // 1. Write to local SQLite immediately
  const localId = await db.inspections.insert({
    ...data,
    _syncStatus: "pending",
    _localId: generateId(),
    _createdAt: new Date().toISOString(),
  });

  // 2. Queue for sync (non-blocking)
  syncQueue.push({ type: "CREATE", table: "inspections", id: localId });
  
  // 3. Return immediately — UI updates instantly
  return localId;
}

SQLite with Expo

We use expo-sqlite with a custom migration system. The schema is version-controlled and migrations run automatically on app startup.

import * as SQLite from "expo-sqlite";

const db = SQLite.openDatabaseSync("fleet.db");

// Run migrations on startup
async function runMigrations() {
  const currentVersion = await getDBVersion();
  
  const migrations = [
    {
      version: 1,
      sql: `
        CREATE TABLE IF NOT EXISTS inspections (
          id TEXT PRIMARY KEY,
          vehicle_id TEXT NOT NULL,
          driver_id TEXT NOT NULL,
          status TEXT DEFAULT 'draft',
          data TEXT,  -- JSON blob
          _sync_status TEXT DEFAULT 'pending',
          _local_id TEXT UNIQUE,
          _created_at TEXT,
          _synced_at TEXT
        )
      `
    },
  ];
  
  for (const m of migrations) {
    if (m.version > currentVersion) {
      await db.execAsync(m.sql);
      await setDBVersion(m.version);
    }
  }
}

The Sync Queue

Background sync runs every 30 seconds when online, and immediately when connectivity is restored. Conflict resolution uses last-write-wins with server as source of truth.

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

class SyncQueue {
  private queue: SyncItem[] = [];
  private syncing = false;

  constructor() {
    // Sync when connectivity returns
    NetInfo.addEventListener(state => {
      if (state.isConnected && !this.syncing) {
        this.flush();
      }
    });
  }

  async flush() {
    if (this.syncing || this.queue.length === 0) return;
    this.syncing = true;

    const pending = await db.inspections.findAll({
      where: { _syncStatus: "pending" }
    });

    for (const item of pending) {
      try {
        const response = await api.post("/inspections", item);
        await db.inspections.update(item.id, {
          _syncStatus: "synced",
          _syncedAt: new Date().toISOString(),
          id: response.data.id,  // Replace local ID with server ID
        });
      } catch (err) {
        await db.inspections.update(item.id, {
          _syncStatus: "failed",
          _errorMsg: err.message,
        });
      }
    }
    this.syncing = false;
  }
}

Never use the server ID as your primary key locally. Generate a UUID locally, sync it, then update with the server ID. This prevents race conditions when the same record is created offline on two devices.

React NativeSQLiteExpoOffline
← Back to Blog