WebSocket Client

A per-runtime WebSocket client for dialing upstream servers with one consistent signature.

Every runtime exposes a slightly different WebSocket constructor for dialing a server: Bun and Deno take their dialing options as the constructor's second argument, the WHATWG/ws clients take a third, and only some accept the ws+unix: scheme or custom upgrade headers. crossws smooths this over with the crossws/websocket subpath — a thin per-runtime client that dials ws:, wss:, and ws+unix: upstreams through one uniform (url, protocols, options) signature.

This is the same client the proxy uses under the hood. Reach for it directly when you need to dial an upstream WebSocket yourself — for example from a custom hook — and want custom headers or Unix-socket support to work the same way on every runtime.

crossws/websocket is a client (it dials out). To accept incoming connections, use an adapter or crossws/server.

Usage

Import the default export and construct it like a standard WebSocket:

import WebSocket from "crossws/websocket";

const ws = new WebSocket("wss://echo.websocket.org");
ws.onopen = () => ws.send("hello");
ws.onmessage = (event) => console.log(event.data);

The returned instance is a standard WebSocket — the same events (open, message, close, error), methods (send, close), and static members (WebSocket.OPEN, …) you already know.

Signature

new WebSocket(url, protocols?, options?);
  • urlstring | URL. The upstream to dial: ws:, wss:, or a ws+unix://<socketPath>:<pathname> target.
  • protocolsstring | string[] (optional). Subprotocol(s) offered during the handshake, exactly as the WHATWG constructor.
  • optionsRecord<string, unknown> (optional). Extra dialing options — most commonly custom upgrade headers. crossws relays this into whichever argument position the current runtime expects.

The third options argument is the value the WHATWG browser constructor can't express. crossws makes it behave the same everywhere:

import WebSocket from "crossws/websocket";

const ws = new WebSocket("wss://backend.example.com", undefined, {
  headers: { authorization: "Bearer …" },
});
When you call it with just (url, protocols) and no options, crossws keeps the runtime's native positional path — so option-less calls are identical to using the global WebSocket directly.

Per-runtime behavior

crossws resolves a runtime-specific implementation through export conditions, so a bundle built for one runtime tree-shakes away the others (e.g. ws and node:* never enter a Deno or browser bundle):

RuntimeImplementation
BunGlobal WebSocket — dials ws:/wss:/ws+unix: natively. crossws relays options into Bun's second-argument form.
Node.jsGlobal WebSocket (undici) for the common ws:/wss: path; routes through ws (bundled, no extra dependency) whenever options are passed or the target is ws+unix: — and everywhere on Node < 22, which has no global.
DenoGlobal WebSocket; relays options into Deno's second-argument form, and rewrites ws+unix: targets through Deno.createHttpClient's unix client.
Browser / Workers / edgeThe WHATWG global WebSocket verbatim. It has no third-argument options, so custom headers are silently ignored.
Custom headers (and other options) are honored on Bun, Deno, and Node — not in browsers, Cloudflare Workers, or other edge runtimes, whose WHATWG constructor drops them. Don't rely on upstream identity via headers there.

Unix domain sockets

Dial an upstream listening on a Unix domain socket by passing a ws+unix://<socketPath>:<pathname> URL. This works out of the box on Node.js, Bun, and Deno:

import WebSocket from "crossws/websocket";

const ws = new WebSocket("ws+unix:///var/run/backend.sock:/chat");

The path before the : is the socket file (/var/run/backend.sock); the path after it is the request path forwarded to the upstream (/chat).

On Deno, the unix transport uses Deno.createHttpClient, an unstable API — run Deno with the --unstable-net flag to enable it.

Relationship to the proxy

createWebSocketProxy() dials every upstream through this client, which is why its headers, webSocketOptions, and ws+unix: targets work uniformly across runtimes without a custom constructor. Passing an explicit WebSocket option to the proxy opts out of this per-runtime handling and dials with your constructor verbatim.

See the WebSocket Proxy guide for forwarding incoming connections to an upstream.