Skip to content

Presence Realms

The classic first realm: a lobby or roster showing who is here and what they are doing, built from assertOnAttach, last-detach pruning and ephemeral hosting.

Presence means showing who is here and what they are doing: a lobby roster, cursors on a canvas, a “who is watching” list. It is the classic first realm because it is pure shared state. Everyone sees everyone, and each avatar owns exactly its own entry. It is also where hand-rolled sync code grows the most belts: reconcile polls, re-assert-on-reconnect, last-socket scans, stale-entry sweeps. A presence realm needs none of them. This page shows the whole pattern.

The state is one record, keyed by the avatar’s persistent id. Each owner writes their own entry, and 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 assertOnAttach declares exactly that:

client.ts
const realm = connectRealm(lobbyRealm, {
avatar: { type: "player" }, // persistentId from the authenticated coordinate
connection: realmConnection(connector),
assertOnAttach: (draft) => { draft.players[realm.avatar.persistentId] = initialEntry(); },
});
const me = realm.avatar.persistentId;
// 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: the first attach, an out-of-log-window resume, or an epoch change after a server wipe. Those are exactly the moments your entry could have been lost. It does not re-run on an ordinary patch-replay reconnect, because your entry is still confirmed there, and within pendingExpiryMs any in-flight writes resend on their own. The re-join, re-announce and restart-recovery code a hand-rolled lobby carries is all this one option.

For pointer-frequency fields (cursor, walk target), set coalesceMs on the connect options instead of throttling by hand. If the same connection also carries event-shaped writes (a click-to-act, a keyed transition), use coalesce: { ms, leading: true } so those isolated writes send immediately while the stream still merges. 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 ties 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. Writes are 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 is the library’s job: it batches frames per connection, and the broadcastCoalesceMs engine option merges commits into one broadcast per window. coalesceMs is the half you own.)

onAvatarDetach fires once per avatar identity, when its last connection drops. The engine counts multiple tabs and devices for you, so the whole prune handler is this:

LobbyDO.ts
const realms = createRealmHost(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]; });
},
},
}),
},
});

There is no socket scanning, no “is this their last tab” bookkeeping and no departure race. 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 inherits 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. A consumer setTimeout cannot give that guarantee. The same applies on wake: 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, because 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, so persisting its patch log buys nothing. Ephemeral hosting skips SQLite entirely. Every cold start boots the empty roster on a fresh epoch, surviving clients do a 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. Schema changes stop mattering as well, because 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. Picture 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 that is 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, so peers see the cursor, and still obeys its view/alter rules. It is not 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. In 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 regions 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. So for a realm with transient regions, the library re-runs assertOnAttach after every resume, not only on a full hydrate. That covers reconnect and live probe/rehello alike, including the idle staleness probe. 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, such as a fire that reads your aim, runs against the re-owned entry and never the momentarily-empty region. You don’t sequence this. The library sends the re-own with the recovery hello, 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, such as a matchmaking claim or a seat count, that specific mutation becomes an intent. The rest of the roster stays exactly as above.