Skip to main content
Presence tracks ephemeral per-client state with a TTL. Entries expire automatically when a client stops sending heartbeats. Ideal for “who’s online”, cursor positions, and typing indicators.

Usage without schema

const room = client.presence("pr:room-123");

// Announce presence (data: any, ttl in ms)
room.heartbeat({ status: "online" }, 30_000);

// Read current entries
console.log(room.value()); // Map<clientId, { data: unknown; expiresAtMs: number }>

// Subscribe
room.onChange(entries => {
  for (const [clientId, entry] of entries) {
    console.log(clientId, entry.data);
  }
});

// Leave
room.leave();

Usage with schema

import { Schema } from "effect";

const Cursor = Schema.Struct({
  x: Schema.Number,
  y: Schema.Number,
});

const cursors = client.presence("pr:cursors", Cursor);

cursors.heartbeat({ x: 120, y: 340 }, 5_000);

cursors.onChange(entries => {
  for (const [clientId, entry] of entries) {
    // entry.data: { x: number; y: number }
    renderCursor(clientId, entry.data.x, entry.data.y);
  }
});

API

MethodDescription
heartbeat(data: T, ttlMs: number)Send presence data, expires after ttlMs
leave()Remove own entry immediately
value()Returns Map<number, PresenceEntry<T>> — only live entries
onChange(fn)Subscribe — returns unsubscribe function

PresenceEntry

interface PresenceEntry<T> {
  data: T;
  expiresAtMs: number; // absolute timestamp
}

TTL behavior

Entries expire when now > expiresAtMs. Call heartbeat() periodically to keep the entry alive. value() always filters out expired entries before returning.

Server restart behavior

Presence is ephemeral by design — entries are stored in sled but the TTL clock is wall-time based. When the server restarts:
  • All presence entries that were already expired remain expired.
  • Entries that were still live will appear missing for connected clients until clients re-heartbeat.
  • Clients receive a fresh empty state on reconnect, then repopulate as they send heartbeats.
This is expected behavior. To handle it gracefully, send a heartbeat() immediately after the WebSocket connects (or reconnects):
const room = client.presence("pr:room-123");

client.waitForConnected().then(() => {
  room.heartbeat({ status: "online" }, 30_000);
});

// And refresh periodically
setInterval(() => room.heartbeat({ status: "online" }, 30_000), 10_000);
There is no server-side “grace period” after restart. If your use case requires durable presence (e.g., last-seen timestamps), use a LwwRegister instead — it persists across restarts.