Offline & local-first sync ๐Ÿ“ถ

Clinics have terrible wifi. A clinical app that breaks at the point of care is useless there. bonfireDB is offline-first: writes commit to a local cache instantly, queue, and sync on reconnect โ€” with the freshness lifecycle extended all the way to sync state.

the worldview

The exam room is where the network is worst

Basement therapy offices, rural clinics, a tablet two walls away from the router โ€” the place a clinician actually charts is the place the connection drops. An EHR that spins on a save, or loses a note because the request timed out, has failed at the one moment that matters. We learned this building TicVision, our own offline-first mobile app: the only acceptable behavior is that a write always succeeds locally and reconciles later. So we built that into the backend instead of leaving every app to hand-roll it.

Server-round-trip writes

Every save is a network call. When the connection blips, the clinician gets a spinner, a timeout, or a silent loss โ€” and the chart can't be written until the bars come back.

  • A dropped request loses the note the clinician just typed
  • The UI blocks on the network at the point of care
  • Each app reinvents a queue, a retry, and a sync diff by hand

bonfireDB โ€” local-first

The write lands in a local cache optimistically, the UI updates instantly, and the operation queues. On reconnect it syncs in order, with conflicts handled โ€” not dropped.

  • Writes succeed offline; nothing is lost when the network is
  • The UI reflects the write immediately โ€” no spinner, no block
  • The queue, retry, ordering, and conflict handling are the platform

A write that succeeds with no network

Call clinical.notes.create(...) on a tablet that's offline and it returns โ€” the note is committed to the local cache, the timeline re-renders, and the operation is durably queued. When the connection comes back, it syncs in commit order. Same API call, online or off.

  • โœ“ Optimistic local commit โ€” the SDK resolves immediately, offline.
  • โœ“ The operation is persisted to a durable on-device queue, not held in memory.
  • โœ“ On reconnect, queued writes replay in order against the server.

designed-for-offline: the network is an optimization, not a precondition.

chartNote.ts
// tablet is offline โ€” this still succeeds
const note = await clinical.notes.create({
  patientId,
  encounterId,
  text,
})

// committed to the local cache right now,
// queued to sync when the network returns
note.sync.status   // "committed-local"
note.sync.queued   // true
note.id            // a real id โ€” usable offline

the freshness lifecycle, extended

Sync is a stage of freshness, not a separate mystery

bonfire's freshness lifecycle already tells you what's committed and what's still cooking. Offline just extends it one stage earlier: a write moves committed-local โ†’ synced โ†’ indexed. You always know whether a record exists only on this device, has reached the server, or is fully indexed for search โ€” instead of guessing.

1

committed-local

The write is durable on this device and the UI reflects it. It exists, it's queued, but the server hasn't seen it yet.

2

synced

On reconnect the queued op replays in order, the server commits it, and operational views are fresh on commit server-side.

3

indexed

Heavy work โ€” embeddings, semantic search, agent context โ€” drains on the async lane and reports pending โ†’ fresh, same as any other write.

sync-status.ts
// the sync-status object on a record
{
  status: "committed-local",   // committed-local | synced | indexed
  queued: true,
  pendingOps: 3,                  // writes waiting to sync on this device
  lastSyncedAt: null,             // no server commit yet
  conflict: null                  // set if reconciliation needs a decision
}

The same object you read for "is search caught up?" now also answers "has this even reached the server yet?" โ€” one lifecycle, from the first local keystroke to the final index.

Reads come from the local cache, instantly โ€” then reconcile

useClinicalQuery reads the on-device cache first, so the screen paints with no network round-trip. When the device reconnects, the query reconciles against the server and re-renders with the authoritative result โ€” the same reactive primitive, now offline-aware.

  • First paint is instant โ€” the cache answers before the network does
  • Your own un-synced writes are visible immediately in the query
  • On reconnect it reconciles and re-renders โ€” no manual refetch
How freshness works โ†’
PatientNotes.tsx
// reads the local cache first โ€” instant, offline-safe
const notes = useClinicalQuery(
  api.notes.listByPatient,
  { patientId }
)

// notes.data renders from cache immediately,
// including your own un-synced local writes
notes.source   // "local" | "synced"
notes.stale    // true until reconciled with the server

the honest hard part

Offline sync is the lost-update problem. We don't pretend otherwise.

Credit where it's due: this is genuinely hard. Two devices edit the same note offline; both reconnect; one naive write clobbers the other โ€” the classic lost update. Anyone who tells you offline sync is "just a queue" hasn't shipped it. Here's how bonfire handles merges instead of hoping they don't happen.

Versioned writes

Every record carries a version. A queued write replays with the version it was based on, so the server can detect that the record moved underneath it โ€” no blind PUT that silently overwrites a concurrent edit.

Field-level merge

Non-overlapping edits to the same record merge automatically. Two clinicians touching different fields don't collide โ€” only a true overlap is treated as a conflict at all.

Conflicts surface, never silently resolve

When edits genuinely overlap, the sync object reports a conflict with both versions. For clinical data we'd rather raise it to a human than guess โ€” a wrong auto-merge of a chart is worse than a prompt.

reconcile.ts
// on reconnect, a queued write whose base version is stale
// does not clobber โ€” it surfaces a conflict
{
  status: "synced",
  conflict: {
    field: "assessment.plan",
    base:   "v4",           // what your edit was based on
    server: "v6",           // what the record is now
    mine:   "โ€ฆincrease to weekly",
    theirs: "โ€ฆhold at biweekly"
  }
}

// you decide โ€” keep, take, or merge โ€” explicitly
await clinical.sync.resolve(note.id, { keep: "mine" })

The honest trade-off: clinical data biases toward raising a conflict over auto-resolving one. bonfire merges what's safe to merge and asks a human about the rest โ€” because a silently lost charting edit is a patient-safety problem, not a UX nit.

why it's Postgres-first, even here

One sync model for web and mobile โ€” no Redis, no bespoke diff

The queue, the ordering, and the reconciliation are the same whether the client is a React web app or a React Native tablet. No per-app GET /sync diff endpoint, no last_sync_time bookkeeping, no Redis to coordinate it. The local cache is the durable on-device store; Postgres remains the source of truth on commit.

Durable local queue

Queued writes persist on the device across reloads and restarts โ€” a crash doesn't drop the note you typed in the basement office.

Ordered, idempotent replay

On reconnect, ops replay in commit order and are idempotent โ€” a retried sync can't double-write the same observation.

Same model, web + mobile

The offline contract is identical across clients. You don't write one sync layer for the portal and another for the tablet.

See the TicVision rebuild โ†’

Postgres-first by default: the source of truth is one database, and sync is a property of the SDK + freshness lifecycle โ€” not a second system you stand up, secure under a BAA, and page someone about at 2am.

The TicVision dogfood: we deleted our own sync code

TicVision is a React Native (Expo) app that's offline-first by necessity โ€” people log tics anywhere, network or not. The original build had a hand-rolled GET /sync endpoint diffing changes since last_sync_time, plus offline reconciliation bookkeeping on the client. Rebuilt on bonfire, that whole subsystem becomes the local-first SDK + freshness layer.

  • โœ“ The /sync diff endpoint and its last_sync_time logic โ€” deleted.
  • โœ“ A tic logged on the subway commits locally and syncs on reconnect.
  • โœ“ The same offline contract the mobile app needed, now built in.
Read the full case study โ†’
logTic.ts ยท offline-first
// logged on the subway โ€” no signal, still works
const tic = await clinical.observations.record({
  patientId,
  code: "tic-severity",
  value: intensity,        // 1โ€“10
  effective: occurredAt,
})

tic.sync.status            // "committed-local"

// reconnect above ground โ†’ replays in order,
// no /sync diff, no last_sync_time to track

honest scope

Where this is โ€” early access, told straight

Offline & local-first sync is core to bonfire's product vision because it's core to charting at the point of care. We're in early access, so here's the straight version of what that means.

bonfireDB is pre-launch and early-access; this page describes product design and positioning, not shipped-today guarantees. "FHIR" is used descriptively (FHIRยฎ is a registered trademark of HL7ยฎ).

where this fits

Offline rides the same freshness spine as everything else

Always fresh

The lifecycle offline extends โ€” committed-local โ†’ synced โ†’ indexed.

Explore โ†’

Clinical authorization

Synced writes pass the same gate, and every op is audited.

Explore โ†’

FHIR underneath

Local-first records become FHIR R4 on commit, exportable on demand.

Explore โ†’

How it works

The Postgres-first architecture the whole sync model runs on.

Explore โ†’

You build the app. Bonfire is the clinical data layer underneath.

Writes that succeed offline, queue, and reconcile on reconnect โ€” with conflicts raised, not lost, and sync state baked into the freshness lifecycle. Stop hand-rolling a /sync diff and start shipping your app.