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.
Settings
Section titled “Settings”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.
Sending and receiving
Section titled “Sending and receiving”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.
Sending from another actor
Section titled “Sending from another actor”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:
| Hook | When |
|---|---|
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.
Codecs
Section titled “Codecs”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.
Reconnect and buffering
Section titled “Reconnect and buffering”An unexpected disconnect is handled by the BrokerActor lifecycle:
- The actor transitions to
disconnectedand firesBrokerDisconnectedon the event stream. - The reconnect cycle starts, with exponential backoff per the
reconnectsettings (initialDelayMs,maxDelayMs,factor,maxAttempts) — jittered byrandomFactor(default ±20 %) so a fleet does not reconnect in lockstep. - After a successful re-connect,
onConnected()runs andBrokerConnectedfires.
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.
Ping / keep-alive
Section titled “Ping / keep-alive”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 attemptOn 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.
HOCON config
Section titled “HOCON config”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.
When to use it
Section titled “When to use it”Three primary fits:
- Subscribing to real-time data feeds — stock tickers, crypto exchanges, social-media streams.
- Outbound WebSocket clients to third-party services (Slack RTM, streaming LLM APIs, IoT vendor APIs).
- 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.
Where to next
Section titled “Where to next”- 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.
