> ## Documentation Index
> Fetch the complete documentation index at: https://chahine.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Presence

> Ephemeral presence with TTL. Who's online, cursor positions, typing indicators.

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

```ts theme={null}
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

```ts theme={null}
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

| Method | Description |
| - | - |
| `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

```ts theme={null}
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):

```ts theme={null}
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);
```

> [!NOTE]
>
> 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.