Native WebSocket API patterns, connection lifecycle, reconnection strategies, heartbeat, message typing, binary data, custom hooks
Quick Guide: The native WebSocket API gives you a full-duplex channel and nothing else — reconnection, liveness detection, delivery during a drop and message typing are all yours to build, and this skill is the shape each of them takes. The facts that change the answer: the API accepts no custom headers, so authentication is a first message rather than a header;
onerroris always followed byonclose, so recovery belongs in one place; an open connection blocks the browser's back/forward cache; andreadyStatechanges are not synchronous with the calls that cause them.
Detailed Resources:
useWebSocket, shared connection, bfcachesubscribe(type, handler) API that returns its own unsubscribe. Opening a socket per component multiplies handshakes and heartbeats against the same server. Also examples/core.md.Once the connection has more than two or three states worth distinguishing — reconnecting-with-attempt-count, failed-permanently — the boolean flags stop composing and examples/state-machine.md is the shape that replaces them.
<critical_requirements>
Back off exponentially, with jitter, and cap both the delay and the attempt count. Every client dropped by one server restart tries to return at the same instant, and the jitter is what spreads them out instead of re-creating the outage.
Give every message a type field and model the set as a discriminated union. A never assignment in the default branch then turns a new server message into a compile error rather than a value that falls through.
Queue sends attempted while the socket is not OPEN, and flush on reconnect. send() on a closed socket throws or drops depending on the state, and a bounded queue is what turns a two-second drop into a delay rather than data loss.
Run a heartbeat and treat a missing reply as a dead connection. Proxies and NATs drop idle connections without a close frame, so a socket can read OPEN for minutes after it stopped carrying anything.
Use wss:// anywhere the page is served over HTTPS. Browsers block the insecure scheme from a secure origin, localhost aside.
Close on pagehide and reconnect on pageshow when event.persisted. An open socket disqualifies the page from the back/forward cache, so this is what keeps instant back-navigation working.
</critical_requirements>
Auto-detection: new WebSocket, wss://, ws://, socket.onmessage, socket.onopen, socket.onclose, socket.onerror, readyState, WebSocket.OPEN, bufferedAmount, binaryType, CloseEvent, event.code, pagehide, pageshow, event.persisted, WebSocketStream
Applies to:
Handled elsewhere:
A WebSocket is one TCP connection held open, with framing on top. Everything HTTP gave you for free is gone, and what you now own is what this skill is about.
CONNECTING -> OPEN <-> (messages) -> CLOSING -> CLOSED
| |
(error) <- reconnect <- (close)
</philosophy>
<decision_framework>
event.code says whether reconnecting is sensible: 1000 is a clean, intentional close and reconnecting fights the user; 1006 is an abnormal close with no frame, which is the ordinary network drop and the case backoff exists for; 1012 and 1013 are the server asking for a longer wait; the 100x protocol and data errors mean the client is wrong and retrying reproduces it. The full table with a reconnect column is in reference.md.
Track intentional closes separately from the code — a user pressing disconnect and a server sending 1000 both arrive as 1000, but only one of them should stop the retry loop for good.
JSON with a discriminated union is the default, and being readable on the wire is most of why. Reach for binary frames when payload size actually shows up in a measurement, and keep the mixed shape — JSON for control messages, binary for the payload — rather than encoding everything one way.
send() accepts anything and buffers what the network has not taken, so a fast producer grows bufferedAmount without bound. Check it before a large send and chunk the payload, which also makes progress reportable. There is no built-in backpressure signal beyond that number.
</decision_framework>
Four handlers, and all four earn their place: onerror carries no useful detail and is always followed by onclose, so recovery goes in onclose alone.
const socket = new WebSocket(WS_URL);
socket.onopen = () => flushQueue();
socket.onmessage = (event: MessageEvent) => handle(event.data);
socket.onerror = () => markUnhealthy(); // no detail available, onclose follows
socket.onclose = (event: CloseEvent) => maybeReconnect(event.code);
Full code: examples/core.md
The jitter is the part that matters: without it every client dropped together returns together.
function calculateBackoff(attempt: number): number {
const exponential = Math.min(
INITIAL_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, attempt),
MAX_BACKOFF_MS,
);
const jitter = exponential * JITTER_FACTOR * (Math.random() * 2 - 1);
return Math.floor(exponential + jitter);
}
Full code: examples/core.md
Send on an interval, arm a shorter timeout, and let the reply disarm it. A timeout that fires means the connection is dead however healthy readyState looks.
const ping = setInterval(() => {
socket.send(JSON.stringify({ type: "ping" }));
pongTimer = setTimeout(
() => socket.close(4000, "heartbeat timeout"),
HEARTBEAT_TIMEOUT_MS,
);
}, HEARTBEAT_INTERVAL_MS);
// on receiving { type: "pong" }: clearTimeout(pongTimer)
Full code: examples/core.md
Check readyState before every send, because it changes independently of the calls around it.
public send(data: unknown): void {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(data));
} else {
this.queueMessage(data); // bounded — oldest dropped at MAX_QUEUE_SIZE
}
}
Full code: examples/core.md
Separate unions per direction, and an exhaustiveness check on the receiving one.
type ServerMessage =
| { type: "subscribed"; channel: string; members: string[] }
| { type: "message"; channel: string; content: string; sender: string }
| { type: "error"; code: number; message: string };
function handleServerMessage(message: ServerMessage): void {
switch (message.type) {
case "subscribed":
return onSubscribed(message);
case "message":
return onMessage(message);
case "error":
return onError(message);
default: {
const exhaustive: never = message;
return exhaustive;
}
}
}
Full code: examples/core.md
binaryType = "arraybuffer" makes a frame readable synchronously through a DataView; the default Blob forces an async read for every message.
socket.binaryType = "arraybuffer";
socket.onmessage = (event: MessageEvent) => {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data);
const messageType = view.getUint8(0);
} else {
handleJson(JSON.parse(event.data));
}
};
Full code: examples/core.md · chunked uploads: examples/binary.md
The API sets no custom headers, so the token goes in the first frame — not the URL, which is logged. Everything else waits behind the result.
socket.onopen = () => {
socket.send(JSON.stringify({ type: "auth", token }));
};
// queue all other sends until { type: "auth_result", success: true } arrives
Full code: examples/core.md
There is no room concept in the protocol — it is a message convention plus local membership state, and the guard against sending to an unjoined room is what makes the state worth keeping.
public joinRoom(roomId: string): void {
if (this.rooms.has(roomId)) return;
this.rooms.set(roomId, { id: roomId, members: new Set(), joined: false });
this.send({ type: "join_room", roomId });
}
// the server's room_joined reply flips joined to true and seeds members
Full code: examples/core.md
One hook owning the socket, the backoff timer, the heartbeat and the queue, exposing status and the actions.
const { status, send, close, reconnect } = useWebSocket(WS_URL, {
onMessage: handleServerMessage,
heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
});
Full code: examples/core.md
The provider owns the socket and dispatches by message type; each subscriber gets back its own unsubscribe to return from an effect.
const { status, send, subscribe } = useWebSocketContext();
useEffect(() => subscribe("notification", handleNotification), [subscribe]);
Full code: examples/core.md
Close on pagehide so the page stays eligible for the cache, and reconnect on pageshow only when it was actually restored from it.
window.addEventListener("pagehide", () => socket?.close(1000, "Page hidden"));
window.addEventListener("pageshow", (event: PageTransitionEvent) => {
if (event.persisted) connect();
});
Full code: examples/core.md
</patterns><red_flags>
Breaks at runtime:
ws:// on an HTTPS page — browsers refuse it outside localhost.send() without a readyState check — the state moves independently of the surrounding code, so the message is lost or throws.JSON.parse on event.data with no try — one malformed frame takes the handler down for every frame after it.event.data is text — check instanceof ArrayBuffer first once any binary is in play.beforeunload used for cleanup — registering it is itself enough to disqualify the page from bfcache — use pagehide.1000 — that is the clean, intentional close, including the one the user asked for.Surprising behaviour:
onerror carries no diagnostic detail and is always followed by onclose; recovery written in both places runs twice.readyState can read OPEN for minutes — only a heartbeat notices.bufferedAmount is the only backpressure signal there is; nothing throws when a fast producer outruns the network.binaryType is Blob, which forces an async read per message — set arraybuffer before the first binary frame arrives.1006 never appears on the wire; the browser synthesises it for an abnormal close, so it carries no server reason.WebSocketStream, which would give real backpressure, is Chromium-only.</red_flags>
npx skills add agents-inc/web-realtime-websockets下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer