Skip to content

Bidirectional real-time connections in sillo — routing, the WebSocket object, the connection lifecycle, the state machine that governs it, and what breaks when you run more than one worker.

A WebSocket is a long-lived, bidirectional connection between a client and your server. Unlike an HTTP request, which ends when the response is sent, a WebSocket stays open and either side can send at any time. That makes it the right tool for chat, live dashboards, collaborative editing, multiplayer state, and push notifications.

sillo’s WebSocket support has four layers, each building on the one below:

LayerWhat it gives youGuide
WebSocketThe raw connection: accept, send, receive, closeThis page
ChannelA connection plus a payload type and an expiryChannels
ChannelBoxNamed groups of channels and broadcastGroups
WebSocketConsumerA class with lifecycle hooks and group helpersConsumers

Start at the bottom. Most real applications end up using consumers, but understanding the raw connection first makes the rest obvious rather than magical.

Use one when the server needs to initiate messages, and often — a chat room, a price ticker, a progress feed, a presence indicator.

Do not use one when the client drives everything. A form submission, a search, a file upload, a CRUD API: all of these are request/response, and HTTP does request/response better, with caching, retries, status codes, and every piece of debugging tooling ever built.

Consider Server-Sent Events instead when the traffic is one-directional server-to-client. SSE runs over plain HTTP, reconnects automatically in the browser, survives proxies that mangle upgrades, and needs no special infrastructure. See Streaming Responses. Reach for a WebSocket when you genuinely need the client to push too.

Register a WebSocket endpoint with the ws_route decorator:

a minimal echo endpoint
from sillo import silloApp
from sillo.websockets import WebSocket, WebSocketDisconnect
app = silloApp()
@app.ws_route("/ws/echo")
async def echo(websocket: WebSocket):
await websocket.accept()
try:
while True:
message = await websocket.receive_text()
await websocket.send_text(f"echo: {message}")
except WebSocketDisconnect:
pass

The handler takes exactly one argument — the WebSocket. It is not called with request and response, and it does not return anything. The connection ends when the handler returns.

There is no @app.websocket(...) decorator. The name is ws_route.

Three equivalent registration forms:

the three ways to register
@app.ws_route("/ws/chat")
async def chat(websocket): ...
app.add_ws_route(path="/ws/chat", handler=chat)
from sillo.core.routing import WebsocketRoute
app.add_ws_route(WebsocketRoute("/ws/chat", chat))

Declared the same way as HTTP routes, and read off the connection rather than injected as arguments:

path parameters
@app.ws_route("/ws/room/{room_id}")
async def room(websocket: WebSocket):
await websocket.accept()
room_id = websocket.path_params["room_id"] # '42' — a str
await websocket.send_json({"room": room_id})
a websocket router
from sillo.core.routing import Router
ws_router = Router(prefix="/ws")
ws_router.add_ws_route(path="/chat", handler=chat)
ws_router.add_ws_route(path="/presence", handler=presence)
app.mount_router(ws_router)

mount_router does not take a prefix — set it on the Router. And add_ws_route wants either a WebsocketRoute instance or the path=/handler= keyword pair; a bare positional function will not do.

Every WebSocket goes through the same four phases.

Handshake. The client sends an HTTP request with Upgrade: websocket. Your handler is invoked, and nothing can be sent yet.

Accept. You call await websocket.accept(). Until this happens the client is waiting; if you return without accepting or closing, the connection is rejected.

Exchange. Both sides send and receive freely until one stops.

Close. Either side closes. A client disconnect raises WebSocketDisconnect from whatever receive call is in flight.

the full shape
@app.ws_route("/ws/session")
async def session(websocket: WebSocket):
await websocket.accept()
try:
async for message in websocket.iter_json():
await handle(message)
except WebSocketDisconnect:
pass
finally:
await cleanup()

iter_json(), iter_text(), and iter_bytes() are async generators that loop until disconnect and then stop cleanly — they swallow WebSocketDisconnect internally, so the except above is belt and braces for anything raised by handle.

Do work before accepting when you need to reject

Section titled “Do work before accepting when you need to reject”

Authentication belongs before accept(). Once accepted, a rejection costs the client a round trip and looks like an error rather than a refusal.

authenticating before accept
@app.ws_route("/ws/private")
async def private(websocket: WebSocket):
token = websocket.query_params.get("token")
user = await authenticate(token)
if user is None:
await websocket.close(code=1008) # policy violation
return
await websocket.accept()
...

Browsers cannot set custom headers on a WebSocket handshake, which is why tokens usually arrive as a query parameter or in the first message after accept. A query parameter ends up in access logs and proxy logs — prefer a short-lived, single-use ticket over a long-lived session token, and treat it as compromised the moment it is used.

WebSocket tracks two states — client_state and application_state — and enforces legal transitions. This is why the error messages are specific rather than mysterious.

AttemptResult
receive_text() before accept()RuntimeError: WebSocket is not connected. Need to call "accept" first.
send_text() after close()RuntimeError: Cannot call "send" once a close message has been sent.
receive() after a disconnect messageRuntimeError: Cannot call "receive" once a disconnect message has been received.
send() when the peer is gone (OSError)WebSocketDisconnect(code=1006)

Two of these matter in practice. Accepting exactly once is easy to get wrong when a base class also accepts — see the consumer guide. And is_connected() is a method, not a property:

if websocket.is_connected(): # correct
await websocket.send_text("still here")
if websocket.is_connected: # always truthy — this is a bound method
await websocket.send_text("sent even after the peer is gone")

The second form is always true and silently defeats whatever guard you wrote it for.

the message API
await websocket.send_text("hello")
await websocket.send_bytes(b"\x00\x01")
await websocket.send_json({"type": "ping"})
await websocket.send_json(payload, mode="binary") # JSON over a binary frame
text = await websocket.receive_text()
data = await websocket.receive_bytes()
obj = await websocket.receive_json()
obj = await websocket.receive_json(mode="binary")

send_json serializes with separators=(",", ":") and ensure_ascii=False — compact output, and non-ASCII characters sent as real UTF-8 rather than escapes.

Frame type must match on both ends. Calling receive_text() when the client sent a binary frame raises KeyError: 'text', because the ASGI message has a bytes key instead. Pick one encoding per endpoint and document it, or use receive() and branch on what arrived:

handling either frame type
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
return
payload = message.get("text") or message["bytes"].decode()
await websocket.close(code=1000, reason="session ended")

The close codes worth knowing, all available as constants in sillo.websockets.status:

CodeConstantMeaning
1000WS_1000_NORMAL_CLOSUREDone, no error
1001WS_1001_GOING_AWAYServer shutting down
1003WS_1003_UNSUPPORTED_DATAWrong frame type or unparseable payload
1008WS_1008_POLICY_VIOLATIONAuth failure, rate limit
1011WS_1011_INTERNAL_ERRORUnhandled exception

Codes in the 4000–4999 range are yours to define. Use them for application-level reasons the client should act on differently — “token expired, refresh and reconnect” versus “you were banned, do not reconnect”.

Note that browsers do not expose the close reason reliably and, for abnormal closures, report code 1006 regardless of what you sent. Anything the client must act on should be a normal message sent before closing.

The other scaling limit is per-connection memory. Each open WebSocket holds a task, two ASGI callables, and whatever your handler keeps in scope. Ten thousand connections holding a loaded user object each is a memory profile worth measuring rather than assuming.

TestClient speaks WebSocket, so endpoints are testable without a running server. The connection is a context manager and the send/receive calls are synchronous.

testing a websocket endpoint
from sillo.testclient import TestClient
def test_echo():
with TestClient(app) as client:
with client.websocket_connect("/ws/echo") as ws:
ws.send_text("hello")
assert ws.receive_text() == "echo: hello"
def test_room_path_param():
with TestClient(app) as client:
with client.websocket_connect("/ws/room/42") as ws:
assert ws.receive_json() == {"room": "42"}

Leaving the inner block closes the connection, which is how you exercise disconnect handling — assert on whatever your finally was supposed to clean up. A rejected connection surfaces as WebSocketDisconnect raised by websocket_connect, so an auth test wraps it in pytest.raises.

Do not use @app.websocket(...). It does not exist; use ws_route.

Do not treat is_connected as a property. It is a method, and the un-called form is always truthy.

Do not accept before authenticating when the endpoint is private.

Do not send a path_params mapping to send_json. Index the values.

Do not mix frame types on one endpoint without branching on receive().

Do not rely on the close reason reaching the browser. Send a normal message first.

Do not run multiple workers with in-memory groups. Broadcasts will silently reach a fraction of your users.

Do not do slow work inside the receive loop. A blocking call stalls that connection entirely; hand it to a background task.

Each connection is one asyncio task. Idle connections cost almost nothing; the cost is in what you keep alive alongside them.

send_json serializes on every call. Broadcasting one payload to a thousand channels currently serializes it a thousand times, because Channel._send calls websocket.send_json(payload) per channel. For large payloads to large groups, pre-serialize once and use text channels.

Backpressure is the failure mode that surprises people. If a client stops reading and you keep sending, the frames queue in the server’s buffers until memory runs out. Cap what you queue per connection and drop or disconnect a client that falls too far behind — a slow consumer must not be allowed to degrade the process.

MemberSignatureNotes
accept(subprotocol=None, headers=None)Waits for websocket.connect first
receive_text / receive_bytes() -> str / bytesRaise if the frame type differs
receive_json(mode="text") -> Anymode is "text" or "binary"
iter_text / iter_bytes / iter_jsonasync generatorsStop cleanly on disconnect
send_text / send_bytes(data)
send_json(data, mode="text")Compact separators, ensure_ascii=False
close(code=1000, reason=None)
is_connected() -> boolMethod, not a property
path_params / query_params / headersmappingsInherited from HTTPConnection
  • Consumers — the class-based API with lifecycle hooks
  • Channels — wrapping a connection with a payload type and expiry
  • Groups — broadcasting to many connections
  • Events — routing messages to handlers by type
  • Streaming Responses — SSE, for one-directional push
  • Routing — how paths and parameters are matched