Skip to content

Realtime protocol

The WebSocket is at /ws. It carries two kinds of traffic that behave very differently, and the distinction is the whole design.

Every change that matters is appended to a per-workspace event log inside the same transaction that made the change, and each gets a monotonic sequence number. Sending a message and recording that a message was sent either both happen or neither does.

The socket is a notification channel, not a data channel. Events come from the database, which is why a resume works across a server restart and not just a dropped connection.

Typing indicators, presence, call signaling, and note edits are sent directly between sockets and never logged. They have no sequence number, and losing one costs nothing.

The first frame is hello, carrying a token and the last sequence number this client saw:

{ "type": "hello", "token": "slk_...", "workspaceId": "01J...", "lastSeq": 4821 }

The server replies with everything after lastSeq, in order, then keeps streaming. A brand new client sends lastSeq: 0 after fetching /api/workspaces/:id/bootstrap.

If a client is too far behind the retention window, the server answers full_sync_required instead. The client then re-bootstraps. This is the always-correct fallback: rather than guessing at a gap, the protocol has one honest way to say “start over.”

{ "type": "event", "seq": 4822, "event": { "type": "message.created", "message": { } } }

Event types include message.created, message.updated, message.deleted, reaction.added, reaction.removed, channel.created, channel.updated, channel.archived, member.joined, member.left, workspace.updated, workspace_member.added, workspace_member.updated, workspace_member.removed, user.updated, and read_state.updated.

Membership is enforced on fan-out, so a socket never receives an event for a channel it cannot see.

Events are pruned after roughly seven days. That window exists so that a laptop closed over a long weekend resumes cleanly, while the log does not grow without bound. Anything older resolves through full_sync_required.

Treat any gap in seq as a reason to resume rather than something to patch around, persist lastSeq across restarts, and reconnect with backoff and jitter. @slick/sdk does all of this; apps/web/src/sync/engine.ts is the reference implementation and is deliberately free of DOM references so it can be reused.