Why Offline-First Is No Longer Optional
Here's a scenario every mobile developer has lived through: a user opens your app on the subway, in an elevator, or somewhere in rural America where cell coverage is a polite fiction. The spinner appears. The data doesn't load. The user closes the app and — let's be honest — probably never opens it again.
In 2026, building a mobile app that crumbles without a network connection isn't just a bad user experience. It's a competitive disadvantage. Users expect apps to work everywhere, instantly. They want to create, read, update, and delete data without thinking about whether they're on Wi-Fi or not. And they absolutely expect their changes to still be there when connectivity returns.
That's the promise of offline-first architecture: your app's primary data source lives on the device. The network is a nice-to-have, not a prerequisite.
And the good news? The tooling for building offline-first React Native apps has never been better. In this guide, we'll build a production-grade offline-first setup using Expo SQLite as our local database, Drizzle ORM for type-safe queries, and real sync strategies — including conflict resolution — that hold up under real-world conditions. We'll also compare the ecosystem of database options available right now so you can pick the best fit for your project.
Understanding the Local-First Architecture
Before we write any code, let's get the conceptual model right. "Offline-first" and "local-first" are related but distinct ideas, and I've seen teams get into trouble by conflating them.
Offline-First vs. Local-First
Offline-first means your app can function without a network connection. Data is cached locally, operations queue up for later sync. The server is still the source of truth — the local copy is essentially a cache.
Local-first takes this further. The local database is the source of truth. The server exists for backup, sharing, and multi-device sync. The app isn't degraded when offline — it's the same app, fully functional. The network layer just enables collaboration.
For most production apps, you'll want something between these two extremes. The architecture we'll build here gives users an instantly responsive local experience while maintaining a reliable sync pipeline to your backend. Here's how the data flows:
- Reads always hit the local SQLite database — zero latency, zero network dependency
- Writes go to the local database first, then queue for background sync
- Sync runs opportunistically when connectivity is available, handling conflicts gracefully
- Live queries automatically re-render your UI when local data changes
Choosing Your Local Database in 2026
The React Native database landscape has matured a lot. Let's look at the serious contenders for offline-first apps and when each one makes sense.
Expo SQLite
Expo SQLite is the built-in choice for Expo projects. Since SDK 51, it's offered a modernized async API with WAL mode, foreign key support, and — critically — a change listener system that powers reactive queries. It's lightweight, well-maintained, and doesn't require any native build configuration in managed Expo projects.
Best for: Most Expo projects, especially when you want to pair it with Drizzle ORM for type safety. It's honestly the path of least resistance with excellent developer experience.
OP-SQLite
OP-SQLite is a performance-focused library built on JSI (JavaScript Interface). It communicates directly with the native layer synchronously, eliminating the serialization overhead from the old bridge. If you're processing tens of thousands of records or doing complex joins on the device, OP-SQLite will measurably outperform expo-sqlite.
Best for: Data-intensive apps where raw query performance is critical. Just know that it requires native builds — you can't use Expo Go.
WatermelonDB
WatermelonDB is an opinionated framework built on top of SQLite, designed specifically for complex offline-first apps. It provides lazy loading, observable records that automatically re-render UI components, and a built-in sync protocol. The tradeoff is complexity — WatermelonDB has a steeper learning curve and also requires EAS builds (no Expo Go support).
Best for: Large-scale apps with complex relational data that need advanced sync capabilities and lazy-loaded lists with thousands of records.
PowerSync
PowerSync is a sync engine, not just a database. It streams changes from your backend database (Postgres, MongoDB, MySQL) into client-side SQLite, keeping them in sync based on configurable rules. You get local reads and writes with automatic bidirectional sync. PowerSync handles conflict resolution, offline queues, and connection management for you.
Best for: Apps where you already have a backend database and want turnkey sync without building the infrastructure yourself.
Our Choice: Expo SQLite + Drizzle ORM
For this guide, we're going with Expo SQLite paired with Drizzle ORM. This combination gives us the best developer experience for most projects: zero native build configuration, full type safety, reactive live queries, and the flexibility to add any sync strategy we want. So, let's build it.
Setting Up Expo SQLite with Drizzle ORM
First, let's install the dependencies. I'm assuming you have an Expo project already set up with SDK 53 or later.
npx expo install expo-sqlite
npm install drizzle-orm
npm install -D drizzle-kit
We also need the Babel plugin for inline imports, which lets Drizzle handle SQL migration files:
npm install -D babel-plugin-inline-import
Configuring Babel and Metro
Update your babel.config.js to include the inline-import plugin:
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [["inline-import", { extensions: [".sql"] }]],
};
};
Next, update your Metro config so the bundler recognizes .sql files. This is a step a lot of tutorials skip, and it'll crash your app at runtime if you forget it:
// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const config = getDefaultConfig(__dirname);
config.resolver.sourceExts.push("sql");
module.exports = config;
Defining Your Schema
Drizzle ORM uses a schema-first approach. You define your tables as TypeScript objects, and Drizzle generates the SQL and types for you. Here's a practical schema for a task management app with offline sync tracking:
// db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
name: text("name").notNull(),
color: text("color").default("#6366f1"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
syncStatus: text("sync_status", {
enum: ["synced", "pending", "conflict"],
})
.notNull()
.default("pending"),
syncVersion: integer("sync_version").notNull().default(0),
});
export const tasks = sqliteTable("tasks", {
id: text("id").primaryKey(),
title: text("title").notNull(),
description: text("description"),
isCompleted: integer("is_completed", { mode: "boolean" })
.notNull()
.default(false),
priority: text("priority", {
enum: ["low", "medium", "high"],
})
.notNull()
.default("medium"),
projectId: text("project_id").references(() => projects.id),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
deletedAt: integer("deleted_at", { mode: "timestamp" }),
syncStatus: text("sync_status", {
enum: ["synced", "pending", "conflict"],
})
.notNull()
.default("pending"),
syncVersion: integer("sync_version").notNull().default(0),
});
Notice a few important design decisions here. Every table has syncStatus and syncVersion columns — these are essential for tracking which records need syncing and for detecting conflicts. We're using deletedAt (soft delete) instead of actually removing rows, because you need to sync deletions to the server before you can physically remove them. I've seen people skip this and wonder why deleted items keep reappearing.
Configuring Drizzle Kit
Create a Drizzle configuration file for generating migrations:
// drizzle.config.ts
import type { Config } from "drizzle-kit";
export default {
schema: "./db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
driver: "expo",
} satisfies Config;
Generate your initial migration:
npx drizzle-kit generate
This creates a drizzle/ folder with SQL migration files and a metadata journal that Drizzle uses to track which migrations have been applied.
Initializing the Database
Now let's wire everything together with a database provider that handles initialization and migration:
// db/provider.tsx
import React from "react";
import { SQLiteProvider, openDatabaseSync } from "expo-sqlite";
import { drizzle } from "drizzle-orm/expo-sqlite";
import { useMigrations } from "drizzle-orm/expo-sqlite/migrator";
import migrations from "../drizzle/migrations";
import * as schema from "./schema";
import { ActivityIndicator, View, Text } from "react-native";
const DATABASE_NAME = "app.db";
const expoDb = openDatabaseSync(DATABASE_NAME, {
enableChangeListener: true,
});
export const db = drizzle(expoDb, { schema });
function MigrationGate({ children }: { children: React.ReactNode }) {
const { success, error } = useMigrations(db, migrations);
if (error) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>Migration failed: {error.message}</Text>
</View>
);
}
if (!success) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
</View>
);
}
return <>{children}</>;
}
export function DatabaseProvider({ children }: { children: React.ReactNode }) {
return (
<SQLiteProvider databaseName={DATABASE_NAME}>
<MigrationGate>{children}</MigrationGate>
</SQLiteProvider>
);
}
The key detail here is enableChangeListener: true — without this flag, live queries simply won't work and you'll spend an embarrassing amount of time debugging why. The MigrationGate component ensures migrations complete before your app renders, preventing crashes from missing tables.
Building Reactive UI with Live Queries
One of the biggest wins of using Drizzle ORM with Expo SQLite is the useLiveQuery hook. It automatically re-runs your query and re-renders the component whenever the underlying tables change. No manual cache invalidation. No subscription management. It just works.
// components/TaskList.tsx
import { useLiveQuery } from "drizzle-orm/expo-sqlite";
import { eq, isNull, desc } from "drizzle-orm";
import { db } from "../db/provider";
import { tasks, projects } from "../db/schema";
import { FlatList, Text, View, Pressable } from "react-native";
export function TaskList({ projectId }: { projectId: string }) {
const { data: taskList } = useLiveQuery(
db
.select()
.from(tasks)
.where(
projectId
? eq(tasks.projectId, projectId)
: isNull(tasks.deletedAt)
)
.orderBy(desc(tasks.createdAt))
);
return (
<FlatList
data={taskList}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Pressable
onPress={() => toggleTask(item.id, !item.isCompleted)}
style={{
flexDirection: "row",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#e5e7eb",
}}
>
<View
style={{
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: item.isCompleted ? "#10b981" : "#d1d5db",
backgroundColor: item.isCompleted ? "#10b981" : "transparent",
marginRight: 12,
}}
/>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 16,
textDecorationLine: item.isCompleted
? "line-through"
: "none",
color: item.isCompleted ? "#9ca3af" : "#111827",
}}
>
{item.title}
</Text>
<SyncBadge status={item.syncStatus} />
</View>
</Pressable>
)}
/>
);
}
function SyncBadge({ status }: { status: string }) {
if (status === "synced") return null;
return (
<Text
style={{
fontSize: 11,
color: status === "conflict" ? "#ef4444" : "#f59e0b",
marginTop: 2,
}}
>
{status === "pending" ? "Waiting to sync" : "Sync conflict"}
</Text>
);
}
Writing Data Locally
Every write operation goes to the local database first, with syncStatus set to "pending". Here's how the create and toggle operations work:
// db/operations.ts
import { eq } from "drizzle-orm";
import { db } from "./provider";
import { tasks } from "./schema";
import * as Crypto from "expo-crypto";
export async function createTask(
title: string,
projectId: string,
priority: "low" | "medium" | "high" = "medium"
) {
const id = Crypto.randomUUID();
const now = new Date();
await db.insert(tasks).values({
id,
title,
projectId,
priority,
createdAt: now,
updatedAt: now,
syncStatus: "pending",
syncVersion: 0,
});
return id;
}
export async function toggleTask(id: string, isCompleted: boolean) {
await db
.update(tasks)
.set({
isCompleted,
updatedAt: new Date(),
syncStatus: "pending",
syncVersion: db
.select({ v: tasks.syncVersion })
.from(tasks)
.where(eq(tasks.id, id)),
})
.where(eq(tasks.id, id));
}
export async function softDeleteTask(id: string) {
await db
.update(tasks)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
syncStatus: "pending",
})
.where(eq(tasks.id, id));
}
Because we're using live queries, the TaskList component re-renders immediately when you call any of these functions. No state management library, no manual updates — the database is the state. This is one of those patterns that feels almost too simple once you see it working.
Building the Sync Engine
Alright, this is where offline-first gets real. You need a reliable way to push local changes to your backend and pull remote changes to the device. Let's build a sync engine that handles the hard parts: batching, ordering, conflict detection, and retry logic.
The Sync Queue
First, let's create a dedicated sync queue table to track pending operations with proper ordering:
// db/schema.ts (add to existing schema)
export const syncQueue = sqliteTable("sync_queue", {
id: integer("id").primaryKey({ autoIncrement: true }),
tableName: text("table_name").notNull(),
recordId: text("record_id").notNull(),
operation: text("operation", {
enum: ["create", "update", "delete"],
}).notNull(),
payload: text("payload").notNull(), // JSON string
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
retryCount: integer("retry_count").notNull().default(0),
lastError: text("last_error"),
});
The Core Sync Logic
Here's a practical sync engine that processes the queue and handles common failure scenarios:
// sync/engine.ts
import { eq, asc, lte } from "drizzle-orm";
import { db } from "../db/provider";
import { syncQueue, tasks, projects } from "../db/schema";
import NetInfo from "@react-native-community/netinfo";
const MAX_RETRIES = 5;
const BATCH_SIZE = 20;
const API_BASE = "https://api.yourapp.com";
type SyncResult = {
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
};
export async function runSync(): Promise<SyncResult> {
const netState = await NetInfo.fetch();
if (!netState.isConnected) {
return { pushed: 0, pulled: 0, conflicts: 0, errors: ["No connection"] };
}
const result: SyncResult = { pushed: 0, pulled: 0, conflicts: 0, errors: [] };
// Phase 1: Push local changes
try {
const pushResult = await pushChanges();
result.pushed = pushResult.pushed;
result.conflicts = pushResult.conflicts;
} catch (e) {
result.errors.push(`Push failed: ${e}`);
}
// Phase 2: Pull remote changes
try {
const pullResult = await pullChanges();
result.pulled = pullResult.pulled;
} catch (e) {
result.errors.push(`Pull failed: ${e}`);
}
return result;
}
async function pushChanges() {
let pushed = 0;
let conflicts = 0;
const pendingOps = await db
.select()
.from(syncQueue)
.where(lte(syncQueue.retryCount, MAX_RETRIES))
.orderBy(asc(syncQueue.createdAt))
.limit(BATCH_SIZE);
for (const op of pendingOps) {
try {
const response = await fetch(`${API_BASE}/sync/${op.tableName}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
operation: op.operation,
recordId: op.recordId,
payload: JSON.parse(op.payload),
clientVersion: op.createdAt,
}),
});
if (response.ok) {
// Remove from queue and mark record as synced
await db.delete(syncQueue).where(eq(syncQueue.id, op.id));
await markRecordSynced(op.tableName, op.recordId);
pushed++;
} else if (response.status === 409) {
// Conflict — server has a newer version
const serverData = await response.json();
await handleConflict(op, serverData);
conflicts++;
} else {
// Transient error — increment retry count
await db
.update(syncQueue)
.set({
retryCount: op.retryCount + 1,
lastError: `HTTP ${response.status}`,
})
.where(eq(syncQueue.id, op.id));
}
} catch (e) {
await db
.update(syncQueue)
.set({
retryCount: op.retryCount + 1,
lastError: String(e),
})
.where(eq(syncQueue.id, op.id));
}
}
return { pushed, conflicts };
}
async function markRecordSynced(tableName: string, recordId: string) {
const table = tableName === "tasks" ? tasks : projects;
await db
.update(table)
.set({ syncStatus: "synced" })
.where(eq(table.id, recordId));
}
Pulling Remote Changes
For the pull phase, we use a last-sync timestamp to fetch only the changes since the previous sync:
// sync/pull.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { eq } from "drizzle-orm";
import { db } from "../db/provider";
import { tasks, projects } from "../db/schema";
const API_BASE = "https://api.yourapp.com";
export async function pullChanges() {
let pulled = 0;
const lastSync = await AsyncStorage.getItem("lastSyncTimestamp");
const since = lastSync || new Date(0).toISOString();
const response = await fetch(
`${API_BASE}/sync/changes?since=${encodeURIComponent(since)}`
);
const { changes, serverTimestamp } = await response.json();
for (const change of changes) {
const table = change.table === "tasks" ? tasks : projects;
const existing = await db
.select()
.from(table)
.where(eq(table.id, change.record.id))
.limit(1);
if (existing.length === 0) {
// New record from server
await db.insert(table).values({
...change.record,
syncStatus: "synced",
});
pulled++;
} else if (existing[0].syncStatus === "synced") {
// No local modifications — safe to overwrite
await db
.update(table)
.set({ ...change.record, syncStatus: "synced" })
.where(eq(table.id, change.record.id));
pulled++;
} else {
// Local modifications exist — skip and let push handle it
// The push phase will detect the conflict via version mismatch
}
}
await AsyncStorage.setItem("lastSyncTimestamp", serverTimestamp);
return { pulled };
}
Conflict Resolution Strategies
Conflicts are inevitable in offline-first apps. Two devices edit the same record while disconnected, and when they both sync, the server has to decide what wins. There's no universal right answer here — the correct strategy depends entirely on your data and your users.
Last-Write-Wins (LWW)
The simplest strategy: whichever change has the latest timestamp wins. This works surprisingly well for data that's usually edited by one person at a time — think user profiles, personal settings, or single-user task lists.
async function resolveConflictLWW(
localRecord: any,
serverRecord: any
): Promise<"local" | "server"> {
const localTime = new Date(localRecord.updatedAt).getTime();
const serverTime = new Date(serverRecord.updatedAt).getTime();
return localTime >= serverTime ? "local" : "server";
}
The risk: if device clocks are out of sync, you can lose data. Mitigate this by using server-assigned timestamps and hybrid logical clocks rather than relying on device time.
Field-Level Merge
Instead of picking one entire record, you merge at the field level. If Device A changed the title and Device B changed the priority, keep both changes. Only flag a conflict when the same field was modified on both sides.
async function resolveConflictFieldMerge(
localRecord: Record<string, any>,
serverRecord: Record<string, any>,
baseRecord: Record<string, any>
): Promise<{ merged: Record<string, any>; hasConflicts: boolean }> {
const merged = { ...baseRecord };
let hasConflicts = false;
for (const key of Object.keys(baseRecord)) {
if (key === "id" || key === "syncVersion" || key === "syncStatus") continue;
const localChanged = localRecord[key] !== baseRecord[key];
const serverChanged = serverRecord[key] !== baseRecord[key];
if (localChanged && serverChanged) {
// Both modified the same field — true conflict
if (localRecord[key] !== serverRecord[key]) {
// Default to server wins for conflicting fields
merged[key] = serverRecord[key];
hasConflicts = true;
} else {
merged[key] = localRecord[key]; // Same value, no real conflict
}
} else if (localChanged) {
merged[key] = localRecord[key];
} else if (serverChanged) {
merged[key] = serverRecord[key];
}
}
return { merged, hasConflicts };
}
This approach is more complex but preserves more user intent. It does require storing the "base" version of each record (the state it was in before local modifications) so you can compute a three-way diff. It's extra bookkeeping, but worth it for collaborative scenarios.
When to Use What
- LWW: Personal data, settings, simple single-user entities
- Field-level merge: Collaborative documents, shared records edited by multiple users
- Manual resolution: High-stakes data where incorrect merges could cause real problems (financial records, medical data). Flag the conflict and let the user decide.
- CRDTs: If you need guaranteed conflict-free merging without any coordination, look into Conflict-free Replicated Data Types. Libraries like Yjs and Automerge provide CRDT implementations that work well with TinyBase in React Native.
Triggering Sync: Background and Foreground Strategies
A sync engine is useless if it never actually runs. You need to trigger sync at the right times without draining the battery or annoying the user.
Network-Aware Sync with NetInfo
// sync/manager.ts
import NetInfo from "@react-native-community/netinfo";
import { AppState } from "react-native";
import { runSync } from "./engine";
let isSyncing = false;
export function startSyncManager() {
// Sync when connectivity changes
const unsubNet = NetInfo.addEventListener((state) => {
if (state.isConnected && !isSyncing) {
performSync();
}
});
// Sync when app comes to foreground
const unsubApp = AppState.addEventListener("change", (nextState) => {
if (nextState === "active" && !isSyncing) {
performSync();
}
});
// Periodic sync every 5 minutes while active
const interval = setInterval(() => {
if (AppState.currentState === "active" && !isSyncing) {
performSync();
}
}, 5 * 60 * 1000);
return () => {
unsubNet();
unsubApp.remove();
clearInterval(interval);
};
}
async function performSync() {
if (isSyncing) return;
isSyncing = true;
try {
const result = await runSync();
if (result.conflicts > 0) {
// Optionally notify the user about conflicts
console.warn(`Sync completed with ${result.conflicts} conflicts`);
}
} catch (e) {
console.error("Sync failed:", e);
} finally {
isSyncing = false;
}
}
Background Sync with Expo Background Task
Expo SDK 53 introduced the expo-background-task package, which uses WorkManager on Android and BGTaskScheduler on iOS for energy-efficient background work. This is a significant upgrade over the older expo-task-manager approach.
// sync/background.ts
import * as BackgroundTask from "expo-background-task";
import * as TaskManager from "expo-task-manager";
import { runSync } from "./engine";
const SYNC_TASK_NAME = "BACKGROUND_SYNC";
TaskManager.defineTask(SYNC_TASK_NAME, async () => {
try {
const result = await runSync();
return result.errors.length === 0
? BackgroundTask.BackgroundTaskResult.Success
: BackgroundTask.BackgroundTaskResult.Failed;
} catch (e) {
return BackgroundTask.BackgroundTaskResult.Failed;
}
});
export async function registerBackgroundSync() {
const isRegistered = await TaskManager.isTaskRegisteredAsync(SYNC_TASK_NAME);
if (!isRegistered) {
await BackgroundTask.registerTaskAsync(SYNC_TASK_NAME, {
minimumInterval: 15 * 60, // Minimum 15 minutes on iOS
});
}
}
One thing to keep in mind: background tasks are not guaranteed to run at your specified interval. The OS schedules them based on battery state, network conditions, and user behavior patterns. Always design your sync logic to gracefully handle large time gaps between syncs.
Handling Edge Cases That Will Break Your App
The happy path of offline-first is pretty straightforward. It's the edge cases that keep you up at night. Here are the ones I've seen bite teams in production — sometimes including my own.
Database Migrations
Drizzle's migration system handles schema changes, but what happens when a user hasn't opened your app in six months and is three migrations behind? The useMigrations hook runs them sequentially, which is great, but you need to test migration chains end-to-end. Also consider: what if the user has unsynced data in a table you're about to alter?
// Always sync before running destructive migrations
async function safeMigrate() {
// Try to sync first
const netState = await NetInfo.fetch();
if (netState.isConnected) {
await runSync();
}
// Check for unsynced data
const pendingCount = await db
.select({ count: sql<number>`count(*)` })
.from(syncQueue);
if (pendingCount[0].count > 0) {
// Warn user or defer migration
console.warn(`${pendingCount[0].count} unsynced changes exist`);
}
}
Storage Limits
SQLite on mobile can grow large, but not infinitely. If your app caches media references or large text blobs, you'll want to implement a cleanup strategy. Periodically prune synced soft-deleted records and consider storing only the most recent N days of data locally.
UUID Generation
Client-generated IDs are essential for offline-first — you simply can't wait for the server to assign an ID. Use expo-crypto's randomUUID() for RFC 4122 v4 UUIDs. And whatever you do, never use auto-incrementing integers as primary keys in an offline-first system. They will collide. It's not a matter of if, but when.
Clock Skew
Device clocks are unreliable. If your conflict resolution depends on timestamps, use Hybrid Logical Clocks (HLCs) or server-assigned timestamps. An HLC combines a physical timestamp with a logical counter, giving you causally consistent ordering without relying on perfectly synchronized clocks.
Managed Sync Services: When to Build vs. Buy
Building a sync engine from scratch gives you maximum control, but it's honestly a lot of work to get right — especially conflict resolution, partial sync, and access control. Here's when managed services make more sense.
PowerSync
PowerSync sits between your backend database and the client. You define Sync Rules that control which data each client receives, and PowerSync handles streaming changes bidirectionally. It works with Postgres, MongoDB, MySQL, and SQL Server. The React Native SDK provides reactive hooks and handles connection management.
Use PowerSync when you already have a backend database and want robust sync without building the infrastructure. It's particularly strong for apps with complex access control rules — where different users need to see different subsets of data.
Turso Offline Sync
Turso is a modern database service built on libSQL (a SQLite fork). Its Offline Sync feature enables bidirectional sync between local and remote SQLite databases with built-in conflict detection. It integrates directly with expo-sqlite, which makes it an attractive option if you want a SQLite-based backend that speaks the same language as your client.
TinyBase
TinyBase takes a different approach entirely — it's a reactive data store that plugs into various persistence and sync backends. It works with Expo Go (no native builds needed) and supports synchronization through Yjs CRDTs. If you need real-time collaboration features similar to Figma or Google Docs, TinyBase is definitely worth evaluating.
Performance Considerations
Offline-first architecture gives you inherently fast reads (local SQLite queries are sub-millisecond for most workloads), but there are still performance traps to watch out for.
WAL Mode
Expo SQLite enables WAL (Write-Ahead Logging) mode by default in recent SDKs. This allows concurrent reads and writes — which is critical for offline-first apps where sync writes shouldn't block UI reads. You can verify it's enabled like this:
// WAL mode is typically enabled by default, but you can verify
expoDb.execSync("PRAGMA journal_mode = WAL");
Batch Writes
When pulling many records from the server, wrap your inserts in a transaction. SQLite commits after every statement by default, and 1,000 individual inserts are dramatically slower than 1,000 inserts wrapped in a single transaction. We're talking orders of magnitude here:
await db.transaction(async (tx) => {
for (const record of serverRecords) {
await tx.insert(tasks).values(record).onConflictDoUpdate({
target: tasks.id,
set: { ...record, syncStatus: "synced" },
});
}
});
Index Your Sync Columns
Every query that filters by syncStatus needs an index. Without it, your sync engine does a full table scan every time it looks for pending changes:
-- In a migration or schema file
CREATE INDEX idx_tasks_sync_status ON tasks(sync_status);
CREATE INDEX idx_sync_queue_created_at ON sync_queue(created_at);
Putting It All Together
Here's how to wire the database provider and sync manager into your app entry point:
// app/_layout.tsx (Expo Router)
import { useEffect } from "react";
import { Stack } from "expo-router";
import { DatabaseProvider } from "../db/provider";
import { startSyncManager } from "../sync/manager";
import { registerBackgroundSync } from "../sync/background";
export default function RootLayout() {
useEffect(() => {
const cleanup = startSyncManager();
registerBackgroundSync();
return cleanup;
}, []);
return (
<DatabaseProvider>
<Stack />
</DatabaseProvider>
);
}
And that's it. Your app now reads and writes to a local SQLite database through type-safe Drizzle queries. Live queries keep the UI reactive. The sync manager pushes changes when connectivity is available and pulls updates from the server. Background tasks keep sync running even when the app isn't in the foreground. And conflict resolution handles the inevitable cases where two devices edit the same data.
Summary and Recommendations
Building offline-first React Native apps in 2026 is more accessible than it's ever been, but it still requires intentional architecture. Here's the decision framework I'd recommend:
- Starting a new Expo project? Use
expo-sqlite+ Drizzle ORM. It's the smoothest path with excellent type safety and live queries. Build your sync layer incrementally as your needs grow. - Need managed sync? Evaluate PowerSync (for backend database sync) or Turso Offline Sync (for SQLite-to-SQLite sync). Both save you from building and maintaining sync infrastructure.
- Need real-time collaboration? Look at TinyBase with Yjs CRDTs for conflict-free sync across devices and users.
- Have a data-intensive app? Consider OP-SQLite for raw performance, or WatermelonDB for its lazy-loading and built-in sync protocol.
- Conflict resolution: Start with last-write-wins. Move to field-level merge if your users actually collaborate on the same records. Only implement manual resolution for high-stakes data.
The core principle remains the same regardless of which tools you pick: put the database on the device, make the network optional, and design your sync to be resilient. Your users will thank you — especially the ones on the subway.