Writing data
What happens between calling insert and the server agreeing — the outbox, the three settle points, and every way a write can be refused.
This guide covers the write path end to end. You will learn:
- How a write becomes visible, durable and confirmed, and how to await each
- How to batch writes into one server transaction
- How the outbox replays after a reload, and why it is ordered
- Every reason the server can refuse a write, and what the client does about it
Status: Built. The API below exists and is under test.
Overview
todosCollection.insert({ title: 'Ship it' }) returns before anything has left
the device, and the row is on screen in the same frame. That is the easy part,
and every optimistic UI library does it.
The hard part is the next three seconds. The write has to reach disk before the tab can be closed. It has to survive a reload that happens mid-flight. It has to go to the server exactly once even though the app tried twice. It has to come back and either agree with what is on screen or correct it. And if the user has been offline since Tuesday, forty of these have to happen in order.
That machinery is the outbox, and this guide is about what it promises.
The three settle points
A write passes through three states, and you can wait for any of them.
const tx = replica.transaction(() => {
todosCollection.update(todo.id, (draft) => {
draft.done = true
})
})
tx.id // string, generated on the client
await tx.durable // reached the replica; a reload will find it
await tx.confirmed // the server answered; rejects if it refusedtx.durable resolves before insert returns control to your event handler in
practice, but it is a promise because storage is storage. The write is visible
and durable in the same step. There is no window in which the screen is ahead
of the disk, because the row is rendered from the replica rather than from a
memory layer that storage catches up with later.
tx.confirmed resolves when the server has accepted the transaction — including
when it accepted it and resolved a field against somebody else's concurrent
write. It rejects only when the server refused the transaction whole. See
Conflicts for why "accepted" and "unchanged" are different
things.
Offline, tx.durable resolves and tx.confirmed stays pending, potentially for
days. That is correct and it is why the two exist separately — code that awaits
confirmation before letting the user continue has made the network a
prerequisite for typing.
Ignoring confirmed is safe. It is not a floating promise: refusals are
always delivered through onRejection and
replica.rejections whether or not anybody awaited it, and the handle keeps a
no-op rejection handler
so an unawaited transaction never becomes an unhandled rejection. Await it when
you have something specific to do at that moment; otherwise let it be.
Single writes
The TanStack DB collection API is the ordinary path, and each call is its own transaction.
todosCollection.insert({ workspaceId, title: 'Ship it' })
todosCollection.update(todo.id, (draft) => {
draft.title = 'Ship it today'
})
todosCollection.delete(todo.id)Each returns TanStack DB's own transaction handle, whose isPersisted.promise
maps to locel's confirmed by default. If you would rather it mean durable —
which is usually right for an app that expects long offline sessions, since
otherwise the handle stays pending for the whole trip — set it once:
locelCollectionOptions({ replica, collection: todos, settle: 'durable' })With settle: 'durable', a later refusal still surfaces — in
replica.rejections, and through onRejection — it just does not come back
through the promise you already resolved.
Batching
Wrap writes to commit them together:
const tx = replica.transaction(() => {
todosCollection.update(a.id, (draft) => { draft.position = 1 })
todosCollection.update(b.id, (draft) => { draft.position = 2 })
categoriesCollection.insert({ workspaceId, name: 'Later' })
})One transaction, across collections, with one sequence number. Either all three land or none do — including if the server refuses the third for a policy reason it has never heard of the first two.
Writes inside the callback apply to optimistic state immediately, so the UI does not flicker through an intermediate ordering.
What is actually sent
A mutation carries the field patch, the primary key, and the base version — the version the client had for that row when the user made the change.
It does not carry the client's checkpoint, and it does not carry the row's current server version, because neither answers the question the server needs to ask. That question is "has anybody else moved these fields since this person last looked?", and only the base version knows what they were looking at.
The base version is stored with the transaction and preserved verbatim across a reload. A tab that comes back four hours later replays a write whose base version is four hours old, and that is not staleness to be corrected — it is the fact the conflict rule is built on.
Two edges worth knowing
An insert against a key that already exists is refused, with E_ROW_EXISTS.
It does not become an update. A duplicate primary key means two devices minted
the same id or your code inserted twice, and silently upserting would turn a bug
into two users sharing a row. Replay is not a source of this: a re-sent
transaction is recognised by its id and returns the original result rather than
applying again.
Two mutations against the same row in one transaction merge, field by field,
last write winning per field. They carry one base version — the one from when
the row was first read — so update(a, …) twice in a callback is one mutation
on the wire and one conflict comparison, not two.
Ordering and at-most-once
The outbox is an ordered queue per writer. Transactions are sent in the order they were created and one at a time; the next does not go until the previous has been answered.
Serial rather than parallel, deliberately. A writer's own earlier write is never a conflict with its own later one — that rule is what stops your offline session from fighting itself — and it is only true if the server sees them in the order you made them.
Every transaction carries a client-generated id — a ULID or UUIDv7, and it must be globally unique, because the server applies each id at most once and two clients colliding would hand one of them the other's result. locel generates it; you only need to care if you build your own client.
So So the retry after a timeout, the replay after a crash, and the duplicate from a flaky proxy all collapse to one apply. You do not need idempotency keys of your own on top.
Replay after a reload
On attach, the replica:
- Verifies the fingerprint, and refuses if it does not match.
- Verifies it is not torn — every
$versionand every outbox base version is at or below the checkpoint. - Replays the outbox on top of confirmed state to rebuild optimistic state.
- Resumes sending from the first unanswered transaction.
Step 3 is not a special path. It is the same rebase that runs on every acknowledgement, rejection and remote edit — optimistic state is always confirmed state plus the outbox, recomputed, never patched in place. A reload is just the case where confirmed state came from disk instead of the socket.
Refusals
A refusal rejects the whole transaction. The client rolls it back, removes it from the outbox, and reports it.
| Code | Meaning |
|---|---|
E_UNKNOWN_COLLECTION | The collection is not in the server's schema. Almost always a client running ahead of a deploy. |
E_ROW_OUTSIDE_PARTITION | The row's partition columns do not match the session's scope. |
E_FIELD_NOT_WRITABLE | The patch touches a field not in writable, or one marked serverOwned(). |
E_POLICY_REFUSED | A write policy returned false. |
E_ROW_NOT_FOUND | An update or delete against a row that does not exist. |
E_ROW_EXISTS | An insert against a key that already exists. |
E_ROW_DELETED | An update whose base version predates a concurrent delete, under the default onDeleteConflict: 'reject'. |
E_SCHEMA_MISMATCH | The transaction was authored against a schema the server no longer has. |
E_INVALID_VALUE | A value fails the declared type — a non-finite number, a null in a non-nullable field. |
Every one of them is a bug or a race, never a routine outcome, which is why they are refusals rather than results. The routine outcome — somebody else edited the same field first — is a merge, not a refusal, and it does not appear in this table.
Handling refusals
A refusal rolls the row back, so there is no row left to carry the news — that
is what $conflict does for a merge, which is a
different outcome. Refusals are recorded on the replica instead, durably:
const refused = replica.rejections
// [{ transaction: '01J…', code: 'E_POLICY_REFUSED', collection: 'todos',
// key: 't_91', message: '…', mutations: [ … ] }]It is written in the same transaction that rolls the write back, so it survives
the reload that a toast would not, and it stays until
replica.acknowledge(rejection).
For anything that needs to happen once, at the moment the server answers, subscribe:
replica.onRejection((rejection) => {
telemetry.count('locel.rejected', { code: rejection.code })
})Two rules for whatever you do here. Do not retry automatically — every code
in the table above will fail again the same way, and a retry loop against
E_ROW_OUTSIDE_PARTITION is a request storm. And do not discard silently: a
user who ticked three todos on a train is owed the information that one of them
did not stick, even if the other two did.
Server-owned fields
The server stamps its own timestamps on every accepted mutation, so updatedAt
appears on the row when the write is confirmed — not when it is made. A
server-owned field that is not a timestamp is never written: the server has no
value to invent for one.
This means a freshly inserted row has no updatedAt until the server answers.
Render for that: updatedAt is typed as possibly absent on optimistic rows, and
$pending tells you which state you are looking at.
Where to go next
- Conflicts — what happens when the write is accepted but changed
- Offline and durability — how long the outbox can get, and what happens when storage fills
- Partitions — where
E_ROW_OUTSIDE_PARTITIONcomes from