Skip to content

WebSocket

createWebSocket + useWebSocket mirror the SSE API but add a send channel. Use them when the client needs to send messages, not just receive.

When WebSocket instead of SSE?

SSE only receives. As soon as the client needs to send (chat, shared cursors, real-time commands), you need a bidirectional channel — that's where WebSocket comes in. If it's purely server → client push, SSE is simpler.

When to use

  • Chat / real-time collaboration.
  • Bidirectional, low latency.
  • Binary frames (also supported via send(ArrayBuffer)).

Imperative API — createWebSocket

import { createWebSocket } from "tempest-react-sdk";

interface ChatEvent {
  user: string;
  text: string;
}

const socket = createWebSocket<ChatEvent>(`${import.meta.env.VITE_WS_URL}/chat`, {
  pingInterval: 30_000, // keeps the socket alive
  maxRetries: 10,
  onOpen: () => console.log("ws open"),
  onMessage: ({ data }) => render(data),
  onClose: (event) => console.log("closed", event.code),
  onStatusChange: (status) => console.log("WS", status),
});

// Send — returns false if the socket isn't open:
const sent = socket.send(JSON.stringify({ user: "ana", text: "hi" }));

// Binary frame:
socket.send(new Uint8Array([1, 2, 3]).buffer);

// Close (does not try to reconnect):
socket.close();

// Force an immediate reconnect (resets the counter):
socket.reconnect();

onMessage receives { data, raw } — data is already JSON-parsed (string fallback), raw is the original MessageEvent.

Reconnection, ping, and clean close

Backoff: 1s → 2s → 4s → ... (capped at 30s), up to maxRetries (default 10)
  • Exponential reconnect, same as SSE (default maxRetries: 10), with 30% jitter (jitter, 0–1) added to each wait.
  • pingInterval (ms) sends pingPayload periodically — default JSON.stringify({ type: "ping" }). Pass 0 (default) to disable.
  • respondToPing (default true) answers pongPayload to every inbound {"type":"ping"} frame. That is the heartbeat tempest-fastapi-sdk expects: without the pong it closes the socket with code 4408 once WS_HEARTBEAT_TIMEOUT_SECONDS passes.
  • queueWhileClosed (default false) buffers whatever you send while the socket is down and drains it on the next open, oldest first, capped at maxQueuedMessages (default 100).
  • Which close reopens depends on the code, not on wasClean alone — the table is in Reconnect or not.

Why jitter

When the server is what went down, every client connected to it wakes on the same schedule and retries in the same millisecond — the box comes back up into a synchronized stampede and drops the connections again. Jitter only ever adds time (the floor stays predictable) and breaks the alignment. Pass jitter: 0 when you want an exact schedule, in a test for instance.

Failures that fire no event

Three failure modes produce no open, no close and no error — which is why a retry chain built on events alone gets stuck.

1. The handshake that hangs. A WebSocket that cannot reach its server does not fail: it sits in CONNECTING, silent. Measured in Chrome with the backend down, 12 s later readyState was still 0 and the event list empty. A retry chain built only on events stops on its first hung attempt and never moves again — and hanging, rather than refusing, is exactly how a bad mobile link behaves, which is the very case reconnection exists for.

handshakeTimeout (default 8000 ms, 0 disables) abandons the attempt and schedules the next one.

2. The link that dies in flight. The socket only reports a connection that closes cleanly. When the link dies mid-flight — the phone leaves coverage, an intermediary drops the connection — readyState stays OPEN on this side and nothing ever arrives again. Silence is the only symptom available.

silenceTimeout (ms, default 0 = off) is the tolerated window. It is re-armed by any inbound frame, not just by a pong: traffic is traffic, and an exchange in progress already proves the link is carrying data. Use ~2.5× the server's ping interval, so one dropped ping is not mistaken for an outage.

const socket = createWebSocket(url, {
  silenceTimeout: 75_000,
  onMessage: ({ data }) => {
    // the server announces its own heartbeat: don't hard-code it on both ends
    if (data.type === "welcome") socket.setSilenceTimeout(data.heartbeat_seconds * 2500);
  },
});

3. The radio that is switched off. Burning retries against a radio that is off is how a phone exhausts its budget inside a tunnel and gives up right as it comes out the other side. With waitForOnline (default true), while navigator.onLine is false the schedule is suspended and the online event drives the next attempt.

Reconnect or not

Close Reopens? Why
wasClean: false (any code) ✅ the connection died, it did not end
1001, 1011, 1012, 1013 ✅ server going away for a reason that ends (deploy, restart, overload)
4408 ✅ tempest-fastapi-sdk heartbeat timeout — that is the link failing
clean 1000 / 1005 ❌ a goodbye meant on purpose: session over, logout, stream finished
4400–4499 (minus 4408) ❌ → onLost("rejected") refusal: invalid room, unauthorized, forbidden, full

The same classification is exported, for when your own code has to inspect a CloseEvent: isRejectionCloseCode(code) answers whether that close is a refusal, and HEARTBEAT_CLOSE_CODE is 4408 with a name.

import { HEARTBEAT_CLOSE_CODE, isRejectionCloseCode } from "tempest-react-sdk";

onClose: (event) => {
  if (isRejectionCloseCode(event.code)) showAccessDenied();
  else if (event.code === HEARTBEAT_CLOSE_CODE) log("heartbeat lost");
};

4408 lives inside the refusal range and is not one

The obvious reading — "4400–4499 is fatal" — makes one dropped pong permanent. tempest-fastapi-sdk closes with 4408 when no pong arrived within WS_HEARTBEAT_TIMEOUT_SECONDS: that is the link failing, exactly the case reconnection exists for. The SDK carves out the exception for you.

Reconnecting ≠ error

const socket = createWebSocket(url, {
  onReconnecting: (attempt, total) => setBanner(`Reconnecting ${attempt}/${total}…`),
  onReconnected: () => refetchEverything(),
  onLost: (reason) => setBanner(reason === "rejected" ? "Access denied" : "No connection"),
});
  • onReconnecting(attempt, total) — an attempt was scheduled. A quiet state, not an error: announcing every attempt puts "the connection dropped" in front of someone whose session is coming back on its own.
  • onReconnected() — back up after at least one attempt. Nothing is resumed for you: a server that keys state by connection sees a brand-new client, so this is where you re-subscribe, re-join or refetch whatever the gap invalidated.
  • onLost(reason) — it gave up. This is the one that deserves UI, because it is the only state the person can act on: offer a "try again" that calls reconnect().

Joining is not dropping

const socket = createWebSocket(url, { maxRetries: 0 });

try {
  await socket.opened;
} catch {
  showJoinError();
}

opened resolves on the first open and rejects when the socket dies without ever having opened (websocket_rejected, websocket_exhausted or websocket_closed). Failing to join is a different event from dropping mid-session: the first has to be reported, the second should reconnect quietly. Pair it with maxRetries: 0 when the first attempt should fail fast instead of spending the whole schedule against a server that is not there.

send() is a no-op when the socket isn't open

If you call send() before the status becomes "open" (or after a close), nothing is sent and the return value is false. Always check status === "open" (or the returned boolean) before assuming the message went out.

Turn on queueWhileClosed: true when the action must not vanish: send() then buffers during the backoff and returns true, and the queue drains on the next open. The queue dies on close() — nothing survives an explicit shutdown.

Against a Tempest server, leave pingInterval off

tempest-fastapi-sdk sends the ping and wants the pong back. A client-originated {"type":"ping"} is an unknown frame to it — a strict handler rejects it. Keep pingInterval: 0 (the default) and let respondToPing handle the heartbeat.

Hook — useWebSocket

import { useState } from "react";
import { useWebSocket } from "tempest-react-sdk";

interface ChatEvent {
  user: string;
  text: string;
}

export function Chat({ enabled }: { enabled: boolean }) {
  const [draft, setDraft] = useState("");
  const [log, setLog] = useState<ChatEvent[]>([]);

  const ws = useWebSocket<ChatEvent>(`${import.meta.env.VITE_WS_URL}/chat`, {
    enabled,
    pingInterval: 30_000,
    onMessage: ({ data }) => setLog((prev) => [...prev, data]),
  });

  function sendMessage() {
    const ok = ws.send(JSON.stringify({ user: "me", text: draft }));
    if (ok) setDraft("");
  }

  return (
    <div>
      <span>Status: {ws.status}</span>
      <ul>
        {log.map((m, i) => (
          <li key={i}>
            <strong>{m.user}:</strong> {m.text}
          </li>
        ))}
      </ul>
      <input value={draft} onChange={(e) => setDraft(e.target.value)} />
      <button disabled={ws.status !== "open"} onClick={sendMessage}>
        Send
      </button>
      {ws.status === "error" && <button onClick={ws.reconnect}>Reconnect</button>}
    </div>
  );
}
  • The hook returns { status, lastMessage, send, reconnect, setSilenceTimeout }.
  • enabled: false does not open the socket; changing the URL reopens. Every callback (onOpen, onMessage, onClose, onError, onReconnecting, onReconnected, onLost) goes through a ref: an inline arrow function is safe, always runs the latest closure, and never reopens the socket.
  • Connection-shaping options (protocols, maxRetries, initialBackoff, maxBackoff, jitter, handshakeTimeout, silenceTimeout, waitForOnline, pingInterval, queueWhileClosed) are part of the handshake — changing one reopens the socket with the new value instead of being silently ignored.

lastMessage is a snapshot, not a queue

Every frame is a setState, so two arriving in the same tick collapse into one render and you only ever see the later one. A single server action often emits several frames in a row. For streams use onMessage, which fires once per frame; lastMessage is for rendering current state.

  • send is stable (useCallback) — safe to put in deps without reopening anything.
  • Automatic cleanup on unmount (clean close, no reconnect attempts).

A frame that is not JSON

Both transports JSON.parse the frame by default. When that fails — the server returned an error page, a plain-text ping arrived, a proxy injected something — the SDK delivers the raw string announced as your type. That is the historical behaviour and it stays, because changing it would break whoever relies on it; what changed is that it stopped being silent.

import { createWebSocket } from "tempest-react-sdk";

interface Event {
    id: string;
    kind: string;
}

const socket = createWebSocket<Event>("wss://api.example.com/events", {
    onParseError: (error, raw) => {
        console.error("unreadable frame, dropped:", raw.slice(0, 120), error);
    },
    onMessage: ({ data }) => {
        console.log(data.id);
    },
});

With onParseError registered, the broken frame does not reach onMessage — a caller who asked to hear about failures did not ask to also receive the frame. Without it, the frame is delivered as before and a development build warns once per transport in the console.

Why the old default is a trap

data typed as Event while actually being a string does not blow up at the parse — it blows up at the first data.id, far away, with nothing pointing at the frame that caused it. The warning and onParseError exist so the error shows up where it happens.

parser still wins

Passing parser switches all of this off: its result is delivered, because decoding text, base64 binary or a protocol of your own is exactly what the option is for. onParseError only applies when there is no parser. The one thing that still looks at what parser returned is schema, when you pass both.

Validating the frame with schema

The type argument (createWebSocket<Event>) is a promise about the server that TypeScript cannot keep: it is erased at runtime. A frame missing its message field reaches onMessage announced as Event, with message: undefined, and the app writes that to IndexedDB and renders an empty notification — with no error anywhere along the way.

Pass schema and validating stops being the app's job:

import { useWebSocket } from "tempest-react-sdk";
import { z } from "zod";

const frameSchema = z.object({
    kind: z.string(),
    body: z.string(),
});

type Frame = z.infer<typeof frameSchema>;

export function Room() {
    const { status } = useWebSocket<Frame>("wss://api.example.com/room", {
        schema: frameSchema,
        onValidationError: (issues, raw) => {
            console.warn("frame off contract, dropped:", issues, raw.slice(0, 120));
        },
        onMessage: ({ data }) => {
            console.log(data.body);
        },
    });

    return <p>Room: {status}</p>;
}

What the option guarantees:

  • without schema, nothing changes — today's path stays exactly as it is, fallback and warning included;
  • a frame that does not match is not delivered, the same rule onParseError already follows, and onValidationError receives the issues (dotted path + message) plus the raw text;
  • the delivered value is the schema's output, so z.coerce, .default() and .transform() all count;
  • parser decodes first, and schema validates what it returned;
  • an empty frame is an invalid frame: JSON.parse("") throws, and with schema the raw text goes to the schema, which refuses it — instead of arriving as an empty string announced as your type.

zod, valibot, arktype — and the SDK depends on none of them

schema accepts anything exposing Standard Schema (~standard, which zod >= 3.24, valibot and arktype all implement) or .safeParse — the signature every zod user already knows, and the one that covers zod 3.23, which predates ~standard. None of those libraries becomes a dependency: validation is called through the interface.

Validation must be synchronous

The frame is decoded inside the message handler and delivered from it, so there is nowhere to await: an async schema would deliver frames in whatever order their validations settled. That case is reported through onValidationError rather than awaited. Use z.object(...), not .refine(async ...).

Declare the schema outside the component

schema is read when the socket opens. A schema built inline is a new object on every render — declare it at module level (or memoize it), and the one in force is whichever existed at the last open.

The pong still goes out

A server ping ({"type":"ping"}) almost never matches the app's schema — and it does not stop being answered because of that. The heartbeat is the transport's contract with the server, not the app's with its payload: tempest-fastapi-sdk closes the socket with 4408 when no pong arrives, so the reply is decided from the frame's text, before the schema. The ping itself does not reach your onMessage when the schema refuses it — which is right, since you never declared it.

A signal that exists in production, not only in dev

The one-time warning is dev-only (it goes through isDevBuild(), which answers correctly on its own under Vite, webpack, Rspack and Parcel — measured) and it lives in the console of whoever has DevTools open. onValidationError is the app's own: it fires in every build, and it is where a "the backend changed the contract" metric comes from — the one that reaches your logger in production, where nobody is watching a console.

Status

"idle" | "connecting" | "open" | "closing" | "closed" | "error" — error is terminal and arrives together with onLost: either the schedule ran out ("exhausted") or the server refused the client ("rejected"). A reconnect in progress goes through "connecting", not through "error".

Recap

  • createWebSocket(url, options) opens a bidirectional WebSocket; the controller exposes send, close, reconnect, setSilenceTimeout, opened, and status.
  • send() returns false (no-op) when the socket isn't open — always check first.
  • Three failures fire no event at all and each has its own knob: a hung handshake (handshakeTimeout), a link that died in flight (silenceTimeout), a radio that is off (waitForOnline).
  • Reopening depends on the close code, not on wasClean alone: 4400–4499 is a refusal (minus 4408, the heartbeat, which does reopen); a clean 1000 is a goodbye.
  • onReconnecting is a quiet state; onLost is what deserves UI and a "try again" button.
  • opened separates "could not join" from "dropped mid-session" — the first is an error, the second reconnects on its own.
  • pingInterval + pingPayload keep the connection alive.
  • schema validates each frame before delivering it — the heartbeat pong still goes out even when the schema refuses the ping.
  • useWebSocket ties everything to the component and exposes status/lastMessage/send/reconnect/setSilenceTimeout.

See also

  • SSE — when you only receive (simpler)
  • HTTP