Getting started
Requirements
Section titled “Requirements”DurableWS targets the standard global WebSocket with zero runtime
dependencies:
- Node.js ≥ 22 — the first release with the global
WebSocketavailable unflagged (there is nowsdependency, by design). - Any modern runtime with a global
WebSocket: current browsers, Deno, Bun, and Cloudflare Workers.
Installation
Section titled “Installation”npm install durablewsQuick start
Section titled “Quick start”import { defineClient } from "durablews";
const client = defineClient({ url: "wss://example.com/socket" });
client.on("message", (data) => { console.log("received:", data);});
await client.connect();client.send({ type: "hello", message: "world" });
// laterclient.close();Typed (and validated) messages
Section titled “Typed (and validated) messages”Pass any Standard Schema — zod, valibot, arktype, … — and you get both static types and runtime validation, with zero added dependencies:
import { z } from "zod";import { defineClient } from "durablews";
const Message = z.object({ type: z.string(), body: z.string() });
const client = defineClient({ url: "wss://example.com/socket", schema: Message});
client.on("message", (msg) => { // msg is { type: string; body: string } — inferred from the schema, // and every inbound message was validated at runtime.});
client.on("error", (err) => { // Invalid inbound messages arrive here as SchemaValidationError // (with the schema's issues) instead of reaching your handler.});Validation runs after the codec decodes and before middleware, so middleware and handlers only ever see trusted data.
Prefer types without runtime checks? Use generics:
const client = defineClient<Incoming, Outgoing>({ url });// on("message") receives Incoming; send() accepts OutgoingMiddleware
Section titled “Middleware”Middleware intercepts messages on their way through the client — in both directions. A bare function is inbound middleware; the object form registers per direction:
client.use({ outbound: async (ctx, next) => { // Attach a token that is fresh when the message actually goes out — // outbound middleware runs at transmission time, so even a message // queued across a reconnect gets a current token. ctx.data = { ...ctx.data, token: await getFreshToken() }; await next(); }});Outbound middleware may be async: the outbound path preserves send() order,
so messages never overtake each other (which also means a slow middleware
delays everything behind it — pacing the stream is a feature, but per-key
debounce/batching belongs in a wrapper in front of send(), not in the
pipeline). Returning without calling next() deliberately drops the message —
no drop event, that’s policy, not loss. A throw fails only that message (it
surfaces as an error event) and later messages continue. Heartbeat pings
bypass outbound middleware entirely.
What works today
Section titled “What works today”- Automatic reconnection, on by default — full-jitter exponential backoff
with unlimited retries. An unexpected disconnect transparently recovers; a
reconnectingevent ({ attempt, delay }) keeps your UI informed. Tune or disable via thereconnectoption (baseDelay,factor,maxDelay,jitter,maxRetries,shouldReconnect, orreconnect: false). - Message queueing while disconnected, on by default —
send()duringconnecting/reconnectingqueues (bounded, 256 by default) and flushes in order when the socket opens. Dropped messages are never silent: every one fires adropevent ({ data, reason }). Tune viaqueue: { maxSize }or restore throw-when-not-open withqueue: false. - Heartbeat / idle detection, opt-in — set
heartbeat: { interval, message?, timeout? }and the client pings while open; if no inbound traffic answers withintimeout, the link is declared dead (close code4408) and the normal reconnect machinery takes over. Opt-in because it requires a server that answers the ping. - Connect / send / close over the standard
WebSocket, driven by an explicit connection state machine - Incoming-message handling and lifecycle events (
open,message,close,error,statechange,reconnecting,drop) viaon() - A read-only
stateandgetState()snapshot (incl.retryAttemptandqueueLength) - A pluggable wire-format codec (
codecoption; JSON by default) - Typed + validated messages via any Standard Schema (
schemaoption), or plain generics (defineClient<In, Out>) - A message middleware pipeline (
use()) — inbound and outbound, with outbound running at transmission time, async-capable with strictsend()-order preservation; plus an opt-inpingpongkeepalive - Framework bindings in the box — a Vue composable and
a React hook (
durablews/vue,durablews/react) with reactive connection state and automatic cleanup; the frameworks are optional peers, so core installs never warn - A drop-in
WebSocketclass —durablews/compat: swap it in where a socket goes (or inject it as a library’swebSocketImpl) and get the durable core underneath, with a published known-deviations table
On the roadmap
Section titled “On the roadmap”Channels are the headline of v2.x — see the architecture RFC for the full plan and status.