Roadmap · post-launch

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.

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, hybrid 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 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.
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 — 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

Fresh on commit

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 land as canonical 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.