EnorexaEnorexa
Back to blog

Engineering

Offline-First React Native: Why REST Polling Breaks Field Apps

October 26, 20265 min readBy Enorexa Team

Every warehouse scanner, logistics tracker, and field inspection app eventually hits the same wall: a concrete basement with one bar of fluctuating LTE. When you build a React Native app like a standard web client—firing await fetch('/api/inspections') on every button tap—spotty connectivity doesn't just slow the UI down. It corrupts state. Users tap 'Submit' three times while staring at a frozen spinner, the OS kills the backgrounded socket with NSURLErrorTimedOut (-1001), and ten minutes later the technician realizes forty-five minutes of barcode scans vanished into an unhandled promise rejection.

Why Naive Optimistic UI and React Query Aren't Enough

Most engineering teams try to patch bad mobile connectivity by flipping on persistQueryClient in TanStack Query and wrapping AsyncStorage around their state. That works for caching read-only dashboards. It falls apart the moment two technicians edit the same work order offline.

AsyncStorage is a slow, unindexed key-value store that serializes entire JSON blobs across the React Native bridge (or JSI). Once a field worker caches 15,000 SKU rows and 80MB of inspection photos locally, serializing a single 12MB JSON key blocks the JavaScript thread for 400ms and triggers OutOfMemoryError crashes on mid-tier Android rugged devices. Worse, standard HTTP retry queues replay full-document PUT requests in arbitrary order when connectivity returns. If Technician A updates a status offline at 9:02 AM and Technician B updates the parts list at 9:15 AM, whoever walks into Wi-Fi range last silently overwrites the other's work.

Moving the Source of Truth to On-Device SQLite via JSI

True offline-first architecture flips the data model upside down. The React Native UI never talks to your Node.js or Python backend directly. Instead, every screen reads from and writes to an embedded on-device SQLite database (using synchronous JSI bindings like op-sqlite or WatermelonDB) running in WAL (PRAGMA journal_mode=WAL;) mode.

When a supervisor scans a pallet or signs off a safety checklist, the app commits that change to local SQLite in under 2 milliseconds. The UI updates instantly. In the same local transaction, we insert an immutable event into an outbox_mutations table containing a client-generated UUIDv7, the entity ID, the specific column diff ({"status": "inspected", "temp_c": 4.2}), and a Hybrid Logical Clock (HLC) timestamp. Because we store fine-grained column diffs rather than whole-record snapshots, two technicians can modify different fields on the same asset offline without colliding.

Designing the Push/Pull Delta Sync Loop Without Battery Drain

Syncing local SQLite with a central PostgreSQL database requires a strict two-phase protocol: push local mutations first, then pull remote deltas. We never rely on WebSockets alone on cellular networks; half-open TCP connections drain device batteries and hang silently behind carrier NATs.

Instead, our sync engine listens to OS network reachability changes and runs an exponential-backoff batch loop. During the Push phase, the client sends up to 100 pending rows from outbox_mutations in a single compressed POST /api/v1/sync/push payload. The backend processes each mutation inside a PostgreSQL transaction, comparing the incoming HLC timestamp against the per-column _hlc_map JSONB metadata column on the target row. Field-level Last-Write-Wins (LWW) resolves 98% of concurrent edits automatically, while strict state transitions (like voiding an already-invoiced work order) get flagged into a conflict resolution queue rather than throwing a 409 Conflict that halts the entire batch.

Once the outbox drains, the Pull phase sends a single cursor (last_pulled_server_seq) to GET /api/v1/sync/pull. Rather than running expensive updated_at > ? timestamp scans—which miss rows committed inside long-running database transactions—we track a monotonically increasing PostgreSQL sequence (bigserial) on a change-tracking ledger table. The server returns only the exact rows modified or soft-deleted (deleted_at IS NOT NULL) since that sequence number. A worker returning from a four-hour dead zone pulls 300 changed rows in a single 45KB gzipped response instead of re-downloading a 25MB catalog.

Separating Binary Media Uploads from Metadata Sync

The fastest way to jam a mobile sync queue is stuffing base64-encoded inspection photos inside your JSON mutation payloads. A single 8MB camera capture over a 3G connection will block 50 lightweight text status updates behind it.

Always decouple metadata from binary blobs. When a user snaps a photo offline, write the compressed WebP file to the device file system (FileSystem.documentDirectory) and immediately insert the database record with a local_uri pointer and upload_status = 'pending'. The JSON sync engine pushes the database row immediately so dispatchers see the inspection exists within seconds. A separate background worker (BGTaskScheduler on iOS, WorkManager on Android) requests presigned S3 or Cloudflare R2 multipart upload URLs and streams the binary files independently when throughput allows.

What to Audit in Your Mobile Architecture This Week

Open your React Native app in Airplane Mode, complete a core three-step workflow, force-quit the app from the OS task switcher, and turn Wi-Fi back on. If the user's work is gone—or if your backend logs show duplicate records when they tap submit twice on a 1,500ms 4G connection—your architecture is built for office fiber, not field operations. At Enorexa, we build offline-first React Native apps, Next.js control planes, and high-throughput sync backends that survive real-world industrial environments. If your mobile team is fighting dropped mutations or sluggish local state, talk to us and we will help you re-architect your sync layer.