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
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.
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.
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.
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.
designed-for-offline: the network is an optimization, not a precondition.
// 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
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.
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.
On reconnect the queued op replays in order, the server commits it, and operational views are fresh on commit server-side.
Heavy work โ embeddings, semantic search, agent context โ drains on the async lane and reports pending โ fresh, same as any other write.
// 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.
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.
// 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
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.
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.
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.
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.
// 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
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.
Queued writes persist on the device across reloads and restarts โ a crash doesn't drop the note you typed in the basement office.
On reconnect, ops replay in commit order and are idempotent โ a retried sync can't double-write the same observation.
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.
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.
// 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
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
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.