Skip to content

Writes & Intents

The two ways a client changes realm state: last-writer-wins direct writes and atomic server-side intents, plus settle handles.

Read this page before you ship a realm. A client can change state in exactly two ways, 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.

const me = realm.avatar.persistentId; // who you are in this realm
realm.update((draft) => { draft.presence[me].cursorX = 412; });

“Cursor is at 412” is a perfect direct write, because the newest value is the truth. A read-modify-write composed on the client races, though:

// ❌ 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”, and the server applies both, leaving stock at 4 instead of 3. No error and no warning appear, because each write was legal on its own. 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: updates made in the same microtask merge into one frame, keeping only the newest value per path. Pointer-frequency writing is cheap by default. For hover or cursor 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-limited: within each window only the newest value per path is sent.

The coalesceMs window is trailing-edge: the first write opens the window, and the merged frame is sent when the window closes. Nothing is sent on the opening write. That suits a continuous stream, since a cursor emitting all window long loses nothing by waiting. But a lone write pays the full window as added latency: a keyboard transition (accel = true on keydown, seconds after the last write) waits out the entire coalesceMs before it is sent. Match the edge to how the connection is used:

connectRealm(gameRealm, {
// …
coalesce: { ms: 50, leading: true },
});

With leading: true the window becomes a classic leading+trailing throttle. A write that arrives with no open window is sent immediately, and a merge window opens behind it as a cooldown. A burst still coalesces, with the merged remainder flushed once at the window’s end, and sends stay capped at 1000 / ms per second. An isolated transition no longer pays the window as latency. Use this when one connection carries both a held-key stream and transition events. coalesceMs: 120 stays as the trailing-edge shorthand for coalesce: { ms: 120 }, which is right when everything on the connection is a continuous stream.

If only one kind of write on a windowed connection is latency-critical, there is also a per-write escape hatch:

realm.update((d) => { d.inputs[me].brake = true; }, { flush: "immediate" });

It first flushes any buffered coalescing writes, so compose order is preserved exactly as it is for an intent, and then sends its own frame at once. If a path only ever writes transitions, the simplest correct setting is still no window at all: the microtask-only default merges same-tick writes with zero added latency.

For games, the full latency playbook (input lanes, prediction, interpolation, measurement) is at Realtime Games.

The server has a matching option for the downlink, broadcastCoalesceMs. It defaults to 0, and a game realm should leave it there. It exists for chatty non-game realms where per-message cost outweighs latency, not as a pair for the client window.

By default, a write the server never answers expires after pendingExpiryMs and rolls back. That is right for presence and cursors, and wrong for a client that is the authoritative producer of a region. Take 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 covers link failures and never overrides 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. That is the cue to stop queueing and re-sync from your own source of truth after reconnecting.

Intents have no durable tier by design. A durable exchange that returns data is a job for actions and their 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 }) => {
// A retry of a purchase that already went through: commit and change nothing.
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] ??= {}; // a first-time buyer has no record yet
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 direct write’s. A predictor that throws is treated as absent, because prediction must never break the write.
  • apply — receives an Immer draft of the full authoritative state. Return true to commit, or a NiceError to reject. On rejection the draft is discarded entirely, never partially applied.

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

Because an intent commits atomically, everything it touched reaches the client in one flush. That is what you want for consistency, and it 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 such as winner overlays and end-of-round screens on your presentation layer’s own progress (an animating flag, a scene event), not directly on the authoritative field. The realm 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 no 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, and rejected values are rolled back.

Rejections also reach the handle-level onMutationRejected callback with the rolled-back paths. Use it to show a toast and highlight what snapped back:

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, so the optimistic overlay can’t get stuck. The setting is per-handle. Raise it freely on a handle whose UI doesn’t render from realm state, such as a game host drawing from its local sim, or skip expiry entirely for producer regions with durability: "reassert". It is a different setting 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, so 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