Skip to content

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-client

connect(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:

OptionTypeMeaning
tokenProviderTokenProviderReturns a fresh token and WebSocket endpoint. Required.
webSocketFactory(url: string) => WebSocketLikeDefaults to (url) => new globalThis.WebSocket(url). Inject ws in Node.
backoffPartial<BackoffConfig>Reconnect timing, see below.
random() => numberRandomness 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,
): Subscription
const 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:

OptionTypeMeaning
historyHistoryOptionsInitial replay.
onSubscribed(info: { recovered?: boolean }) => voidFired on every subscribed ack, the first one and after each reconnect.
presencePresenceOptionsEnter presence on the channel.
onSubscribeError(error: { code: string }) => voidFired 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 })
OptionTypeMeaning
infounknownOpaque member metadata, up to 10KB serialized.
onState(members: PresenceMember[]) => voidAn authoritative snapshot: the initial one and one after each resubscribe.
onJoin(member: PresenceMember) => voidA member was added or updated.
onLeave(identity: string) => voidA 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 good

onStateChange 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:

ConstantValueWhat it meansWhat the client does
CLOSE_UNAUTHORIZED4001The 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_DRAINING4100The service node is shutting down and is moving connections off.Reconnects immediately, with no backoff — another node is ready.
CLOSE_SLOW_CONSUMER4200The 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

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect