WebSockets
pp.socket(name, args, handlers) opens a named socket: a long-lived, bidirectional JSON channel to a server-side function. It keeps itself alive with a heartbeat and reconnects on its own after a dropped connection. It is part of the core runtime — no plugin, no extra script.
Choosing the right wire
Sockets are for genuinely bidirectional channels. Ordinary reads and writes stay on RPC, and one-way server push stays on RPC streaming:
| API | Shape | Use it for |
|---|---|---|
pp.rpc(name, data) |
Request / response | Reads, writes, form submits — one call, one JSON answer. |
pp.rpc(name, data, { onStream }) |
One-way server push | LLM output, progress feeds — the server streams chunks over SSE. |
pp.socket(name, args, handlers) |
Bidirectional, long-lived | Chat, presence, notifications, live dashboards, collaborative editing — both sides send frames at any time. |
The component pattern
Open the socket in a mount effect, keep the handle in a ref, close it in the effect's cleanup. The socket's lifetime is the component's lifetime — disposal runs the cleanup, so nothing leaks on either end of the wire. The handle reconnects by itself; the component only reflects the state:
<div pp-component="chat_room">
<p>{status}</p>
<ul>
<template pp-for="msg in messages">
<li key="{msg.id}">{msg.author}: {msg.text}</li>
</template>
</ul>
<form onsubmit="send(event)">
<input name="text" autocomplete="off" placeholder="Say something…" />
<button>Send</button>
</form>
<script>
const [messages, setMessages] = pp.state([]);
const [status, setStatus] = pp.state("Connecting…");
// The handle lives in a ref: connection plumbing is not render-state.
const socketRef = pp.ref(null);
// Open in a mount effect, close in its cleanup — the socket's lifetime
// is the component's lifetime. Reconnecting is the runtime's job.
pp.effect(() => {
socketRef.current = pp.socket("chatRoom", { room: "general" }, {
onOpen: ({ reconnected }) => {
setStatus("Connected");
// Frames sent while the connection was down are not replayed:
// reload whatever this page must not miss.
if (reconnected) pp.rpc("recentMessages", { room: "general" }).then(setMessages);
},
onMessage: (msg) => setMessages((prev) => [...prev, msg]),
onError: (err) => setStatus("Error: " + err.message),
onClose: ({ willReconnect }) => setStatus(willReconnect ? "Reconnecting…" : "Closed"),
});
return () => socketRef.current?.close();
}, []);
const send = (event) => {
event.preventDefault();
const form = event.currentTarget;
const text = String(new FormData(form).get("text") || "").trim();
if (!text) return;
// Buffered while reconnecting, delivered once the connection is back.
socketRef.current.send({ text });
form.reset();
};
</script>
</div>
Staying connected
A quiet connection is not a dead one. A notification feed can sit silent for an hour and then deliver one message, so the handle keeps the channel alive the way Socket.IO does, with no code on your side:
- Heartbeat. Every 25 s an open connection sends
{"__pp": "ping"}and the server answers{"__pp": "pong"}. Any frame counts as proof of life. If nothing arrives within 20 s of a ping — a laptop that slept, a proxy that silently dropped the connection — the connection is treated as dead and replaced. - Reconnect. After an unexpected close the handle opens a new connection with exponential backoff and jitter (1 s doubling to 30 s), sends the arguments frame again, and flushes any
send()calls made meanwhile. - Wake-ups. When the browser comes back
online, or a hidden tab becomes visible, a waiting reconnect happens immediately and an open connection is re-checked with a ping instead of waiting for the next scheduled one. - No replay. Frames the server sent while the connection was down are lost, as with Socket.IO without connection-state recovery. When a page must not miss one, reload the state it needs in
onOpenwhenreconnectedis true. - Fresh call per connection. Each reconnect runs the server function again from the top with the same arguments, so it must be safe to run twice (a chat room announces the rejoin; a feed re-registers its listener).
Whether a close is followed by a reconnect depends on why it happened:
| Close | Meaning | Reconnects |
|---|---|---|
handle.close() |
The page closed it. | No |
{"error": "…"} frame |
The server refused the socket or the function failed. | No |
1000 |
The server function returned — the conversation is over. | No |
1003 / 1007 / 1008 / 1009 / 1010 |
Policy: rejected data, auth or origin, oversized frame. | No |
4000 |
Idle timeout: the server stopped hearing from this client. | Yes |
Heartbeat timeout |
No frame arrived within heartbeatTimeout of a ping. | Yes |
1001 / 1006 / 1011 / 1012 / 1013 … |
Server restart, network loss, crash, server full. | Yes, with backoff |
Handlers
| Handler | Fires when |
|---|---|
onOpen({ reconnected }) |
Every successful open, after the argument frame has been sent. reconnected is false for the first connection and true for every reconnect. |
onMessage(value) |
One incoming frame, JSON-parsed. Non-JSON text is handed through as a string rather than dropped. Heartbeat frames ({"__pp": …}) never arrive here. |
onError(error) |
The first connection could not open, or the server sent an error frame — a frame shaped {"error": "…"} (that key alone) is reserved for failure and routed here, never to onMessage. |
onClose({ code, reason, wasClean, willReconnect }) |
Every closed connection, including one about to be replaced. willReconnect says whether the handle will open a new one. |
onReconnecting({ attempt, delay }) |
A reconnect is scheduled delay ms from now. attempt counts consecutive failures and resets after a successful open. |
Options
Passed in the same object as the handlers. The defaults suit almost every page:
| Option | Default | Effect |
|---|---|---|
url |
— |
Endpoint override; defaults to the shared /__pulsepoint/ws path. |
reconnect |
true |
Reopen after an unexpected close. false restores the one-shot behavior. |
reconnectDelay |
1000 |
First backoff delay in ms. Each failed attempt doubles it. |
reconnectDelayMax |
30000 |
Backoff ceiling in ms. |
maxReconnectAttempts |
Infinity |
Give up after this many consecutive failed attempts. |
heartbeatInterval |
25000 |
Ping period in ms while connected. 0 disables the heartbeat. |
heartbeatTimeout |
20000 |
How long to wait for any frame after a ping before the connection is treated as dead. |
The returned handle
| Member | Behavior |
|---|---|
send(value) |
Queues one JSON value. Frames sent while connecting or reconnecting are buffered (up to 256) and flushed after the argument frame. Returns false once the handle is closed for good, or when the buffer is full. |
close(code?, reason?) |
Closes for good (defaults to a clean 1000 close) and cancels any pending reconnect. |
readyState |
Mirrors WebSocket.readyState; CONNECTING while waiting to reconnect, CLOSED once the handle is done. |
The wire protocol
Every named socket shares one endpoint, /__pulsepoint/ws, with the function's name in the name query parameter. The arguments travel as the connection's first frame rather than in the URL — a URL is logged by every proxy on the way, and an argument is data. Two frame shapes are reserved: {"error": …} for failure and {"__pp": …} for the heartbeat (each with that key alone):
CLIENT SERVER
| WS CONNECT /__pulsepoint/ws?name=chatRoom |
|----------------------------------------------->| check Origin, look up "chatRoom"
| frame 1: {"room": "general"} | the arguments — one JSON object,
|----------------------------------------------->| filtered against the handler signature
| {"text": "hi"} |
|----------------------------------------------->|
| {"id": 7, "author": "ana", "text": "hi"}|
|<-----------------------------------------------|
| |
| ... 25 s of silence ... |
| {"__pp": "ping"} | heartbeat: answered by the server,
|----------------------------------------------->| never passed to the handler
| {"__pp": "pong"} |
|<-----------------------------------------------|
| |
| {"error": "room closed"} | reserved failure frame
|<-----------------------------------------------| → onError, then the server closes
The server half
Any backend can serve the endpoint — resolve the name against an explicit registry (exactly like RPC), read the argument frame, then exchange JSON frames. Keep one task reading the socket at all times, so the heartbeat is answered even by a handler that only sends:
# The server half, FastAPI flavor — one endpoint serves every named socket.
IDLE_TIMEOUT = 120 # seconds; must comfortably exceed the 25 s client heartbeat
@app.websocket("/__pulsepoint/ws")
async def pulsepoint_socket(ws: WebSocket):
if ws.headers.get("origin") not in ALLOWED_ORIGINS:
await ws.close(code=1008)
return
handler = SOCKETS.get(ws.query_params.get("name")) # explicit registry
await ws.accept()
if handler is None:
await ws.send_text(json.dumps({"error": "no such socket"}))
await ws.close(code=1008)
return
args = json.loads(await ws.receive_text()) # first frame = arguments
inbox: asyncio.Queue = asyncio.Queue()
async def reader():
# Read every frame, even while the handler is busy or only sending:
# the heartbeat must be answered no matter what the handler does.
while True:
try:
text = await asyncio.wait_for(ws.receive_text(), IDLE_TIMEOUT)
except asyncio.TimeoutError:
await ws.close(code=4000, reason="idle timeout") # client reconnects
break
except WebSocketDisconnect:
break
frame = json.loads(text)
if isinstance(frame, dict) and frame.keys() == {"__pp"}:
if frame["__pp"] == "ping":
await ws.send_text('{"__pp": "pong"}')
continue # control frames stop here
await inbox.put(frame)
await inbox.put(None) # end of conversation
reading = asyncio.create_task(reader())
try:
await handler(ws, inbox, **filter_to_signature(handler, args))
await ws.close(code=1000) # finished: no reconnect
except Exception as exc:
await ws.send_text(json.dumps({"error": str(exc)}))
await ws.close(code=1008)
finally:
reading.cancel()
A server that does not answer {"__pp": "ping"} gets its connections replaced about every 45 s. Close an unresponsive peer with 4000, never 1000: 1000 tells the client the conversation is over and it will not come back. Production servers must also check the Origin header against an allow-list, cap concurrent connections, and bound message size and rate. The full server contract lives in Backend Integration and in llms.md.
Rules
- Open sockets in
pp.effect(..., []), keep the handle inpp.ref(...), close in the cleanup — never at module top level. - Do not write your own reconnect loop or ping timer around
pp.socket; the handle already does both. ReflectwillReconnectin UI state instead. - Frames sent before the connection opens, or while it reconnects, are buffered, so
pp.socket(...).send(...)on one line just works. - A
send()that returnsfalsemeans the handle is closed for good (or its buffer is full) — open a new socket rather than retrying the send. - Reach for a raw
new WebSocket(...)only for wires the JSON-frame contract cannot carry, such as binary protocols — and then implement the origin check, auth, heartbeat and reconnect yourself.