Skip to content

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.

One record, keyed by the avatar’s persistent id; the owner writes their entry, the server owns membership:

shared/lobbyRealm.ts
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"),
),
],
});

There is no “join” action. Your entry is state you must own whenever you are attached, and that is exactly what assertOnAttach declares:

client.ts
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.)

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:

LobbyDO.ts
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:

shared/boardRealm.ts
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?”
TierDeclare it withPersisted?Rolled back on link loss?Use for
Durable (default)nothingyes (log + snapshots)noNotes, scores, anything with history
Transient regiontransient: ["presence"]nonoCursors, “who’s looking”, live position — hot, throwaway, on a realm that also has durable state
Ephemeral realmpersistence: "ephemeral"no (whole realm)noA pure-presence realm with nothing worth persisting
Durable client writerealm.update(fn, { durability: "reassert" })(client-side)no — resent until confirmedA client that is the sole producer of a region (an input log) — see durable writes

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.

Belt from the hand-rolled versionReplaced 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 reconnectassertOnAttach + 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 departureonAvatarDetach 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 eventThe roster converges on its own; delete the belt
migrate hooks / durable writes / intentsPresence 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.