Skip to main content
Version: 57.2.0

Local-first data in Terreno

Terreno's default frontend data path is local-first: the on-device store is what your UI reads and writes, and the server reconciles changes asynchronously. This is a different mental model from request/response fetching with RTK Query.

What local-first means here

In @terreno/syncdb, a TinyBase MergeableStore on the device is the UI source of truth. When a user taps "save," the mutation applies to local state immediately and lands in a durable outbox. The client then pushes the change to @terreno/api over the sync protocol (socket deltas with HTTP snapshot catch-up). The server remains the authority for permissions, validation, and conflict resolution — but the UI does not wait on the network for every write.

There is no server-first mode and no opt-in flag: if you use syncdb, you adopt this model for every synced collection.

Mental model shift

Server-first (RTK Query)Local-first (syncdb)
UI reads from cache filled by HTTPUI reads from the local store
Writes call the API, then invalidate/refetchWrites apply locally, then reconcile
Loading spinners track request lifecycleSync status tracks outbox + connectivity
Optimistic updates are manual and fragileOptimistic state is the default path
Conflicts are rare / ignoredConflicts are explicit and must be resolved

Think apply locally, then reconcile instead of request, wait, update cache.

What you stop doing

  1. Manual optimistic updatesonQueryStarted patches, temporary IDs, and rollback logic for writes.
  2. Write-level loading spinnersisLoading on mutations for "saving…" UX; the row updates instantly.
  3. Refetch orchestrationinvalidatesTags, refetch(), and list/detail cache coordination after mutations.
  4. Treating the network as the read path — list screens blocked on isLoading for every navigation.

RTK Query remains appropriate for non-synced endpoints: /auth/me, admin RPC, AI routes, and anything that should stay request/response.

What you start doing

  1. Conflict resolution UI — When the server rejects a mutation (409 / sync:nack), the user must choose a strategy (useServer or keepMine). Use ConflictSheet from @terreno/ui or build your own with useConflicts.
  2. Sync-status UX — Surface offline state, queued mutations, and drain progress with useSyncStatus (and optionally SyncStatusBanner).
  3. Encryption at rest (web) — Web persistence uses AES-GCM via the default createServerKeyProvider; understand key fetch and onDecryptFailure for support flows.
  4. Key lifecycle on logout — Call syncDb.stop() and consider wipeLocalData when the authenticated user changes so one account does not read another's local store.
  5. Backend sync registration — Every synced collection needs syncPlugin, isDeletedPlugin, and a sync block on modelRouter, plus SyncApp and RealtimeApp on the server.

Tradeoffs

Gains: instant UI, offline writes with a durable outbox, simpler screen code, and one write path on the server (sync and REST share modelRouter).

Costs: larger conceptual surface (conflicts, sync status, local storage), mandatory replica-set MongoDB for realtime, and migration work for existing RTK Query screens.

Server-first fetching is simpler when you only need online CRUD with no offline requirement — but Terreno's blessed path for collection data is local-first.