Skip to content

Feature request: awaitable per-write push acknowledgement (opt-in write-through for replication) #8632

Description

@KeyTurns

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

  1. The local write happens normally (optimistic, local-first untouched).
  2. 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).
  3. Optionally a way to flush immediately (replicationState.pushNow() or an option on the write) so the ack doesn't wait for batching/debounce.
  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions