Skip to content
English

WebsocketClientActor

WebsocketClientActor opens a client-side WebSocket connection to a remote endpoint. Inbound frames are decoded into typed messages you handle in onMessage; outbound messages are encoded and sent (buffered while disconnected, resent after reconnect). For accepting incoming WS connections (server side), see server WebSocket.

import { match } from 'ts-pattern';
import { WebsocketClientActor, WebsocketClientOptions, websocketSend } from 'actor-ts/http';
type ClientMessage = { kind: 'ping'; n: number };
type ServerMessage = { kind: 'pong'; n: number } | { kind: 'event'; data: unknown };
class Feed extends WebsocketClientActor<ClientMessage, ServerMessage> {
constructor() {
const webSocketClientOptions = WebsocketClientOptions.create<ClientMessage, ServerMessage>().withUrl('ws://localhost:8080/ws');
super(webSocketClientOptions);
}
override onConnected(): void {
this.send({ kind: 'ping', n: 1 });
}
onMessage(message: ServerMessage): void {
// decoded server message
match(message)
.with({ kind: 'pong' }, (m) => this.onPong(m))
.exhaustive();
}
private onPong(message: PongMessage): void {
this.log.info(`pong ${message.n}`);
}
}
const feed = system.spawn(Feed, 'feed');

The two type parameters read from the client’s point of view: TOut = the messages this client sends, TIn = the decoded server messages it receives.

WebsocketClientActor extends the shared BrokerActor base, so it inherits reconnect-with-backoff, the outbound buffer across reconnects, and the circuit breaker for free.

The constructor takes Partial<WebsocketClientOptionsType<TOut, TIn>>. Only url is required; everything else has a default.

interface WebsocketClientOptionsType<TOut, TIn> extends BrokerCommonOptionsType {
url: string; // required — ws:// or wss://
protocols?: string | string[]; // subprotocols
codec?: WebsocketCodec<TOut, TIn>; // default jsonCodec()
maxFrameBytes?: number; // default 1 MiB; oversize inbound closes with 1009
onInvalidMessage?: 'drop' | 'hook' | 'disconnect'; // default 'drop'
pingIntervalMs?: number; // application-level ping; default off
idleTimeoutMs?: number; // read-idle deadline; default 0 (off)
connectTimeoutMs?: number; // connect deadline; default 0 (off)
}

Because the settings extend BrokerCommonOptionsType, the reconnect ({ initialDelayMs, maxDelayMs, factor, maxAttempts, randomFactor }), outboundBuffer, and circuitBreaker blocks all apply — see the BrokerActor base for their semantics.

Inside the actor, this.send(message) encodes the message via the codec and enqueues it; while disconnected the message is buffered and resent after reconnect. Inbound frames are decoded and delivered to onMessage.

class Chat extends WebsocketClientActor<ClientMessage, ServerMessage> {
constructor() {
const webSocketClientOptions = WebsocketClientOptions.create<ClientMessage, ServerMessage>().withUrl('wss://chat.example.com/ws');
super(webSocketClientOptions);
}
override onConnected(): void {
this.send({ kind: 'setName', name: 'alice' }); // encode + enqueue → true
}
onMessage(message: ServerMessage): void {
// one call per decoded server frame, in frame order
}
}

send(message: TOut): boolean returns whether the message was accepted (it can be rejected if the outbound buffer is full). For a raw frame — bypassing the codec — use sendRaw(frame: WebsocketFrame): boolean.

Other actors don’t call send directly; they push a typed send through the ref with the websocketSend helper:

import { websocketSend } from 'actor-ts/http';
feed.tell(websocketSend({ kind: 'ping', n: 42 }));

onMessage(message: TIn) is abstract — you must implement it. The rest are optional overrides:

HookWhen
onConnected()The connection is (re)established. Good place for a handshake / re-subscribe.
onDisconnected(cause?: Error)The connection dropped; cause is set on error-driven drops.
onInvalidMessage(error: WebsocketDecodeError)A frame failed to decode (only when onInvalidMessage: 'hook').
onSelfMessage(message: TSelf)Handle the optional third type parameter — messages the actor sends to itself.
onTerminated(signal: Terminated)An actor you context.watched stopped. Inherited from BrokerActor.

onReceive and onCommand are sealed — the base classes dispatch to the hooks above; do not override them. A Terminated from a watch of your own arrives at onTerminated, not at onSelfMessage.

The codec turns TOut messages into wire frames and wire frames back into TIn messages. The default is jsonCodec():

import { jsonCodec } from 'actor-ts/http';
new Feed(); // uses jsonCodec() — text frames <-> JSON
// With runtime validation (zod etc.):
class Validated extends WebsocketClientActor<ClientMessage, ServerMessage> {
constructor() {
const webSocketClientOptions = WebsocketClientOptions.create<ClientMessage, ServerMessage>()
.withUrl('wss://...')
.withCodec(jsonCodec<ClientMessage, ServerMessage>({
validate: (value: unknown): ServerMessage => ServerMessageSchema.parse(value),
}));
super(
webSocketClientOptions,
);
}
onMessage(message: ServerMessage): void { /* already validated */ }
}

For binary protocols, rawCodec() is the escape hatch — TOut = TIn = WebsocketFrame, so you handle raw text/binary frames yourself:

import { match } from 'ts-pattern';
import { rawCodec, type WebsocketFrame } from 'actor-ts/http';
class Binary extends WebsocketClientActor<WebsocketFrame, WebsocketFrame> {
constructor() {
const webSocketClientOptions = WebsocketClientOptions.create<WebsocketFrame, WebsocketFrame>()
.withUrl('wss://...')
.withCodec(rawCodec());
super(webSocketClientOptions);
}
onMessage(frame: WebsocketFrame): void {
match(frame)
.with({ kind: 'binary' }, (f) => this.onBinary(f))
.otherwise(() => {});
}
private onBinary(frame: BinaryFrame): void {
this.handleBytes(frame.data); // Uint8Array
}
}

A WebsocketFrame is { kind: 'text'; data: string } or { kind: 'binary'; data: Uint8Array }. Decode failures throw a WebsocketDecodeError; the inbound frame-size cap (maxFrameBytes) is enforced on the raw frame before decode.

data is a plain Uint8Array on every runtime. The client sets the socket’s binaryType to 'arraybuffer' when it dials, because the runtimes disagree about the default — Bun hands over a Node Buffer, Node and Deno hand over a Blob — and a Blob yields its bytes only asynchronously, which the inbound path cannot afford: awaiting it would move the maxFrameBytes check behind a microtask and stop guaranteeing that frames reach onMessage in arrival order. Two consequences worth knowing. The bytes are always the frame’s own, never a window into a pooled buffer, so frame.data.buffer is safe to hand on. And the value is a Uint8Array and not a Buffer even on Bun, so reach for Buffer.from(frame.data) if you want toString('hex') and friends.

An oversize frame closes the connection with code 1009 (“Message Too Big”) and starts the usual reconnect, rather than being dropped in place. Be clear about what that does and does not buy you: it does not stop the allocation. Unlike the server backends, the client has no transport cap to push the limit down to — no supported runtime’s native WebSocket honours a payload limit passed to its constructor, so by the time maxFrameBytes is checked the runtime has already reassembled the frame on the heap. What closing buys is that the peer cannot spend that heap again on the same socket: without it, one connection could be made to repeat the allocation for every frame it felt like sending. Another round now costs the peer a full reconnect, which the reconnect backoff and the circuit breaker already throttle. See Capping untrusted input.

The close is accompanied by one warning line. It names the connection by a redacted label (wss://feed.example.com/ws/orders) rather than by url: the userinfo and the query string are stripped, since a WebSocket endpoint is commonly authenticated with a ?token=…, and the path is kept because it is what tells two connections to the same host apart. The same masking is available to your own code as redactedUrlLabel.

An unexpected disconnect is handled by the BrokerActor lifecycle:

  • The actor transitions to disconnected and fires BrokerDisconnected on the event stream.
  • The reconnect cycle starts, with exponential backoff per the reconnect settings (initialDelayMs, maxDelayMs, factor, maxAttempts) — jittered by randomFactor (default ±20 %) so a fleet does not reconnect in lockstep.
  • After a successful re-connect, onConnected() runs and BrokerConnected fires.

Outbound messages sent while disconnected are buffered per the outbound-buffer policy and resent after reconnect. The inherited circuit breaker trips after repeated connection failures so a dead endpoint doesn’t spin the reconnect loop forever.

class KeepAlive extends WebsocketClientActor<ClientMessage, ServerMessage> {
constructor() {
const webSocketClientOptions = WebsocketClientOptions.create<ClientMessage, ServerMessage>()
.withUrl('wss://...')
.withPingIntervalMs(30_000);
super(webSocketClientOptions);
}
onMessage(message: ServerMessage): void {}
}

An application-level ping every pingIntervalMs keeps intermediate proxies / load balancers from closing idle connections — most production setups want it. The protocol-level WebSocket ping (control frame) is separate and handled by the runtime; the application-level ping is for proxies that inspect payload, not frames.

Detecting a connection that was silently dropped

Section titled “Detecting a connection that was silently dropped”

A ping keeps a proxy from closing the connection. It does not tell you when one was dropped anyway — an expired NAT entry, a load balancer that forgot the flow, a peer killed with SIGKILL. Neither side is notified, so close and error never fire, onDisconnected never runs, and the actor reports connected while every send goes nowhere.

idleTimeoutMs is the deadline that ends that state:

const webSocketClientOptions = WebsocketClientOptions.create<ClientMessage, ServerMessage>()
.withUrl('wss://realtime.example.com/feed')
.withIdleTimeoutMs(90_000) // no inbound frame for 90 s → reconnect
.withConnectTimeoutMs(10_000); // handshake stalled for 10 s → fail the attempt

On expiry the socket is closed, onDisconnected runs with an idle timeout cause, and the ordinary reconnect cycle starts — the same path an observed close takes.

It counts application messages, not pongs. A protocol-level pong is not delivered as a message event on any supported runtime, so pingIntervalMs does not refresh this deadline. Size idleTimeoutMs against the server’s own heartbeat interval, not against your ping interval; below that, the deadline severs healthy connections in a loop. Off by default for exactly that reason.

connectTimeoutMs covers the other end: the handshake settles only on open or error, so a server that accepts the TCP connection and never completes the upgrade holds the actor in connecting indefinitely.

Client defaults can be set under actor-ts.io.broker.websocket:

actor-ts.io.broker.websocket {
url = "wss://realtime.example.com/feed"
protocols = ["my-proto-v1"]
pingIntervalMs = 30000
idleTimeoutMs = 90000
connectTimeoutMs = 10000
maxFrameBytes = 1048576
reconnect { initialDelayMs = 500, maxDelayMs = 30000, factor = 2.0, randomFactor = 0.2 } // maxAttempts omitted = unlimited (the default)
circuitBreaker { /* ... */ }
outboundBuffer { /* ... */ }
}

Precedence follows the broker convention: constructor argument > HOCON > built-in defaults.

Three primary fits:

  1. Subscribing to real-time data feeds — stock tickers, crypto exchanges, social-media streams.
  2. Outbound WebSocket clients to third-party services (Slack RTM, streaming LLM APIs, IoT vendor APIs).
  3. Custom WS-based protocols layered on top — use rawCodec() for binary wire formats.

For server-side WebSocket (accepting client connections), use the server WebSocket page.

  • I/O overview — the bigger picture.
  • Server WebSocket — the websocket() directive + WebsocketServerActor.
  • SSE — server-sent events as a one-way streaming alternative.
  • BrokerActor base — the shared reconnect / buffer / circuit-breaker lifecycle.