Some facts should be allowed to expire
Notes and votes both had to survive — that was the whole argument behind how they’re built. “Ada is reading this card right now” is the opposite kind of fact. Put it in the document, and it becomes permanent right along with everything else: ordered by the server, folded into the board’s saved state, and played back to whoever opens the board next Tuesday — long after it stopped being true.
watershed’s answer is a second system that runs alongside your channels, built for facts that shouldn’t stick around. Ripples are the raw form: a message broadcast to every other client connected to this document right now, with no ordering guarantee and nothing saved anywhere — on purpose. Presence is the roster built on that same ephemeral tier, and it’s the one you want here, because a raw ripple would leave a peer’s last marker stuck on screen even after they close the tab; presence knows how to clear itself when someone leaves.
Declare what a teammate broadcasts
A presence payload is a record you own, with an encoder and a decoder.
The tutorial’s is three fields: a colour, a short name, and the note this
person is looking at — None when they are looking at the board
as a whole.
pub type BoardPresence {
BoardPresence(color: String, name: String, focused_note: Option(String))
}
fn encode_presence(presence: BoardPresence) -> json.Json {
json.object([
#("color", json.string(presence.color)),
#("name", json.string(presence.name)),
#("focused_note", case presence.focused_note {
Some(id) -> json.string(id)
None -> json.null()
}),
])
}
fn presence_decoder() -> decode.Decoder(BoardPresence) {
use color <- decode.field("color", decode.string)
use name <- decode.field("name", decode.string)
use focused_note <- decode.optional_field(
"focused_note",
None,
decode.optional(decode.string),
)
decode.success(BoardPresence(color:, name:, focused_note:))
}
Note the optional_field on the way in. Someone running last
week’s version of the app might not send focused_note at
all, and the roster should shrug that off instead of dropping them
entirely.
One effect handles all of it
watershed_lustre.presence is a single effect that starts
everything running, hands back a Handle to keep in your
model, and dispatches roster events as they arrive. You don’t write any
of the plumbing yourself: no heartbeat (a periodic “I’m still here”
ping), no expiry timer, and no cleanup process to remove people who
disappeared without saying goodbye.
fn presence_effect(
model: Model,
document: Document(document_schema.BoardDocument),
) -> Effect(Msg) {
watershed_lustre.presence(
document: document,
config: presence.config(encode_presence, presence_decoder()),
initial: current_presence(model),
started: PresenceStarted,
on_event: PresenceEvent,
)
}
fn current_presence(model: Model) -> BoardPresence {
BoardPresence(
color: model.color,
name: presence.short_name(model.user_id),
focused_note: model.focus,
)
} Two implementations, one API. Where the server can track each connection directly, it does: a late joiner gets the whole roster at once, and closing a tab removes that entry immediately. Where it can’t, presence falls back to sending its own heartbeat over ripples and expiring anyone who’s gone quiet for too long. The runtime note covers which one you get and what changes when it’s the fallback.
Publishing a change is republishing the whole payload
Presence is state, not a stream of edits. Focusing a note updates the local model, then re-announces the current payload in full — so there is exactly one function that says what this client looks like to everyone else, and no way for the announced state and the real state to drift.
fn announce_focus(model: Model) -> Effect(Msg) {
case model.presence {
Some(handle) ->
watershed_lustre.update_presence(handle, current_presence(model))
None -> effect.none()
}
} FocusClicked(id) -> {
let focus = case model.focus {
Some(current) if current == id -> None
Some(_) | None -> Some(id)
}
let model = Model(..model, focus: focus)
#(model, announce_focus(model))
}
FocusCleared -> {
let model = Model(..model, focus: None)
#(model, announce_focus(model))
}
Clicking a note toggles that id in model.focus; the clear
branch drops it back to None. Both republish through
announce_focus, which is why the UI can flip between
Focus and Focused without a second presence path.
The roster arrives as events, including the bad ones
State is the full roster; Changed carries what
changed plus the full roster anyway, and the tutorial re-renders from the
whole roster either way, instead of trying to patch it in place. Redrawing
the whole list is cheap, and it can never end up out of sync with reality.
PresenceEvent(event) ->
case event {
presence.State(entries) | presence.Changed(_, entries) -> #(
Model(..model, peers: remote_peers(model, entries)),
effect.none(),
)
presence.Failed(presence.DecodeFailed(_, _)) -> #(model, effect.none())
presence.Failed(presence.UnsupportedPresence) -> #(
Model(
..model,
last_error: Some("presence unavailable on this server"),
),
effect.none(),
)
presence.Failed(presence.Rejected(_, message)) -> #(
Model(..model, last_error: Some("presence rejected: " <> message)),
effect.none(),
)
} The interesting part is what happens when something goes wrong. If one teammate’s presence data doesn’t decode, that single entry is ignored — a person on a broken build shouldn’t wipe out everyone else’s roster. If the server doesn’t support presence at all, or rejects your attempt to join, you get a message on screen instead of a crash. Either way, the board itself keeps working: notes and votes don’t depend on presence, so losing presence never takes them down.
fn remote_peers(
model: Model,
entries: List(presence.PresenceEntry(BoardPresence)),
) -> List(presence.PresenceEntry(BoardPresence)) {
case model.presence {
Some(handle) ->
case presence_js.local_session(handle) {
Some(session) -> presence.remote_entries(entries, session)
None -> entries
}
None -> entries
}
} Your own entry is in that roster too. Filtering it out by session means the chip row can render “you” once, deliberately, instead of showing you twice and calling it a bug report.
Field note The minimal presence idiom ↓
Deepening: one highlight, built from two systems
The highlight you see on a card is where the two systems meet. The note’s id comes from the permanent channel — the document. Who’s looking at it comes from the temporary one — presence. The card combines both, freshly, every time it’s drawn.
fn focus_names(model: Model, id: String) -> List(String) {
let local = case model.focus {
Some(current) if current == id -> [
presence.short_name(model.user_id) <> " (you)",
]
Some(_) | None -> []
}
let peers =
model.peers
|> list.filter_map(fn(peer) {
case peer.meta.focused_note {
Some(current) if current == id -> Ok(peer.meta.name)
Some(_) | None -> Error(Nil)
}
})
list.append(local, peers)
}
Same idea, now with the actual function: focus_names combines
your own local focus with anyone else’s whose focused note matches this
card, at render time. If you take one habit from this sheet, take that
one — let the temporary system point at permanent state, never copy it.
Fast-changing signals — cursor position, a typing indicator — stay as raw ripples instead. Presence is for who’s here and the slower-changing status attached to that, not for anything that updates many times a second.
Field note Ride an application protocol on ripples ↓
Click Focus in one tab and watch the card light up in the other, with the roster naming who is on it — then close the tab and watch the marker leave on its own.
How the examples do it
Each note connects an implementation practice to a checked-in example. Open it for the code and reasoning.
Keep latency-critical loops out of the update path
Let a real-time loop read a plain snapshot. Do not make it wait on the application.
The audio engine runs in an FFI module. Every 25 ms, it schedules the steps due in the next 100 ms against the audio clock. Gleam pushes pattern updates into a plain array, so document delays cannot become audio jitter.
Background tabs create another trap: browsers slow timers while the audio clock keeps moving. When the tab returns, the scheduler resets its timing instead of playing every missed step at once.
src/drum_machine_lustre/audio_ffi.mjsfunction tick(engine) {
const ctx = engine.ctx;
if (ctx === null || !engine.playing) return;
// Browsers throttle `setInterval` to about once a second in a background
// tab, while the audio clock keeps running. Without this the next tick would
// "catch up" by scheduling a second of steps whose times have already
// passed, and Web Audio plays a past-dated start immediately — so returning
// to the tab is greeted by a burst of every step it missed. Resync instead,
// keeping the step index continuous so the pattern resumes in place.
if (engine.nextStepTime < ctx.currentTime) {
engine.nextStepTime = ctx.currentTime;
engine.queue = [];
}
const horizon = ctx.currentTime + SCHEDULE_AHEAD_S;
while (engine.nextStepTime < horizon) {
scheduleStep(engine, engine.nextStep, engine.nextStepTime);
engine.queue.push({ step: engine.nextStep, time: engine.nextStepTime });
// Read the duration per step, so a tempo change mid-bar takes effect on
// the next step rather than at the end of the loop.
engine.nextStepTime += stepDuration(engine);
engine.nextStep = (engine.nextStep + 1) % STEP_COUNT;
}
} Demonstrated by Drum machine Source ↗
The minimal presence idiom
Declare one presence effect, use one typed payload, and remove the local session before the roster enters your model.
The tutorial retro board shows the smallest complete presence setup. One effect starts the driver with an encoder and decoder. A helper removes the local session from the roster before the app stores it.
watershed includes the local session on purpose; each app decides whether to show it. The richer cursors and avatar lists in other examples use the same setup with more data.
src/retro_tutorial_lustre.gleamfn presence_effect(
model: Model,
document: Document(document_schema.BoardDocument),
) -> Effect(Msg) {
watershed_lustre.presence(
document: document,
config: presence.config(encode_presence, presence_decoder()),
initial: current_presence(model),
started: PresenceStarted,
on_event: PresenceEvent,
)
}
fn remote_peers(
model: Model,
entries: List(presence.PresenceEntry(BoardPresence)),
) -> List(presence.PresenceEntry(BoardPresence)) {
case model.presence {
Some(handle) ->
case presence_js.local_session(handle) {
Some(session) -> presence.remote_entries(entries, session)
None -> entries
}
None -> entries
}
} Demonstrated by Retro board (tutorial) Source ↗
Ride an application protocol on ripples
Send short-lived coordination over ripples, not through a document channel.
The triptych's guided scenarios ask two tabs to choose a driver and exchange invitations, acknowledgements, and status updates. Ripples deliver those messages with a run id, and the document stores none of them.
Plain functions match runs, ignore the sender's own messages, choose acknowledgements, and reject unknown message types. That keeps the protocol testable without a server.
src/grocery_triptych_lustre/scenario_protocol.gleampub fn matches_run(expected_run_id: String, inbound: Inbound) -> Bool {
run_id(inbound.message) == expected_run_id
}
pub fn from_self(self_id: String, inbound: Inbound) -> Bool {
inbound.from_peer == self_id
}
pub fn should_acknowledge(
self_id: String,
ready: Bool,
busy: Bool,
already_seen: Bool,
inbound: Inbound,
) -> Bool {
case inbound.message {
Invitation(_) ->
ready && !busy && !already_seen && !from_self(self_id, inbound)
Acknowledgement(_) -> False
Go(_, _) -> False
Status(_, _, _) -> False
}
} Demonstrated by Grocery triptych Source ↗