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.

