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).
  • 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).
  • Reconnect only fires on close.wasClean === false. Clean closes (socket.close() or a normal server close) do not try to reopen.

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 }.
  • enabled: false does not open the socket; changing the URL reopens. Every callback (onOpen, onMessage, onClose, onError) 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, 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).

Status

"idle" | "connecting" | "open" | "closing" | "closed" | "error"error means the socket exhausted maxRetries after non-clean closes.

Recap

  • createWebSocket(url, options) opens a bidirectional WebSocket; the controller exposes send, close, reconnect, and status.
  • send() returns false (no-op) when the socket isn't open — always check first.
  • Exponential reconnect only fires on a non-clean close; clean closes do not reopen.
  • pingInterval + pingPayload keep the connection alive.
  • useWebSocket ties everything to the component and exposes status/lastMessage/send/reconnect.

See also

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