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 definition
Section titled “The definition”The state is one record, keyed by the avatar’s persistent id. Each owner writes their own entry, and 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 assertOnAttach declares exactly that:
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.)
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. The engine counts multiple tabs and devices for you, so the whole prune handler is this:
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:
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?”| 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 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.
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, such as a matchmaking claim or a seat count, that specific mutation becomes an intent. The rest of the roster stays exactly as above.