Realtime
Connect a browser tab
Install @lessly/realtime-client, subscribe to channels, and let it reconnect and recover on its own.
@lessly/realtime-client connects a browser tab to the realtime service over a WebSocket, subscribes it to channels, and keeps it connected. The current published version is 0.3.0.
It never sees your product API key. It authenticates with a short-lived capability token that it asks your own backend for.
Install and connect
npm install @lessly/realtime-clientconnect(options) constructs a client and starts connecting. It returns immediately — the connection is established in the background.
import { connect } from '@lessly/realtime-client'
const client = connect({
tokenProvider: async () => {
const res = await fetch('/api/realtime-token')
return res.json() // { token, gatewayUrl }
},
})RealtimeClientOptions:
| Option | Type | Meaning |
|---|---|---|
tokenProvider | TokenProvider | Returns a fresh token and WebSocket endpoint. Required. |
webSocketFactory | (url: string) => WebSocketLike | Defaults to (url) => new globalThis.WebSocket(url). Inject ws in Node. |
backoff | Partial<BackoffConfig> | Reconnect timing, see below. |
random | () => number | Randomness for backoff jitter. Defaults to Math.random. |
new RealtimeClient(options) is exported too, for when you want to construct the client without connecting yet and call connect() later. The class method connect(): Promise<void> resolves once the socket is open, rejects if the client is closed, and resolves immediately when already connected. The standalone connect() function swallows that first rejection — with it, connection problems surface through onError and onStateChange instead.
The token provider
type TokenProvider = () => Promise<{ token: string; gatewayUrl: string }>The provider is the only place a credential enters the client. Point it at an endpoint on your own backend that mints a capability token for the signed-in user with the server SDK, and returns the token together with the WebSocket endpoint the SDK gave you.
The client calls the provider before every connection attempt, the first one and each reconnect. There is no refresh timer and no cached token: a reconnect always carries a freshly minted token, so a token that expired while the tab was offline is not a problem. If the provider throws, the error is reported to onError and the attempt is rescheduled with backoff.
The token is appended to the WebSocket URL as a token query parameter, URL encoded, using & when the URL already has a query string.
Subscribe
subscribe(
channel: string,
onMessage: (m: RealtimeMessage) => void,
options?: SubscribeOptions,
): Subscriptionconst subscription = client.subscribe('chat:room-1', (message) => {
console.log(message.data)
}, {
history: { lastN: 50 },
onSubscribed: ({ recovered }) => {
if (recovered === false) reloadFromScratch()
},
})
subscription.unsubscribe()You may subscribe before the connection is open. Channels are remembered and sent as soon as the socket opens, and re-sent after every reconnect.
SubscribeOptions:
| Option | Type | Meaning |
|---|---|---|
history | HistoryOptions | Initial replay. |
onSubscribed | (info: { recovered?: boolean }) => void | Fired on every subscribed ack, the first one and after each reconnect. |
presence | PresenceOptions | Enter presence on the channel. |
onSubscribeError | (error: { code: string }) => void | Fired when the subscribe is rejected. |
HistoryOptions is one of { cursor: { offset, epoch } }, { lastN } or { lastMs }. A RealtimeMessage carries channel and data, and optionally id, offset, epoch, client and ts.
Several handlers can share one channel: subscribing again adds a handler rather than opening a second subscription, and the last options passed wins. unsubscribe() removes that handler, and the client forgets the channel once its last handler is gone.
There is no wire unsubscribe in this protocol version, so the removal is local. Messages for a forgotten channel are dropped on arrival, and the channel is not re-subscribed on the next reconnect.
A subscribe can be refused — forbidden, for instance, when the token lacks a capability the subscribe needs. There is no partial fallback: the whole subscribe is rejected. The refusal reaches onSubscribeError with the service’s code, and also onError.
Presence
Pass presence in the subscribe options to join the channel roster. The token must carry the presence capability for that channel.
client.subscribe('chat:room-1', onMessage, {
presence: {
info: { name: 'Ada' },
onState: (members) => render(members),
onJoin: (member) => add(member),
onLeave: (identity) => remove(identity),
},
})
client.presence('chat:room-1') // PresenceMember[]
await client.updatePresence('chat:room-1', { name: 'Ada', typing: true })| Option | Type | Meaning |
|---|---|---|
info | unknown | Opaque member metadata, up to 10KB serialized. |
onState | (members: PresenceMember[]) => void | An authoritative snapshot: the initial one and one after each resubscribe. |
onJoin | (member: PresenceMember) => void | A member was added or updated. |
onLeave | (identity: string) => void | A member left. |
A PresenceMember is { identity, connections, info? }. The client keeps the roster for you: presence(channel) returns the current members, empty until the first snapshot arrives. Rebuild your view from onState whenever it fires — after a reconnect it is the truth, and any deltas you accumulated are not. The full guarantees are on presence.
updatePresence(channel, info) replaces this connection’s info. It resolves on the service’s acknowledgement and rejects when the connection is not open, on disconnect, or with invalid — you are not in presence on that channel, or the info is too large.
Publish from the client
await client.publish('chat:room-1', { text: 'hello' })This works where the namespace enables client events and the token carries the publish capability for the channel. Client events are ephemeral: they are fanned out and never stored, so they carry no offset or epoch and can never be replayed. The publishing connection is excluded from delivery, which makes the acknowledgement its only confirmation — render your own message locally.
publish resolves on the published acknowledgement. It rejects when the connection is not open, on disconnect, and on forbidden (no publish capability), invalid (missing channel, or serialized data over 32KB) or rate_limited (the per-connection bucket).
Connection state
type ConnectionState =
| 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'suspended' | 'closed'const stop = client.onStateChange((state) => setBanner(state))
const stopErrors = client.onError((error) => console.warn(error.message))
client.state // current state
client.close() // stop for goodonStateChange and onError each return a function that removes the listener. The state is also readable at any time as client.state.
idle is the state before the first connect. suspended means the client has failed suspendAfterAttempts times in a row — it keeps trying, but it is a reasonable moment to tell the user that live updates are not arriving. closed is terminal: close() rejects everything in flight, stops reconnecting, and after it connect() rejects and subscribe() throws.
Reconnecting
When a connection drops the client reconnects on its own, with a fresh token, and re-subscribes every channel it still has handlers for.
interface BackoffConfig {
initialMs: number // 250
maxMs: number // 30_000
factor: number // 2
jitter: number // 0.5
suspendAfterAttempts: number // 8
}Those are the values in DEFAULT_BACKOFF, which is exported; anything you pass in backoff is merged over them. The delay before attempt n (1-based) is min(maxMs, initialMs * factor ** (n - 1)), reduced by a random fraction of up to jitter of that base. With the defaults, attempt 1 waits between 125 ms and 250 ms and the delay grows to a 15–30 s band. The jitter spreads a crowd of tabs that all lost the connection at the same moment.
The delay is skipped entirely after close codes 4100 and 4001. backoffDelay(attempt, cfg, random) is exported if you want to compute the same number yourself.
Recovery after a reconnect
The client tracks the offset and epoch of the last message it received on each channel. On reconnect it resubscribes from that cursor instead of the history option you originally passed, so a tab that was away gets what it missed rather than the same window again. Until a first message arrives on a channel there is no cursor, and the original history option is used.
Recovery is not always possible — the entries may have aged out, or the epoch may have changed. Each subscribed acknowledgement therefore carries recovered, delivered to onSubscribed. When it is false, the gap was not filled: reload the channel’s state from your own API rather than assuming the stream is continuous. See history.
Close codes
Three close codes are exported from the package:
| Constant | Value | What it means | What the client does |
|---|---|---|---|
CLOSE_UNAUTHORIZED | 4001 | The token was rejected or has expired. | Reconnects immediately. The next attempt fetches a fresh token from your provider, which is normally enough. A persistent 4001 means the provider is returning a token the service will not accept — check what your backend mints. |
CLOSE_DRAINING | 4100 | The service node is shutting down and is moving connections off. | Reconnects immediately, with no backoff — another node is ready. |
CLOSE_SLOW_CONSUMER | 4200 | The connection could not keep up with the message rate and was dropped. | Reconnects with the normal backoff. Reconnecting alone does not fix it: consume faster, or subscribe to less. |
Any other close code, including a network drop, reconnects with the normal backoff.
Protocol helpers
The wire protocol is exported for tooling that speaks to the realtime service directly: parseFrame(raw), which never throws and returns { type: 'unknown', raw } for anything it does not recognize, and the frame builders authFrame(token), subscribeFrame(channel, history?, presence?), publishFrame(channel, data) and presenceUpdateFrame(channel, info). The ServerFrame union describes everything the service sends. Frame types this version does not know are ignored, so a newer service does not break an older client.
auth(token?) sends an auth frame on the open connection, using the token provider when no token is given. Normal use does not need it: the connection is already authenticated by the token in its URL.
Next steps
- Show who is on a channel: the roster guarantees behind the presence callbacks.
- Replay what a client missed: what
recovered: falsemeans and what to do about it. - Call Realtime from your backend: the endpoint your token provider calls.
- Look up a limit or an error: close codes, error frames and the client-event budget.