Skip to content
All field notes

4 min read

Offline Is a State, Not an Error

Building for field reps with no signal — a sync cursor that could silently lose cattle, an iOS upload bug that was three bugs, and retiring the unescapable toast

  • Offline-First
  • Mobile
  • RxDB
  • Capacitor
  • Sync

Our field representatives capture cattle consignments at ranches and sale yards where "spotty coverage" is generous — hours with no signal is a normal Tuesday. That forced a design stance that sounds obvious and changes everything: offline is a state the app lives in, not an error it reports. Here are three problems that stance forced us to actually solve.

The sync cursor that could silently lose cattle

The capture app is offline-first on RxDB: every record lives on the device and replicates to the server when there's signal. Replication pulls in pages using a keyset cursor over (updated_at, id) — the client asks for "everything after this checkpoint" and advances the checkpoint by JavaScript string comparison, which is byte order.

The trap: MySQL's default utf8mb4_unicode_ci collation is case-insensitive and not byte-ordered. If the server sorts one way and the client advances its checkpoint another way, the cursor can silently skip documents — or deliver duplicates — with no error anywhere. "Silently skip" in this domain means a rep's captured consignment just never reaches the sale barn.

The fix is boring and load-bearing: timestamps stored as verbatim ISO-8601 strings, and the keyset columns pinned to utf8mb4_bin so server order and client order agree byte-for-byte. The interesting part is the verification: SQLite — the local dev database — hides this bug entirely, because its text comparison is already binary. So CI runs the full suite against SQLite, MySQL 8.4, and MariaDB 11, with a schema-assertion test that fails if the collation pin is ever dropped. The multi-engine lane earned its keep almost immediately, catching that MariaDB's driver maps UUID columns to a native type that rejected our non-UUID identifiers. If a correctness property depends on the storage engine, test on every engine you deploy on.

The upload bug that was three bugs

Media is the heart of a consignment — buyers bid on video. One iOS crash report (NSInvalidArgumentException deep in string internals) unraveled into three stacked bugs:

Bug one, the crash: overlapping queue drains fired concurrent uploads into the native uploader plugin, whose per-upload listener add/remove dance mutated a shared listener table on one thread while the URLSession delegate read it on another — a data race that corrupts a pointer. Fix: strictly chain uploads one at a time, and register a single app-lifetime listener with a buffer for events that arrive before anyone's listening.

Bug two, hiding inside the fix: the refactor resolved a promise with the Capacitor plugin proxy object. Promise assimilation probes .then on whatever you resolve with — the proxy forwarded that probe to native as a method call, which doesn't exist, and the ready-promise never settled. Every native upload silently hung. The fix is one line: box the plugin in an object before resolving.

Bug three, revealed by bug two's fix: with uploads finally flowing again, they all failed with 401s — the transport had been sending the wrong token type all along, latent and unreachable while uploads were hung.

There's a design lesson under the comedy: the original sin was treating a device-absolute file:// path as a synced field, so paths from other devices — and from the app's own container, which iOS rotates on every reinstall — arrived looking like local files. The transport now re-derives paths under the current container and parks truly-missing media instead of retrying it forever.

Retiring the unescapable toast

Meanwhile, the main CattleUSA app treated connectivity loss the old way: a "check your internet connection" toast with no button, no swipe-to-dismiss, and no timeout — literally unescapable — while every poller and retry kept firing into the void, each burning a native connection attempt and a crash-reporter breadcrumb.

The rework made a NetworkService the single source of truth, optimistic by default — it only declares offline on evidence, because a false "offline" is worse than a failed request. The elegant part is how offline is enforced: an interceptor sits first in the HTTP chain and, when offline, raises the same status-0 error a real network failure produces. Every existing error handler and retry-backoff path keeps working unchanged, because offline is indistinguishable from the failure they already handle — it just fails instantly instead of burning a radio timeout. And only absolute cross-origin requests get dropped: bundled assets ride the same HTTP client and must keep loading with zero bars.

The commit for that work is honest about its edges — the realtime socket still retries on its own schedule, and one media path bypasses the interceptor. Written-down limitations are how the next engineer trusts the rest of the claim.

The stance, restated

Offline-first isn't a sync library you install. It's byte-order agreements between your database collation and your client runtime, upload queues that assume interruption, and error paths designed so that no-signal behaves like a fast, quiet failure instead of a screaming one. The reps don't think about any of this. That's the point.