Offline-First React Native: Replacing Custom Sync Queues with PowerSync
How I replaced a fragile hand-rolled API retry queue with a local-first SQLite sync layer, and the per-user sync rules and server-authoritative upload model that kept it correct.
A field-operations mobile app I work on runs in rural areas where cellular coverage drops in and out for kilometers at a time. The first version handled that with optimistic UI plus a hand-rolled API retry queue. It broke often enough that I replaced the queue with a local-first sync engine.
Note
This reflects a React Native (Expo) field-operations app. Built against Expo SDK 55 with @powersync/react-native and the @powersync/op-sqlite native SQLite adapter, syncing a Postgres schema through a Node BFF. Reconstructed from the project source as of August 2026, with product and company names removed.
The Problem
The original client wrote straight to a REST API. When the network dropped, writes went into a custom queue that retried with backoff and reconciled state once connectivity returned. That design holds for a single, independent call. It falls apart once the queued work is interdependent.
In this app the queued work was deeply interdependent: chat messages, GPS pings, and trip-status transitions that had to land in a specific order to keep the operational timeline correct. The retry queue ended up reimplementing concerns the server already owned, ordering, authorization, and the final shape of each record. Two sources of truth for the same write is where subtle data loss hides. Resend deduplication, out-of-order arrival, and partial failure each grew their own branch of reconciliation code, and each branch was a place a record could silently disappear.
Real-time push had the same shape of problem on a second axis. An earlier plan used a separate WebSocket service to push updates to the device. That would have meant two independent moving parts, the offline queue and the live push channel, both trying to describe the same shifting state.
Going Local-First
I moved the app to a local-first architecture on PowerSync. The app reads and writes a local SQLite database. UI reads are reactive queries over that database, and PowerSync handles bidirectional replication between SQLite and Postgres in the background. Reactive reads come from TanStack DB collections backed by the PowerSync database.
The wiring is small:
// app/lib/powersync/database.native.ts
import { OPSqliteOpenFactory } from "@powersync/op-sqlite";
import { PowerSyncDatabase } from "@powersync/react-native";
import { AppSchema } from "./AppSchema";
import { BackendConnector } from "./BackendConnector";
const openFactory = new OPSqliteOpenFactory({
dbFilename: "app.db",
});
export const powersync = new PowerSyncDatabase({
schema: AppSchema,
database: openFactory,
});
export function connectPowerSync() {
return powersync.connect(new BackendConnector());
}
A local write is just a SQL insert against that database. It returns immediately, the UI updates through the reactive collection, and PowerSync queues the change for upload:
// app/lib/powersync/sync-notes.ts
await powersync.execute(
`INSERT INTO sync_notes (id, subject, message, created_at) VALUES (?, ?, ?, ?)`,
[id, subject, message, createdAt],
);
How Sync Stays Correct
Two decisions carry the weight here.
Per-user sync streams. PowerSync pulls data through declarative SQL sync rules. Each stream is scoped to the authenticated user, resolved from the Cognito ID token the client presents on connect. A device only ever receives rows it owns or participates in:
# sync-config.yaml (excerpt, anonymized)
streams:
notes:
auto_subscribe: true
query: |
SELECT id, subject, message, created_at
FROM app.sync_notes
WHERE subject = auth.user_id()
trip_messages:
auto_subscribe: true
query: |
SELECT id, conversation_id, sender_subject, body, created_at
FROM app.trip_messages
WHERE conversation_id IN (
SELECT id FROM app.conversations
WHERE seller_subject = auth.user_id()
OR driver_subject = auth.user_id()
)
The filter lives in one declarative place instead of imperative authorization code duplicated across endpoints.
Server-authoritative writes. Client-writable tables are a small, explicit set. PowerSync queues each local write, and a connector forwards the queued operation to the BFF, which validates, authorizes, and persists it rather than writing straight into Postgres. The resolved row then streams back down through the same sync rules:
// app/lib/powersync/BackendConnector.ts (trimmed)
async uploadData(database: AbstractPowerSyncDatabase) {
while (true) {
const transaction = await database.getNextCrudTransaction();
if (!transaction) return;
for (const operation of transaction.crud) {
switch (operation.table) {
case "gps_logs":
await uploadGpsLog(operation); // POST /gps-logs
break;
case "sync_notes":
await uploadSyncNote(operation); // POST /sync-notes
break;
case "trip_messages":
await uploadTripMessage(operation); // POST /chat/.../messages
break;
default:
throw new Error(`Unsupported upload table: ${operation.table}.`);
}
}
await transaction.complete();
}
}
This is the part that replaced the retry queue’s hardest job. Ordering, authorization, and conflict resolution live in one place, the BFF. The client writes locally and trusts the round trip. The legacy monolith stays out of the path entirely; the BFF owns this slice of the domain in its own Postgres schema.
What I Actually Tested
I want to be precise here, because “tested offline behavior” gets claimed loosely. I did not write an airplane-mode or network-failure simulation. What I built and run on QA is narrower: an upload round-trip smoke that writes a record locally and asserts the UI reaches a “saved and synced” state with no upload error, plus seeded multi-role flows that exercise the synced data across participants to confirm the per-user streams carry the right rows to the right devices.
The gap I have not closed is explicit offline resilience: kill the network mid-session, queue writes, restore connectivity, and assert ordered delivery. PowerSync’s queue model is designed for exactly that, and the round-trip smoke gives me reasonable confidence, but I have not yet asserted it under a real connectivity drop.
The schema-drift problem this architecture introduces, the client AppSchema, the sync-rule SELECT lists, and the Postgres DDL all describe overlapping shapes, is a topic of its own that I will cover separately.
References
- PowerSync React Native SDK: the local-first sync engine and client API.
- PowerSync sync rules: the declarative per-user stream model.
@powersync/op-sqlite: the native SQLite adapter used on the New Architecture.- TanStack DB: the reactive local-first query layer over PowerSync.
- Maestro: used for the upload round-trip smoke and seeded multi-role flows.
This post was written with AI assistance.