Offline & local-first sync
Clinics have terrible wifi. A clinical app that breaks at the point of care is useless there. On the post-launch roadmap, bonfireDB goes 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. This page describes the planned design, not a shipped feature.
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’re building 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.
// 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.
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.
synced
On reconnect the queued op replays in order, the server commits it, and operational views are fresh on commit server-side.
indexed
Heavy work — embeddings, hybrid 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.
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
// 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.
// 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 custom 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; on commit, the canonical FHIR R4 record in your one Postgres is updated.
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: it’s one canonical 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: deleting 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 go away.
- ✓ A tic logged on the subway commits locally and syncs on reconnect — the target contract.
- ✓ The same offline contract the mobile app needed, being built in.
// 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 — post-launch roadmap, told straight
Offline & local-first sync is core to bonfire’s product vision because it’s core to charting at the point of care. It’s explicitly post-launch — a multi-week sync/CRDT subsystem, not part of the v0 launch — so here’s the straight version of what that means.
- ✓ This is the design of bonfire’s offline model — optimistic local writes, durable queue, versioned reconciliation, sync stages in the freshness lifecycle. It’s the contract we’re building to, not a list of shipped-today guarantees.
- ✓ Conflict handling biases toward raising overlaps to a human over silently auto-merging — a deliberate trade-off for clinical data, not a limitation we’re hiding.
- ✓ The lineage is real: TicVision is our own offline-first app, and the wins map to its actual /sync endpoint and client bookkeeping — this is us porting our own app, not a customer migration we ran.
- ✓ bonfireDB is the open-source clinical backend — TypeScript + Postgres + pgvector, FHIR R4 underneath, no Redis. Offline sync rides that same Postgres-first core.
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 Health Level Seven International, and HL7® does not endorse bonfireDB.
where this fits
Offline rides the same freshness spine as everything else
FHIR underneath
Local-first records land as canonical FHIR R4 on commit, exportable on demand.
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.