useOutbox
Queue mutations offline, replay them in order on reconnect.
Queue mutations while offline, replay IN ORDER on reconnect — over the existing optimistic layer. Replay stops at every failed predecessor to preserve causality. NOT multi-device CRDT (single-user/device, honest about its limits).
const outbox = useOutbox({ send })
// → { queue, enqueue, replay, online, pending, conflicts, resolveConflict }Online enqueues and rehydration start replay automatically. Concurrent replay
calls join one drain; edits appended while a send awaits acknowledgement join
that drain in order. A transient failure retains the entry and its successors
and is retried on the outbox's own clock after retryDelay(attempts) — by
default defaultOutboxRetryDelay, 1 s doubling to 30 s with jitter. Coming
back online, or replay(), retries at once. A server that is down while the
network is up fires no online event, so waiting for one alone would leave the
queue stuck. Rendering or adding another edit does not cut the wait short.
Unmounting prevents further sends but cannot cancel or undo an already
dispatched request.
Persistence across reloads. With persistence, successfully saved entries
rehydrate on mount before newer writes are sent. Every transition requests a
save (delivered entries are compacted away). The backing must serialize saves;
enqueue() does not acknowledge a durable commit. A failing load or save
silently degrades to in-memory behaviour: this is not a zero-data-loss promise.
Server-side idempotency and ownership across tabs/users are separate concerns.
The provided implementation is
outboxPersistence() from @voltro/local-first, backed by the same
PersistenceAdapter the sync engine drains — one durable queue per device,
partitioned by who is signed in when you pass partition:
import { createIndexedDbPersistence, outboxPersistence } from '@voltro/local-first'
const adapter = await createIndexedDbPersistence()
const outbox = useOutbox({
send,
persistence: outboxPersistence(adapter, { partition: () => currentSubjectId }),
})load reads the partition and returns only its writes; every save until the
next load writes under that partition and never touches another subject's —
so a write acknowledged while a sign-in is being switched stays with the
subject who made it. Saves are serialised.
Resolving a conflict. A conflicted entry blocks everything behind it
(causality). resolveConflict(id, input) replaces its input with the RESOLVED
value, returns it to pending and replays — typically computed with
resolveWithPolicy() from @voltro/local-first, where crdtText() columns
merge and scalars follow the declared conflictPolicy():
outbox.resolveConflict(entry.id, resolveWithPolicy(policy, local, remote, {
crdtColumns: ['body'],
}))Bound to an api: the offline write lane
const outbox = useOutbox({ api: 'app', persistence })
outbox.enqueue('notes.create', { title: 'Written offline' })With api, send defaults to that api's own mutation for the entry's tag,
and four things come with it:
- Idempotent resends. The entry id is the mutation's idempotency key, so a write the server committed whose answer was lost is not applied twice when it is resent (where the api has idempotency configured).
- Previews that survive a reload. The mutation's declared
targetsstage a standing optimistic patch on every query of its table — open now, opened later, or rendered from the local-first mirror after a reload — until the write is sent (the server's echo replaces it) or conflicts (reverted). - The api's connectivity. The outbox drains when a write can land: the browser's signal AND the api's transport.
- A queue per signed-in subject. A sign-in, sign-out or tenant switch drops
the previous subject's queue from memory and loads the next one's from
persistence— partition it withoutboxPersistence(adapter, { partition }).
stamp: clock.now (a hybrid logical clock)
records entry.stamp on every write, the local side's ordering key when a
conflict is resolved.
Connectivity: useApiOnline
import { useApiOnline } from '@voltro/client'
const online = useApiOnline('app') // or useApiOnline() for any mounted apitrue when a write can reach the server: the browser's online/offline
signal AND the api's own transport, so it reads false while the server is
down although the network is up. A navigator that does not report onLine
(React Native, Node) counts as online.