Not everything belongs in the document
Some signals are about now, not about state: which station a surveyor is looking at, a cursor position, a "re-check this" nudge, a reaction. Put those in a channel and they get sequenced, summarised, and replayed to whoever joins an hour later, long after they stopped meaning anything. A converged document is the wrong home for a signal whose whole value is that it is fleeting.
watershed keeps that traffic in a separate tier that rides the same connection but obeys none of the convergence machinery. It is the deliberate escape hatch from "everything is durable, ordered state."
Ripples: broadcast and forget
A ripple is a document-scoped, non-sequenced broadcast
delivered to every other connected client and then gone. No
ordering, no storage, no convergence, deliberately.
submit_ripple sends a type tag plus a JSON payload and returns
nothing, because there is no state to change and no ack to await;
subscribe_ripples hands each inbound ripple to your handler
with the sender's id attached. The build guide's presence step builds on this
tier from the other end, and makes the case for why a raw ripple is the
wrong tool for a roster.
Because a ripple never lands in a summary, a client that reconnects or joins late will not see the ones it missed, which is exactly right for a cursor and exactly wrong for a reading. The tier's forgetfulness is the feature.
Presence: who is here
Raw ripples are stateless, and that leaves one gap: a peer who simply goes quiet (closes the tab, loses signal) leaves their last marker stranded on everyone's screen, because "gone" was never an event. Presence closes it, and offers one API over two ways of doing so.
import watershed/presence
// One codec, one mode. `Auto` takes server presence where the server
// offers it and falls back to the ripple heartbeat where it does not.
let config =
presence.config(encode_cursor, cursor_decoder())
|> presence.with_mode(presence.Auto)
A session is one tab, device, or process; a
key groups the sessions of one authenticated person. Two
tabs from the same user are two entries sharing a key: the roster is keyed
by session, so they never overwrite each other. Your handler receives a
State that replaces the roster wholesale, or a
Changed carrying both the delta and the resulting roster; both
hand you a complete list, so render from whichever suits.
Two implementations, deliberately different
Server presence is connection-backed. The server tracks each connection, so a client that joins late is handed the entire roster in one snapshot, and a socket that drops takes its entry with it. No browser involved, no waiting. There is no heartbeat at all: the connection is the liveness signal.
Ripple presence is soft presence, for servers that do not offer the lane. Each client re-announces its metadata on a timer and expires peers it stops hearing from, by default beating every two seconds and expiring after six and a half, roughly three missed beats. It is honest but approximate: a late joiner waits for each peer's next beat, and a background tab whose timers get throttled can briefly look gone.
Auto chooses between them from what the server advertises in
its handshake. Ripple forces the heartbeat.
Server refuses to fall back and reports
UnsupportedPresence instead: a silent downgrade would make
presence look intermittently broken with nothing to point at.
presence_js.mode reports which one is running, because the two
have genuinely different failure timing and hiding that makes it
undebuggable.
Send the right traffic down each tier. Use presence for membership and low-frequency status: which panel, which cell, typing or not. High-frequency cursor motion still belongs on raw ripples: presence exists to answer "who is here", not to be a pipe.
Ripples are throwaway broadcasts that never touch converged state; presence turns them into a roster of who is here, backed by the server's own connection tracking where that exists, and by a heartbeat and TTL where it does not, behind one API that reports which it chose.