loceldocumented
GuidesBasics

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 refused

tx.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:

src/sync/db.ts
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:

  1. Verifies the fingerprint, and refuses if it does not match.
  2. Verifies it is not torn — every $version and every outbox base version is at or below the checkpoint.
  3. Replays the outbox on top of confirmed state to rebuild optimistic state.
  4. 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.

CodeMeaning
E_UNKNOWN_COLLECTIONThe collection is not in the server's schema. Almost always a client running ahead of a deploy.
E_ROW_OUTSIDE_PARTITIONThe row's partition columns do not match the session's scope.
E_FIELD_NOT_WRITABLEThe patch touches a field not in writable, or one marked serverOwned().
E_POLICY_REFUSEDA write policy returned false.
E_ROW_NOT_FOUNDAn update or delete against a row that does not exist.
E_ROW_EXISTSAn insert against a key that already exists.
E_ROW_DELETEDAn update whose base version predates a concurrent delete, under the default onDeleteConflict: 'reject'.
E_SCHEMA_MISMATCHThe transaction was authored against a schema the server no longer has.
E_INVALID_VALUEA 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_PARTITION comes from

On this page