cd..blog

Optimizing React Native Performance with Expo SQLite and CR-SQLite for Local-First Apps

const published = "Jun 25, 2026, 10:48 PM";const readTime = 6 min;
React NativeExpoSQLiteLocal-FirstPerformance
Learn how to implement high-performance local-first synchronization in React Native using Expo SQLite and CR-SQLite to solve concurrency and latency issues in mobile applications.

Optimizing React Native Performance with Expo SQLite and CR-SQLite for Local-First Apps

As of June 2026, the shift toward local-first architecture in mobile development has moved from a niche architectural preference to a performance requirement. Users expect instantaneous UI feedback regardless of network conditions, and traditional REST or GraphQL patterns often fall short due to the inherent latency of the request-response cycle.

In the React Native ecosystem, the recent stabilization of the Expo SQLite next-generation API, combined with Conflict-Free Replicated Data Types (CRDTs) via CR-SQLite, provides a robust foundation for building high-performance, offline-capable applications. This post explores the implementation details, performance tradeoffs, and architectural patterns required to master local-first data management.

The Problem: The 'Spinner' Anti-Pattern

Most mobile applications rely on a remote source of truth. When a user performs an action, the app shows a loading spinner, waits for a network round-trip, and then updates the local state. This introduces two major issues:

  1. Perceived Latency: Even on 5G, network jitter can make an app feel sluggish.
  2. State Inconsistency: Handling concurrent edits from multiple devices often leads to 'last-write-wins' data loss or complex manual merging logic.

By moving the source of truth to a local SQLite database and treating the cloud as a synchronization relay, we eliminate the spinner and provide a 0ms latency experience.

Architecture: The Local-First Stack

To implement this effectively in 2026, we utilize a three-tier storage architecture:

  1. Persistence Layer: Expo SQLite using the synchronous sqlite3 bindings for high-throughput operations.
  2. Conflict Resolution: CR-SQLite, a run-time extension that transforms standard SQLite tables into CRDTs.
  3. Reactivity Layer: A custom hook or library like Drizzle ORM to provide type-safe queries and live-updating UI components.

Why Expo SQLite?

Expo's latest SQLite implementation leverages the JSI (JavaScript Interface), allowing direct communication between the JavaScript engine and the C++ SQLite implementation. This bypasses the bridge, reducing the overhead of data serialization and deserialization—a critical factor when fetching large datasets for offline search or complex filtering.

Implementing CR-SQLite in React Native

CR-SQLite allows us to define 'fractal' replication. Instead of syncing entire rows or documents, it tracks changes at the column level using causal length and logical clocks (Lamport timestamps).

Schema Definition

When using CR-SQLite, you define your tables as 'crrs' (Conflict-Free Replicated Relations). This is done via a simple SQL command after table creation:

import * as SQLite from 'expo-sqlite';

const db = await SQLite.openDatabaseAsync('app.db');

await db.execAsync(`
  CREATE TABLE IF NOT EXISTS tasks (
    id TEXT PRIMARY KEY NOT NULL,
    title TEXT NOT NULL,
    completed INTEGER DEFAULT 0,
    updated_at INTEGER NOT NULL
  );
  SELECT crsql_as_crr('tasks');
`);

Once a table is converted to a CRR, CR-SQLite automatically tracks metadata in hidden tables. This metadata allows the engine to determine which specific fields changed and how to merge them without human intervention.

Performance Optimization: The Sync Loop

One of the biggest performance bottlenecks in local-first apps is the 'Sync Storm'—where a device reconnects and attempts to process thousands of changes simultaneously, locking the UI thread. To mitigate this, we use a background sync pattern with requestIdleCallback or a dedicated web worker (if using Expo's experimental web support).

Efficient Delta Extraction

Instead of polling for changes, we use the crsql_changes function to extract only the binary deltas since the last sync point:

async function getDeltas(lastSyncVersion: bigint) {
  const changes = await db.getAllAsync(
    'SELECT * FROM crsql_changes WHERE db_version > ?',
    [lastSyncVersion]
  );
  return changes;
}

These deltas are compact and can be sent over WebSockets or a simple POST request. Because they are CRDTs, they are idempotent; applying the same change twice results in the same state, which simplifies retry logic significantly.

Data Modeling with Drizzle ORM

While raw SQL is performant, maintaining a mobile schema requires type safety. Drizzle ORM has become the industry standard for React Native due to its lightweight footprint and 'SQL-like' feel.

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

export const tasks = sqliteTable('tasks', {
  id: text('id').primaryKey(),
  title: text('title').notNull(),
  completed: integer('completed', { mode: 'boolean' }),
  updatedAt: integer('updated_at', { mode: 'timestamp' }),
});

const drizzleDb = drizzle(db);

Using Drizzle with Expo SQLite allows for compile-time checked queries while maintaining the ability to drop down to raw SQL for CR-SQLite specific commands like crsql_as_crr.

Handling Large Datasets: Indexing and Pagination

In a local-first world, your mobile device might store the entire history of a user's data. This necessitates aggressive indexing.

  1. Covering Indexes: Ensure that your most frequent queries are covered by indexes that include all columns in the SELECT clause to avoid table scans.
  2. Full-Text Search (FTS5): SQLite’s FTS5 module is available in Expo. Use it for local search rather than sending search queries to a server. It is orders of magnitude faster and works offline.
  3. Virtual Lists: Always use FlashList from Shopify instead of FlatList. When dealing with local-first data that updates reactively, FlashList handles the re-rendering of large datasets with significantly less UI thread jank.

Tradeoffs and Considerations

While local-first provides a superior UX, it introduces complexity:

  • Storage Limits: Mobile devices have finite storage. You must implement a pruning strategy for old data or binary blobs (images/files), which should remain in cloud storage (S3/GCS) with local caching.
  • Schema Migrations: Migrating a distributed database is harder than a centralized one. CR-SQLite handles schema changes to some extent, but you must ensure that migration scripts are robust and can handle 'time-traveling' clients (clients that haven't synced in months).
  • Security: Since the data lives on-device, encryption at rest is mandatory. Use the expo-sqlite encryption features or the SQLCipher integration if your app handles PII (Personally Identifiable Information).

Conclusion

The combination of Expo SQLite and CR-SQLite represents a paradigm shift for React Native developers. By moving conflict resolution into the database layer and leveraging the high-speed JSI bindings, we can build applications that are not only faster but more resilient than their cloud-only counterparts. As we move further into 2026, the 'offline-as-an-afterthought' approach is no longer viable; local-first is the standard for high-quality mobile engineering.