Presence Realms
The textbook first realm — a lobby/roster of "who is here, and what are they doing", built from assertOnAttach, last-detach pruning, and ephemeral hosting.
Presence — a lobby roster, cursors on a canvas, “who is watching” — is the textbook first realm: pure shared state, everyone sees everyone, each avatar owns exactly its own entry. It is also where hand-rolled sync stacks grow the most belts (reconcile polls, re-assert-on-reconnect, last-socket scans, stale-entry sweeps). A presence realm needs none of them — this page is the whole pattern, end to end.
The definition
Section titled “The definition”One record, keyed by the avatar’s persistent id; the owner writes their entry, the server owns membership:
import { defineRealm, t } from "@nice-code/realm";
export const lobbyRealm = defineRealm({ id: "lobby_realm", avatars: { player: { persistentId: t.string() } }, state: { players: t.record( t.id("playerId"), t.object({ name: t.string(), position: t.object({ x: t.number(), y: t.number() }), facing: t.union(t.literal(1), t.literal(-1)), activityId: t.optional(t.string()), // "watch me" pointers, status, … }), ), }, rules: (r) => [ // Everyone sees the roster; only the owner writes their own entry. r.path("players.$playerId.**") .view(r.everyone) .alter(({ avatar, params }) => avatar.persistentId === params.playerId || err_lobby.fromId("not_your_entry"), ), ],});The client — assertOnAttach is the join
Section titled “The client — assertOnAttach is the join”There is no “join” action. Your entry is state you must own whenever you are attached, and that is exactly what assertOnAttach declares:
const realm = connectRealm(lobbyRealm, { avatar: { type: "player" }, // persistentId from the authenticated coordinate connection: realmConnection(connector), assertOnAttach: (draft) => { draft.players[me] = initialEntry(); },});
// Everything else is plain optimistic writes — coalesced, deduped, rate-shaped:realm.update((d) => { Object.assign(d.players[me].position, target); });realm.update((d) => { d.players[me].activityId = runId; });The hook re-runs on every full hydrate — first attach, an out-of-log-window resume, an epoch change after a server wipe — which is precisely the set of moments your entry could have been lost. It does not re-run on an ordinary patch-replay reconnect (your entry is still confirmed there), and within pendingExpiryMs any in-flight writes resend on their own. The re-assert belts a hand-rolled lobby carries — “re-join after reconnect”, “re-announce my status after the socket blips”, “recover after a server restart” — are this one option.
For pointer-frequency fields (cursor, walk target), set coalesceMs on the connect options instead of hand-throttling — see coalescing.
Write cadence is a network cadence, not a render cadence
Section titled “Write cadence is a network cadence, not a render cadence”The instinct for a cursor is to write inside requestAnimationFrame. That bounds writes to the display refresh rate — which is a rendering budget (60 Hz, or 120–144 Hz on a high-refresh monitor), not a networking one. Every write you emit is one authoritative commit fanned out to every other attached avatar, and on a Durable Object every one of those is a billed inbound message. A room of N cursors all writing at 144 Hz is N × 144 messages/second arriving at each participant — the cost scales with the crowd, so pick the cadence deliberately:
const realm = connectRealm(lobbyRealm, { // … coalesceMs: 40, // ~25 cursor frames/sec is smooth; the newest value per path wins the window});Set coalesceMs to your real network cadence (30–50 ms is imperceptible for cursors) and let the coalescer collapse the rAF stream into it — deduped per path, so only the latest position in each window travels. Smooth the remote dots with a short CSS transition rather than by writing faster: sparse network updates plus interpolation look better than a flood, and cost a fraction of the messages. (The server side of this fan-out — batching and tick-rate broadcast — is the library’s job, tracked in the realm message-overload plan; coalesceMs is the half you own.)
The server — pruning is one line
Section titled “The server — pruning is one line”onAvatarDetach fires once per avatar identity when its last connection drops — multi-tab and multi-device are counted by the engine, so the prune handler is genuinely this:
const realms = serveRealmDurableObject(this.ctx, { realms: { lobby_realm: hostRealm(lobbyRealm, { avatarType: "player", persistence: "ephemeral", // presence has no history worth persisting engine: { ctx: {}, initialState: { players: {} }, onAvatarDetach: (avatar) => { realms.engines.lobby_realm.update((d) => { delete d.players[avatar.persistentId]; }); }, }, }), },});No socket scanning, no “is this their last tab” bookkeeping, no departure races. Hibernation is covered too: avatars whose sockets vanished while the DO slept get a synthesized detach on wake, so the roster never keeps ghosts.
avatarDetachGraceMs — don’t flap on a blip
Section titled “avatarDetachGraceMs — don’t flap on a blip”With the connection’s auto-redial, a transient drop heals in ~1–2 s — but a bare onAvatarDetach still fires the instant the socket closes, and onAvatarAttach a second later. Every presence consumer ends up hand-rolling a departure debounce (and inheriting a real footgun: a raw setTimeout in a Durable Object is not hibernation-safe). Declare the debounce instead:
engine: { // Fire onAvatarDetach only if the avatar hasn't re-attached within the window. // A re-attach inside it swallows BOTH hooks — the avatar never left. avatarDetachGraceMs: 8_000, onAvatarDetach: (avatar, reason, ctx, detail) => { // detail.via: "grace-expired" (window elapsed, detail.deferredMs tells how late) // or "immediate" (no grace / a revocation via detachAvatar) realms.engines.lobby_realm.update((d) => { delete d.players[avatar.persistentId]; }); },},On the DO host the deferred detach is backed by the alarm mux + persisted pending rows, so it fires even if the DO hibernates mid-window — the guarantee a consumer setTimeout cannot give. Wake losses run the same funnel: a player whose socket died while the DO slept gets the grace too, so their prompt redial swallows the detach instead of flapping the roster. detachAvatar() (revocation) always bypasses the grace — kicking someone is not a blip.
Why persistence: "ephemeral" is right here
Section titled “Why persistence: "ephemeral" is right here”Presence is attach/detach churn — persisting its patch log buys nothing. Ephemeral hosting skips SQLite entirely: every cold start boots the empty roster on a fresh epoch, surviving clients full-rehydrate, and assertOnAttach re-fills the roster within a round-trip. The two features are a pair: ephemeral hosting is only safe because the assert hook exists (and the docs will keep saying so). Schema changes stop mattering as well — presence never needs a migrate; if you keep the realm durable for other reasons, migrate: "wipe" declares the same disposability.
Mixed realms: mark presence transient instead
Section titled “Mixed realms: mark presence transient instead”persistence: "ephemeral" is all-or-nothing — it makes the whole realm disposable. The common case is mixed: a board that keeps notes and a click counter (durable) but also carries live cursors (throwaway). Hosting that whole realm as ephemeral would lose the notes; hosting it as durable makes every 60 Hz cursor write pay the full persistence cost — a SQLite log row per commit and a slice of every snapshot — for state worthless a second later.
Declare the hot region transient on the definition instead:
export const boardRealm = defineRealm({ id: "board", state: { notes: t.record(/* … durable … */), board: t.object({ totalClicks: t.number() }), presence: t.record(t.id("visitorId"), t.object({ x: t.number(), y: t.number() })), }, transient: ["presence"], // cursor writes: broadcast, but never logged / snapshotted / CAS-tracked rules: (r) => [ /* … */ ],});A transient write still broadcasts (peers see the cursor) and still obeys its view/alter rules — it just isn’t persisted: no log row, no snapshot bytes, no touchedSince history. On a cold start (or an out-of-window resume) the region comes back empty and each client re-owns its entry via assertOnAttach; a resuming client’s staleness probe refreshes it for free. v1 transient paths are concrete, wildcard-free subtree roots (["presence"]), validated at defineRealm time.
Which durability tier does my state belong in?
Section titled “Which durability tier does my state belong in?”| Tier | Declare it with | Persisted? | Rolled back on link loss? | Use for |
|---|---|---|---|---|
| Durable (default) | nothing | yes (log + snapshots) | no | Notes, scores, anything with history |
| Transient region | transient: ["presence"] | no | no | Cursors, “who’s looking”, live position — hot, throwaway, on a realm that also has durable state |
| Ephemeral realm | persistence: "ephemeral" | no (whole realm) | no | A pure-presence realm with nothing worth persisting |
| Durable client write | realm.update(fn, { durability: "reassert" }) | (client-side) | no — resent until confirmed | A client that is the sole producer of a region (an input log) — see durable writes |
Transient regions survive a wake
Section titled “Transient regions survive a wake”Transient and assertOnAttach are a pair, exactly like ephemeral hosting: the region is disposable because every client re-asserts its own entry on resume. This matters most on a durable realm: transient regions are stripped from every snapshot, so a same-epoch DO hibernation wake reloads them empty while your socket stays attached — and a resume answers by patch replay, not a full hydrate. The library therefore re-runs assertOnAttach after every resume for a transient-bearing realm (reconnect and live probe/rehello alike, including the idle staleness probe), not only on a full hydrate — so your entry is re-created across wakes, and your cursor’s leaf writes never land on a vanished parent (which would reject write_target_missing).
Ordering guarantee: on a resume, the re-own reaches the server before any resent write or intent. So an intent whose server logic reads your transient entry — a fire that reads your aim — runs against the re-owned entry, never the momentarily-empty region. You don’t sequence this; the library rides the recovery hello with the re-own on an internal lane. (If it didn’t, a resent fire that outran the re-own would reject “you haven’t joined” for a perfectly legitimate action.)
Do not use CAS (touchedSince) under a transient region — it keeps no touch history, so the check would silently always pass (the engine throws if you try). durability: "reassert" is the opposite pairing and perfectly fine: a producer re-asserting its own transient region across reconnects is the intended shape.
What presence deliberately does not need
Section titled “What presence deliberately does not need”| Belt from the hand-rolled version | Replaced by |
|---|---|
| Roster reconcile poll (“a dropped push can’t leave the list wrong forever”) | Hydrate + versioned patches + the staleness probe — convergence is the library’s contract |
| Re-assert status after every reconnect | assertOnAttach + automatic pending-write resend |
| Authoritative lookup at read time (“don’t trust the cached broadcast”) | Reading confirmed state is the authoritative lookup, staleness-bounded |
| Last-socket scan before announcing a departure | onAvatarDetach last-drop semantics |
| Departure debounce timers (“don’t flap the roster on a blip”) | avatarDetachGraceMs — hibernation-safe, both hooks swallowed on a prompt return |
| Local store patch-ups when a related feature misses an event | The roster converges on its own; delete the belt |
migrate hooks / durable writes / intents | Presence is default-tier owner-writes over disposable state — none of them apply |
When an entry needs more than owner-writes — a matchmaking claim, a seat count — that specific mutation becomes an intent; the rest of the roster stays exactly as above.