Skip to content

Writes & Intents

The realm write contract — LWW direct writes vs atomic server-side intents, settle handles, and choosing between them.

This is the one page to internalise before shipping a realm. There are exactly two ways state changes from a client, and picking the wrong one is the classic way to lose a decrement.

Direct writes: absolute-set, last-writer-wins

Section titled “Direct writes: absolute-set, last-writer-wins”

realm.update() is an optimistic direct write with absolute-set semantics: the server applies your literal values in arrival order.

realm.update((draft) => { draft.presence[me].cursorX = 412; });

“Cursor is at 412” is a perfect direct write — the newest value is simply the truth. But read-modify-write composed locally races:

// ❌ WRONG — two clients doing this concurrently lose one decrement, silently.
realm.update((draft) => { draft.stock[itemId] -= 1; });

Both clients read 5, both send “stock is 4”, the server applies both — stock is 4, not 3. No error, no warning: each write was individually legal. Anything read-modify-write, multi-path atomic, or dependent on hidden state is an intent.

Coalescing — don’t hand-roll a throttle

Section titled “Coalescing — don’t hand-roll a throttle”

Direct writes coalesce: successive updates inside the same microtask merge into one wire frame, deduped per path — pointer-frequency writing is cheap by default. For hover/cursor-grade streams, widen the window with the coalesceMs connect option instead of writing your own throttle timer:

connectRealm(gameRealm, {
// …
coalesceMs: 120, // pointer-frequency writes batch into one frame per 120 ms
});

realm.update((d) => { d.hover = pos; }) on every pointermove is then already rate-shaped — the library dedupes per path inside the window, so only the newest value travels.

The default optimistic contract expires an unanswered write after pendingExpiryMs and rolls it back — right for presence and cursors, wrong for a client that is the authoritative producer of a region (a game host streaming an append-only input log: the data exists nowhere else, so “roll it back” means “lose it”). That role gets the durable tier:

realm.update((d) => { for (const ev of batch) d.log[ev.seq] = ev; }, { durability: "reassert" });

A reassert write never expires: it is rebuilt and resent on every reattach — including across a full rehydrate or a server epoch change — until the server confirms it. Authority still wins: a rules rejection settles it immediately, durable or not; durability is about link failures, never about overriding the server. Two usage rules:

  • One tier per path region. Reassert frames only coalesce with other reassert frames — keep the producer’s paths (the log) durable and everything else default, and never write one path with both tiers.
  • The backlog is bounded. Past ~256 unconfirmed reassert frames (an outage measured in minutes), further reassert writes reject with durable_backlog_overflow — the cue to fall back to re-syncing from your own source of truth after reconnecting, instead of queueing more.

Intents have no durable tier by design: a durable, data-answering exchange is the action plane — that’s reliable delivery. See Reconnects & durability for the full reconnect lifecycle.

An intent is a named mutation whose apply body runs on the server, atomically, against the full authoritative state:

shared/marketRealm.ts
intents: (i) => ({
purchase: i.define({
allow: ["consumer"],
input: t.object({ purchaseId: t.string(), itemId: t.string() }),
// Optional client-side prediction on the caller's own view.
optimistic: ({ view, avatar, input }) => { /* … */ },
apply: ({ state, avatar, input, ctx }) => {
// Idempotent retry: already applied ⇒ commit as a no-op.
if (state.purchases[avatar.persistentId]?.[input.purchaseId] != null) return true;
if (state.stock[input.itemId] < 1) return err_market.fromId("sold_out");
state.stock[input.itemId] -= 1; // authoritative read-modify-write
state.purchases[avatar.persistentId][input.purchaseId] = { itemId: input.itemId };
return true; // or a NiceError — draft discarded entirely
},
}),
}),
client.ts
const handle = realm.intents.purchase({ purchaseId: newId(), itemId });
await handle; // then read the store at your client-supplied key

The pieces:

  • allow — the avatar types that may call it. Together with the input schema, this is the security boundary; the apply body itself is trusted code and bypasses view/alter rules.
  • input — a t.* schema, validated on the way in. Give entities client-supplied ids (purchaseId: newId()) so retries are idempotent and the client knows where to read the outcome.
  • optimistic (optional) — a prediction run on the caller’s own view; its patches join the pending overlay like a write. A predictor that throws is treated as absent — prediction must never break the write.
  • apply — an Immer draft of the full authoritative state. Return true to commit, or a NiceError to reject — the draft is then discarded entirely, never partially applied.

Intents are settle-only: no data result comes back. Outcomes live in state — await the handle, then read the store at the keys you supplied. If you need a data response, that’s a nice-action action.

Because an intent commits atomically, everything it touched lands in one client flush. That is what you want for consistency — and exactly what will spoil an animation: a shot that ends the match delivers the replay payload (lastShot) and meta.status = "finished" together, so a component that renders status directly announces the outcome before the scene has animated the cause. Gate outcome UI (winner overlays, end-of-round screens) on your presentation layer’s own progress — an animating flag, a scene event — not straight on the authoritative field. The state plane says what is true; your scene decides when to show it.

Every write API returns an IRealmWriteHandle — a lazy thenable:

  • Safe to ignore: a fire-and-forget cursor write produces zero unhandled-rejection noise.
  • handle.status ("pending" | "confirmed" | "rejected") and handle.error for imperative checks.
  • Read-your-writes: after await handle, the store already reflects the authoritative outcome — confirmed values are in, rejected values are rolled back.

Rejections also fan into the handle-level onMutationRejected callback with the rolled-back paths — the “toast + highlight what snapped back” hook:

connectRealm(gameRealm, {
// …
onMutationRejected: ({ error, kind, rolledBackPaths }) => toastRollback(error, rolledBackPaths),
});

A pending write that the server never answers (link trouble) expires after pendingExpiryMs (default 10 s) and rolls back — the optimistic overlay can’t get stuck. The dial is per-handle: raise it freely on a handle whose UI doesn’t render from realm state (a game host drawing from its local sim), or skip expiry entirely for producer regions with durability: "reassert". (This is a different dial from nice-action’s reliableActionTimeout, which is a redelivery deadline for at-least-once actions.) Within the window, a disconnect does not expire anything — pending writes resend automatically on reattach; see Reconnects & durability.

If a coalesced flush exceeds the server-advertised maxPatchesPerFrame, the client splits it into consecutive frames. Each part stays atomic on the server and all handles settle with the last part — read-your-writes holds — but a split flush is no longer atomic as a whole. If a group of paths must change together atomically, put them in one intent.

SituationUse
High-frequency absolute-set values (cursor, position, a toggle you own)realm.update
Read-modify-write, counters, balances, multi-path invariantsintent
An append-only region you alone produce (event/input logs)realm.update(fn, { durability: "reassert" })
”My entry must exist while I’m attached” (presence)assertOnAttach
Needs a data response backa nice-action action
Rare single-path write that must not clobber concurrent editsrealm.update + touchedSince CAS in a serverChecked rule
High-stakes / low-confidence mutationintent without optimistic — await the handle (spinner UX, zero rollback risk)
Server-driven mutation (ticks, fulfilment)engine.update() on the server