An integrated, opt-in way to await the server's acceptance of one specific local write through the existing replication machinery — a "write-through" mode for individual writes, while everything else stays local-first.
// Sketch — exact shape up to you, see options below
const doc = await collection.upsert(data);
const ack = await replicationState.awaitDocumentPushed(doc.primary, { timeout: 5000 });
// ack: { status: 'accepted' }
// | { status: 'conflict', masterState } — server rejected/resolved differently
// throws on timeout / replication cancelled / offline
Local-first is the right default and we love it. But almost every app has a handful of writes where the user must synchronously know "did the server take it?" — admin/settings forms with server-side validation, anything money-adjacent, uniqueness-constrained fields. Today those writes have to leave the replication world entirely, which creates real bugs (war story below).
Why the existing primitives don't cover it
awaitInSync() is collection-wide, not per-document; your own docs (rightly) warn against blocking the app on it; with multiInstance: true + waitForLeadership: true it never resolves in a non-leader tab; and offline it simply never settles — so it can't back a "Save" button.
sent$ / received$ / error$ emit documents and errors, but there is no way to correlate my write with its push outcome. Worse, a server-side rejection is expressed as a conflict row in the push handler's return value — RxDB then resolves it via the conflictHandler and the local state quietly converges to the master. Correct for sync; invisible to the caller who needs to show "VAT id invalid" on the form.
- Hand-rolling a parallel REST endpoint (what we do now) means two write paths for the same entity, duplicated validation, and a footgun we actually shipped: after the REST save we patched ONE field of the saved entity into the local RxDB doc (so local readers update immediately) — that local write marked the doc dirty, replication pushed the whole stale document, and last-write-wins reverted the values the REST call had just written. Entirely our bug, but it's the kind of bug this feature would make unnecessary: with an awaitable push we'd have written through replication in the first place — one write path, one roundtrip.
Proposed semantics
- The local write happens normally (optimistic, local-first untouched).
- The caller can await the outcome of that document's next push:
- accepted — the push handler returned no conflict for the row → resolve.
- conflict/rejected — the push handler returned the row in its conflicts array → the normal
conflictHandler flow runs (so state converges exactly as today), and the awaited promise resolves with { status: 'conflict', masterState } (or rejects, bikeshed) so the UI can surface the server's reason.
- offline / cancelled / timeout — reject fast with a typed error; the write stays local and syncs later (caller decides whether to revert).
- Optionally a way to flush immediately (
replicationState.pushNow() or an option on the write) so the ack doesn't wait for batching/debounce.
- Must work from non-leader tabs under
multiInstance (forward to the leader or temporarily push from the calling tab).
Per-row outcome data already exists internally — the push handler's return value is row-granular — so this is mostly about exposing a correlation point per document, not changing the protocol.
API shape options
// A) On the replication state (least invasive)
await replicationState.awaitDocumentPushed(docId, { timeout?, flush?: boolean });
// B) Per-write opt-in on the collection
await collection.upsert(data, { awaitPush: { replicationState?, timeout? } });
// C) A combined helper with optional revert-on-reject
await replicationState.writeThrough(
() => collection.upsert(data),
{ timeout: 5000, revertOnConflict: true },
);
A) feels most aligned with the existing RxReplicationState API (awaitInSync family). C)'s revertOnConflict is sugar over the standard conflict resolution and could come later.
Alternatives considered
upsert() + awaitInSync() + reading the doc back and diffing: racy (another pull can interleave), collection-wide, blocks on unrelated pending writes, and cannot distinguish "accepted" from "conflict-resolved back to master".
- Subscribing to
sent$/error$ with manual correlation: sent$ fires on send, not on accept; rejections surface in neither stream as a per-row outcome.
- Parallel REST endpoint: works, but splits the write path and reintroduces exactly the consistency bugs replication exists to prevent (see war story).
- Prior art: Firestore's
waitForPendingWrites() has the same collection-wide limitation; Replicache's mutators give per-mutation server acks — that's the ergonomics we're after, inside RxDB's protocol.
Environment
RxDB 16.x, replicateRxCollection with custom HTTP push/pull handlers (multi-tenant offline-first B2B app; ~40 replicated collections).
An integrated, opt-in way to
awaitthe server's acceptance of one specific local write through the existing replication machinery — a "write-through" mode for individual writes, while everything else stays local-first.Local-first is the right default and we love it. But almost every app has a handful of writes where the user must synchronously know "did the server take it?" — admin/settings forms with server-side validation, anything money-adjacent, uniqueness-constrained fields. Today those writes have to leave the replication world entirely, which creates real bugs (war story below).
Why the existing primitives don't cover it
awaitInSync()is collection-wide, not per-document; your own docs (rightly) warn against blocking the app on it; withmultiInstance: true+waitForLeadership: trueit never resolves in a non-leader tab; and offline it simply never settles — so it can't back a "Save" button.sent$/received$/error$emit documents and errors, but there is no way to correlate my write with its push outcome. Worse, a server-side rejection is expressed as a conflict row in the push handler's return value — RxDB then resolves it via theconflictHandlerand the local state quietly converges to the master. Correct for sync; invisible to the caller who needs to show "VAT id invalid" on the form.Proposed semantics
conflictHandlerflow runs (so state converges exactly as today), and the awaited promise resolves with{ status: 'conflict', masterState }(or rejects, bikeshed) so the UI can surface the server's reason.replicationState.pushNow()or an option on the write) so the ack doesn't wait for batching/debounce.multiInstance(forward to the leader or temporarily push from the calling tab).Per-row outcome data already exists internally — the push handler's return value is row-granular — so this is mostly about exposing a correlation point per document, not changing the protocol.
API shape options
A) feels most aligned with the existing
RxReplicationStateAPI (awaitInSyncfamily). C)'srevertOnConflictis sugar over the standard conflict resolution and could come later.Alternatives considered
upsert()+awaitInSync()+ reading the doc back and diffing: racy (another pull can interleave), collection-wide, blocks on unrelated pending writes, and cannot distinguish "accepted" from "conflict-resolved back to master".sent$/error$with manual correlation:sent$fires on send, not on accept; rejections surface in neither stream as a per-row outcome.waitForPendingWrites()has the same collection-wide limitation; Replicache's mutators give per-mutation server acks — that's the ergonomics we're after, inside RxDB's protocol.Environment
RxDB 16.x,
replicateRxCollectionwith custom HTTP push/pull handlers (multi-tenant offline-first B2B app; ~40 replicated collections).