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 live entries
console.log(room.online()); // PresenceEntry<unknown>[]

// Subscribe
room.onChange(entries => {
  for (const entry of entries) {
    console.log(entry.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 entry of entries) {
    // entry.data: { x: number; y: number }
    renderCursor(entry.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
online()Returns PresenceEntry<T>[] — only live (non-expired) entries
onChange(fn)Subscribe — returns unsubscribe function
stream()Returns an Effect Stream<PresenceEntry<T>[]> that emits on every change

PresenceEntry

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

TTL behavior

Entries expire when now > expiresAtMs. Call heartbeat() periodically to keep the entry alive. online() always filters out expired entries before returning. The server runs a background GC task every few seconds that removes expired entries and broadcasts tombstone deltas to all connected clients — so peers see removals in near-real-time without waiting for the next heartbeat.

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.