WebSockets
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Tornado 6.5.10 (released 2026-09-15) on Python 3.13, checked on 2026-10-07. Tornado 6.6
is only available as the alpha 6.6a1 at that date, so everything about 6.6 here (such as the trusted_origins
setting) is upcoming rather than released. WebSockets are one of the reasons Tornado exists: a single process holds
thousands of mostly idle connections on one event loop and pushes messages to them as events happen. The running
scenario is Bookshelf Live, where readers who have a book page open see new reviews and reading-progress updates
appear without reloading. The chat and presence logic that real products build on top of this is deliberately kept at
the level of framework wiring; the wider design (message protocols, delivery guarantees, history, presence) is tracked
separately in issue #264 and is not covered here.
What a WebSocket Is, and What Tornado Gives You
A WebSocket (RFC 6455) starts as an ordinary HTTP GET carrying Upgrade: websocket and Connection: Upgrade
headers plus a random Sec-WebSocket-Key. If the server agrees it answers 101 Switching Protocols, and from then on
the same TCP connection carries messages in both directions, in frames, with no request/response pairing. That
changes what a handler is: there is no get() or post() that returns a body, but a long-lived object with
callbacks for the connection life cycle.
Tornado implements the protocol itself, on top of its own IOStream (there is no extra library to install, unlike the
Flask and Django worlds where flask-sock, Flask-SocketIO or Django Channels are separate packages). The server side is
tornado.websocket.WebSocketHandler, a subclass of RequestHandler that routes like any other handler, and the
client side is tornado.websocket.websocket_connect.
| Piece | Role |
|---|---|
|
One instance per connection. |
|
Sends a message. A |
|
Starts the closing handshake with an RFC 6455 status code (1000 normal, 1001 going away, 1008 policy violation, 1009 message too big, 1013 try again later) and a short reason. |
|
Control frames. |
|
The browser-origin check performed during the handshake (see Origin Checks and Cross-Site WebSocket Hijacking). |
|
Handshake negotiation hooks (see Subprotocols and Compression). |
Application settings |
Keep-alive and size limits (see Keep-Alives, Timeouts and Size Limits). |
|
Client: returns a |
Because the connection stays open, a WebSocketHandler has an important restriction: after the handshake the normal
HTTP facilities are gone. The documented ways to talk to the client are write_message, ping and close; do not
call write, finish or redirect from open or on_message. Everything you need from the original request
(cookies, headers, query arguments, path captures) is still readable in open and prepare, because the handler
object is built from the handshake request.
Running the Examples on a Loopback Server
Every example on this page is executed against a real Tornado server bound to an unused loopback port, and talks to it
with Tornado’s own client. A small helper keeps that boilerplate in one place. bind_unused_port comes from
tornado.testing, and HTTPServer.add_socket attaches the socket to the running event loop.
import asyncio
import json
import secrets
from tornado import web
from tornado.httpclient import HTTPClientError, HTTPRequest
from tornado.httpserver import HTTPServer
from tornado.testing import bind_unused_port
from tornado.websocket import (
WebSocketClosedError,
WebSocketHandler,
websocket_connect,
)
async def serve(app: web.Application) -> tuple[HTTPServer, int]:
"""Start `app` on a free loopback port and return (server, port)."""
sock, port = bind_unused_port()
server = HTTPServer(app)
server.add_socket(sock)
return server, port
The Life Cycle: open, on_message and on_close
The smallest useful handler overrides the three callbacks. The arguments of open come from the route’s capture
groups, exactly like the arguments of get in a normal handler (see
Routing), so a route such as /ws/books/(\d)/live+ calls open(book_id).
class EchoSocket(WebSocketHandler):
def open(self):
# The handshake is complete; the connection is usable.
self.write_message({"type": "hello", "path": self.request.path})
def on_message(self, message):
# `message` is str for a text frame and bytes for a binary frame.
if isinstance(message, bytes):
self.write_message(message, binary=True)
else:
self.write_message("You said: " + message)
def on_close(self):
# close_code / close_reason are set when the peer sent them.
print("closed:", self.close_code, self.close_reason)
async def echo_demo():
server, port = await serve(web.Application([(r"/echo", EchoSocket)]))
conn = await websocket_connect(f"ws://127.0.0.1:{port}/echo")
print(await conn.read_message()) # the JSON hello
await conn.write_message("hello")
print(await conn.read_message()) # You said: hello
await conn.write_message(b"\x00\x01", binary=True)
print(await conn.read_message()) # b'\x00\x01'
conn.close(1000, "done")
print(await conn.read_message()) # None: the connection is closed
await asyncio.sleep(0.05) # let the server run on_close
server.stop()
asyncio.run(echo_demo())
Source: Tornado: WebSocketHandler
Points that surprise newcomers:
-
openandon_messagemay be coroutines (since Tornado 5.1 and 4.5).on_messageis not called untilopenhas returned, and, because the protocol loop waits for the coroutine, a connection processes its messages one at a time and in order. A slowon_messagetherefore delays the next message of the same connection, not other connections (see Pitfalls). -
on_closeruns exactly once, whether the peer closed cleanly, the network dropped, or the server calledclose. It is the only reliable place to remove the connection from shared structures. -
Exceptions raised in the callbacks are logged by Tornado and close the connection; they are not turned into an error page, because the HTTP response is already finished (see Error Handling).
-
write_messageon a closed connection raisesWebSocketClosedError(since 5.0 consistently; before that it could surface as anAttributeErroror a stream error). Code that writes from outside the callbacks, such as a broadcast loop, must expect it. -
A WebSocket handler is a normal
RequestHandlerfor routing: it can takeinitialize(...)keyword arguments from the route’s third element, andprepare()runs before the handshake, which is the place to reject the connection with anHTTPError(see Authenticating WebSockets with the Signed Cookie).
Text, JSON and Binary Messages
Tornado does not impose a message format. write_message accepts a str, a bytes value with binary=True, or a
dict which it serialises with tornado.escape.json_encode; on_message receives str or bytes depending on the
frame type. Receiving JSON is up to you, with json.loads inside a try that answers malformed input with a
protocol error rather than letting the exception close the socket.
class JsonSocket(WebSocketHandler):
def on_message(self, message):
try:
data = json.loads(message)
action = data["action"]
except (ValueError, KeyError, TypeError):
self.write_message({"type": "error", "reason": "malformed message"})
return
self.write_message({"type": "ack", "action": action})
async def json_demo():
server, port = await serve(web.Application([(r"/json", JsonSocket)]))
conn = await websocket_connect(f"ws://127.0.0.1:{port}/json")
await conn.write_message(json.dumps({"action": "subscribe"}))
print(await conn.read_message())
await conn.write_message("not json")
print(await conn.read_message())
conn.close()
server.stop()
asyncio.run(json_demo())
Origin Checks and Cross-Site WebSocket Hijacking
Browsers apply the same-origin policy to fetch and XHR, and CORS decides what scripts may read. WebSockets are
outside both: any page on any site may open new WebSocket("wss://bookshelf.example/ws"), and the browser will attach
that site’s cookies to the handshake. A malicious page can therefore open an authenticated socket on behalf of a
logged-in visitor and read everything pushed down it. This is cross-site WebSocket hijacking (CSWSH), and neither
CORS headers nor the xsrf_cookies setting protect against it, because the handshake is a GET and the browser does
not send CORS preflights for it (see CORS for what CORS does cover).
The defence is the Origin header, which browsers always send on a WebSocket handshake and scripts cannot forge.
Tornado checks it in the handshake through WebSocketHandler.check_origin(origin):
-
If the request has no
Originheader, the check is skipped. Non-browser clients (a Python script,websocat, a mobile SDK) normally do not send one, and they are not subject to the browser threat. -
By default the origin’s host (and port) must equal the request’s
Hostheader; otherwise the handshake answers403. Since Tornado 4.0 this strict default applies; before that every origin was accepted. A 403 on a WebSocket connection is almost always this check. -
Override
check_originto allow other origins. ReturningTruefor everything restores the pre-4.0 behaviour and is only acceptable when the connection carries no cookie-based authority (for example when authentication is a token in the first message).
class ReviewSocket(WebSocketHandler):
# Origins of the web front ends allowed to open this socket.
allowed_origins = {"https://bookshelf.example", "https://www.bookshelf.example"}
def check_origin(self, origin: str) -> bool:
# Exact match of scheme + host + port, never a substring or endswith test.
return origin in self.allowed_origins
def open(self):
self.write_message("welcome")
async def origin_demo():
server, port = await serve(web.Application([(r"/live", ReviewSocket)]))
url = f"ws://127.0.0.1:{port}/live"
# A browser page on an unknown site: rejected during the handshake.
try:
await websocket_connect(
HTTPRequest(url, headers={"Origin": "https://evil.example"})
)
except HTTPClientError as exc:
print("rejected:", exc.code)
# The real front end: accepted.
conn = await websocket_connect(
HTTPRequest(url, headers={"Origin": "https://bookshelf.example"})
)
print(await conn.read_message())
# No Origin header (a script, not a browser): the check is skipped.
other = await websocket_connect(url)
print(await other.read_message())
conn.close()
other.close()
server.stop()
asyncio.run(origin_demo())
Comparing with endswith(".bookshelf.example") style helpers (which the documentation shows as an example for
allowing subdomains) needs care: parse the origin with urllib.parse.urlparse, compare the hostname exactly or
against a leading-dot suffix, and remember evilbookshelf.example also ends with bookshelf.example. Because the
header is only checked when present, a missing Origin is not evidence of safety for cookie-authenticated endpoints:
combine origin checks with an authentication scheme that a hostile page cannot trigger (see Authenticating WebSockets with the Signed Cookie).
Tornado 6.6: trusted_origins and cross_origin_protection (upcoming)
The 6.6 alpha moves check_origin to the base RequestHandler and adds two application settings. They are not part
of 6.5.10 and the final 6.6.0 had not been released at the check date, so treat the following as upcoming:
| Item (6.6, alpha) | Behaviour |
|---|---|
|
A list of origins in the exact form of the |
|
When |
|
It is always called for WebSocket connections, and is no longer called when the request can be determined to be same-origin. |
# norun: needs Tornado 6.6, which is only an alpha at the check date
from tornado import web
from tornado.websocket import WebSocketHandler
class ReviewSocket(WebSocketHandler):
pass # the default check_origin consults the trusted_origins setting
app = web.Application(
[(r"/live", ReviewSocket)],
trusted_origins=["https://bookshelf.example"], # 6.6+
)
Until 6.6 is released, keep the explicit check_origin override shown above; it keeps working unchanged afterwards.
Keep-Alives, Timeouts and Size Limits
Idle WebSockets die silently: a phone enters a tunnel, a NAT forgets the mapping, a proxy drops idle streams after 60
seconds. Without traffic the server finds out only at the next write, or never. Tornado’s remedy is protocol-level
pings, configured through application settings (the same names exist as keyword arguments of websocket_connect
for the client, without the websocket_ prefix).
| Setting | Default | Meaning |
|---|---|---|
|
none (no pings) |
Seconds between pings the server sends. Floating-point values are allowed; |
|
equal to the interval |
Seconds to wait for the pong before closing the connection. Since Tornado 6.5.0 it cannot be configured longer than
the interval (the previous default was the larger of three pings or 30 seconds); |
|
10 MiB |
Largest accepted message. A larger one makes the server close the connection with code 1009 ("message too big"). The same limit applies after decompression. |
Pick an interval lower than the shortest idle timeout on the path (load balancers commonly use 60 seconds, so 20 to 30
seconds is typical). A ping that goes unanswered closes the connection and runs on_close, which is how dead peers are
cleaned out of rooms. The 10 MiB default is generous for a chat or notification channel; lower it so that one client
cannot make the process buffer a huge frame, since the limit is per message and is checked before the frame is
handed to on_message.
class PongCounter(WebSocketHandler):
pongs = 0
def on_pong(self, data: bytes) -> None:
type(self).pongs += 1
def on_message(self, message):
self.write_message(f"received {len(message)} characters")
async def settings_demo():
app = web.Application(
[(r"/ws", PongCounter)],
websocket_ping_interval=0.05, # seconds; use 20-30 in production
websocket_ping_timeout=0.05,
websocket_max_message_size=1024, # bytes; the default is 10 MiB
)
server, port = await serve(app)
conn = await websocket_connect(f"ws://127.0.0.1:{port}/ws")
await asyncio.sleep(0.3) # idle: only pings and pongs flow
print("pongs received:", PongCounter.pongs >= 2)
await conn.write_message("x" * 4000) # larger than the limit
print(await conn.read_message()) # None: the server closed it
print("close code:", conn.close_code) # 1009
server.stop()
asyncio.run(settings_demo())
The server can also ping on demand with WebSocketHandler.ping(data), but the documentation recommends the
websocket_ping_interval setting instead, because it also enforces the timeout. Application-level heartbeats (a
JSON {"type": "ping"} message) are still useful when the client is a browser: the JavaScript WebSocket API cannot
send or observe protocol pings, so it cannot detect a half-open connection by itself.
Subprotocols and Compression
Two hooks take part in the handshake and are useful as a protocol grows.
Subprotocols. The client may propose names in the Sec-WebSocket-Protocol header (new WebSocket(url, ["bookshelf.v2",
"bookshelf.v1"])). Override select_subprotocol(subprotocols) to return one of the proposed strings, or None
to select none; it is called exactly once, with an empty list when the client proposed nothing (since 5.1). Failing
to select does not abort the connection automatically, but a client that required a subprotocol will close it. The
chosen value is available as self.selected_subprotocol, which is how a versioned message format is negotiated
without extra round trips.
Compression. get_compression_options() returns None by default, which disables the permessage-deflate
extension. Returning a dict (even an empty one) enables it; the keys compression_level and mem_level (4.5)
tune the zlib compressor. Compression trades CPU and per-connection memory for bandwidth: it helps for repetitive
JSON, but with tens of thousands of connections the compressor state adds up, and mixing secret and attacker-controlled
data in the same compressed stream invites length-oracle attacks, so leave it off unless measurements justify it.
class VersionedSocket(WebSocketHandler):
SUPPORTED = ("bookshelf.v2", "bookshelf.v1")
def select_subprotocol(self, subprotocols):
for name in self.SUPPORTED: # server preference order
if name in subprotocols:
return name
return None
def get_compression_options(self):
return {"compression_level": 6, "mem_level": 5}
def open(self):
self.write_message({"protocol": self.selected_subprotocol})
async def negotiation_demo():
server, port = await serve(web.Application([(r"/v", VersionedSocket)]))
conn = await websocket_connect(
f"ws://127.0.0.1:{port}/v",
subprotocols=["bookshelf.v1", "bookshelf.v2"],
compression_options={}, # same meaning as on the server
)
print("selected:", conn.selected_subprotocol)
print(await conn.read_message())
conn.close()
server.stop()
asyncio.run(negotiation_demo())
Broadcasting to Rooms
Tornado does not ship a "room" or "channel" abstraction; it gives you one handler instance per connection and leaves
the registry to the application. For Bookshelf Live a room is one book: every reader who has the book open joins the
room book-<id>, and a new review is sent to the members of that room only. The registry is plain Python state in
the process, which is safe without locks because the whole application runs on one event loop thread and nothing
inside a callback is interrupted between await points (see
Queues & Locks for what does need locking).
WebSocketClosedError and is dropped B-->>R: Future resolved (flow control) C-->>R: Future resolved (flow control)
The implementation below keeps a dict from room name to the set of open handlers, joins in open, leaves in
on_close, and sends to all members concurrently with asyncio.gather. Sending one message at a time with await
would let one slow client delay everybody else behind it; gathering the futures lets the writes proceed in parallel.
The Future returned by write_message resolves when the data has been handed to the socket buffer, so awaiting it
is Tornado’s flow-control hook, and a client that never reads makes its own future wait without blocking the rest.
class Rooms:
"""Per-process registry: room name -> open WebSocket handlers."""
def __init__(self):
self._rooms: dict[str, set["ReviewRoomSocket"]] = {}
def join(self, room: str, member: "ReviewRoomSocket") -> None:
self._rooms.setdefault(room, set()).add(member)
def leave(self, room: str, member: "ReviewRoomSocket") -> None:
members = self._rooms.get(room)
if members is not None:
members.discard(member)
if not members:
del self._rooms[room] # do not leak empty rooms
def size(self, room: str) -> int:
return len(self._rooms.get(room, ()))
async def broadcast(self, room: str, payload: dict, skip=None) -> int:
text = json.dumps(payload)
targets = [m for m in self._rooms.get(room, ()) if m is not skip]
results = await asyncio.gather(
*(m.send(text) for m in targets), return_exceptions=True
)
return sum(1 for r in results if r is None)
class ReviewRoomSocket(WebSocketHandler):
def initialize(self, rooms: Rooms):
self.rooms = rooms # passed from the route's kwargs
self.room = None
def open(self, book_id: str):
self.room = f"book-{book_id}"
self.rooms.join(self.room, self)
async def on_message(self, message):
try:
data = json.loads(message)
text = str(data["text"])[:500] # bound what one client can send
except (ValueError, KeyError, TypeError):
return self.write_message({"type": "error"})
await self.rooms.broadcast(
self.room, {"type": "review", "text": text}, skip=self
)
def on_close(self):
if self.room is not None:
self.rooms.leave(self.room, self)
async def send(self, text: str) -> None:
try:
await self.write_message(text)
except WebSocketClosedError:
self.rooms.leave(self.room, self)
async def rooms_demo():
rooms = Rooms()
app = web.Application(
[(r"/ws/books/(\d+)/live", ReviewRoomSocket, dict(rooms=rooms))]
)
server, port = await serve(app)
url = f"ws://127.0.0.1:{port}/ws/books"
a = await websocket_connect(f"{url}/7/live")
b = await websocket_connect(f"{url}/7/live")
c = await websocket_connect(f"{url}/9/live") # another book
await asyncio.sleep(0.05)
print("members of book-7:", rooms.size("book-7"))
await a.write_message(json.dumps({"text": "Great read"}))
print("B got:", await b.read_message())
try:
await asyncio.wait_for(c.read_message(), 0.2)
except asyncio.TimeoutError:
print("C (other room) got nothing")
b.close()
await asyncio.sleep(0.1)
print("members after B left:", rooms.size("book-7"))
for conn in (a, c):
conn.close()
server.stop()
asyncio.run(rooms_demo())
Source: Tornado: WebSocketHandler
Design notes for real systems:
-
Skip the sender or not. Chat UIs usually echo the sender’s own message from the server so that ordering is consistent; review feeds often skip the sender and update the UI optimistically. Both are one-line decisions in
broadcast. -
Slow consumers. A client that stops reading makes its pending writes accumulate in memory. Cap what you queue per connection (a bounded
tornado.queues.Queuedrained by a writer coroutine works well), and close the connection with code 1013 when the cap is hit rather than let the process grow. -
Never mutate the set while iterating it. The example copies the members into a list before the first
await. -
Writes from threads.
write_messagemust run on the event loop thread. Code in a worker thread (aThreadPoolExecutorjob, a Kafka consumer thread) hands off withIOLoop.current().add_callback(...)captured on the loop thread beforehand. -
Authorization per room. Validate in
openthat the user may join the room; the room name comes from the URL and is attacker-controlled.
Authenticating WebSockets with the Signed Cookie
The handshake is an HTTP request, so everything that identifies a normal request is available while it is processed:
cookies, headers and the query string. Tornado’s signed cookies (set_signed_cookie and get_signed_cookie, which
need the cookie_secret application setting, and which were called set_secure_cookie / get_secure_cookie before
Tornado 6.3) are the usual session mechanism, and they work unchanged for the handshake. The pattern is:
-
A normal
POST /loginhandler validates credentials and sets the signed cookie (withHttpOnly,Securein production andSameSite). -
get_current_user(orprepare) reads the cookie for the handshake request. -
prepare()raisesHTTPError(401)when nobody is logged in. It runs before the upgrade, so the browser sees a failed handshake instead of an open socket. -
check_originis restricted so that a hostile page cannot ride the victim’s cookie (see Origin Checks and Cross-Site WebSocket Hijacking).
Do not use @web.authenticated on the handler methods: it redirects to the login URL, which a WebSocket client
cannot follow. Raising HTTPError(401) in prepare is explicit and testable. The secret comes from the
environment or a generator, never from source code.
class LoginHandler(web.RequestHandler):
def post(self):
name = self.get_body_argument("user")
# Credential check omitted: see the OAuth and authentication pages for real flows.
self.set_signed_cookie(
"user", name, httponly=True, samesite="Strict"
) # add secure=True behind HTTPS
self.write({"ok": True})
class PrivateSocket(WebSocketHandler):
def get_current_user(self):
raw = self.get_signed_cookie("user") # bytes or None; signature verified
return raw.decode() if raw else None
def check_origin(self, origin: str) -> bool:
return origin in {"https://bookshelf.example"}
def prepare(self):
if self.current_user is None:
raise web.HTTPError(401, reason="Login required")
def open(self):
self.write_message({"type": "welcome", "user": self.current_user})
def make_private_app() -> web.Application:
return web.Application(
[(r"/login", LoginHandler), (r"/private", PrivateSocket)],
cookie_secret=secrets.token_hex(32), # in production: read it from the environment
)
async def auth_demo():
from tornado.httpclient import AsyncHTTPClient
server, port = await serve(make_private_app())
origin = {"Origin": "https://bookshelf.example"}
url = f"ws://127.0.0.1:{port}/private"
# Anonymous handshake: rejected before the upgrade.
try:
await websocket_connect(HTTPRequest(url, headers=origin))
except HTTPClientError as exc:
print("anonymous:", exc.code)
# Log in over HTTP, then reuse the cookie on the handshake.
client = AsyncHTTPClient()
response = await client.fetch(
f"http://127.0.0.1:{port}/login", method="POST", body="user=alice"
)
cookie = response.headers["Set-Cookie"].split(";")[0]
conn = await websocket_connect(
HTTPRequest(url, headers={**origin, "Cookie": cookie})
)
print(await conn.read_message())
conn.close()
server.stop()
asyncio.run(auth_demo())
Alternatives, and their trade-offs:
-
Token in the first message. The browser
WebSocketAPI cannot set custom headers, so token-based APIs often accept the token as the first message, or in theSec-WebSocket-Protocolheader, and close with code 1008 if it is not valid within a few seconds. Never put long-lived tokens in the query string: URLs end up in proxy and access logs. -
Short-lived ticket. An authenticated
POSTreturns a single-use ticket, valid for a few seconds, which the page passes in the WebSocket URL; the server consumes it inprepare. It avoids exposing the session and works across origins. -
Cookies cross subdomains and ports depending on the
Domainattribute and the browser rules;SameSitedoes not stop CSWSH from a sibling subdomain, so the origin check remains mandatory. -
Re-check authorization when a long-lived socket outlives a login: close it with code 1008 when the session expires, for example from a periodic callback.
The full cookie and login story is on the Authentication & Signed Cookies page, and for OAuth-based logins (which also end in a session cookie like the one read here) see Third-party Authentication and OAuth.
The Client: websocket_connect
websocket_connect(url, ...) returns a Future whose result is a WebSocketClientConnection. The URL may be a
string or an HTTPRequest (to send headers such as Cookie, Origin or Authorization). Useful parameters are
connect_timeout, ping_interval, ping_timeout, max_message_size, subprotocols and compression_options. The
connection supports two styles:
-
Pull style: call
await conn.read_message()in a loop. It returns the nextstrorbytes, andNoneonce the connection is closed, which is the loop’s exit condition. -
Push style: pass
on_message_callback, which is called with every message and withNoneon close. Thenread_messagenever returns messages. The callback must not block.
The old callback= argument of websocket_connect is deprecated since 6.5 and will be removed in Tornado 7.0; use the
returned future. on_message_callback is not deprecated. Tornado clients are handy for integration tests, load
tests, and service-to-service streams (for example a worker that listens to another service’s event socket).
async def client_demo():
server, port = await serve(web.Application([(r"/echo", EchoSocket)]))
url = f"ws://127.0.0.1:{port}/echo"
# Pull style: a read loop that ends when the connection closes.
conn = await websocket_connect(url, connect_timeout=5)
received = []
await conn.write_message("one")
await conn.write_message("two")
while len(received) < 3: # hello + two replies
message = await conn.read_message()
if message is None:
break
received.append(message)
print(received)
conn.close()
# Push style: a callback receives every message, then None on close.
seen = []
done = asyncio.Event()
def on_message(message):
if message is None:
done.set()
else:
seen.append(message)
pushed = await websocket_connect(url, on_message_callback=on_message)
await pushed.write_message("three")
await asyncio.sleep(0.1)
pushed.close()
await asyncio.wait_for(done.wait(), 2)
print(seen)
server.stop()
asyncio.run(client_demo())
A production client reconnects: wrap the connect and read loop in a while True, catch OSError,
HTTPClientError and WebSocketClosedError, and wait with exponential back-off and jitter before trying again, so
that a restart of the server does not produce a stampede of simultaneous reconnections.
Scaling Across Processes with Redis Pub/Sub
The room registry above lives in one Python process. A single process serves many thousands of mostly idle sockets, but production deployments run one process per core (see Applications & Request Handlers for how the HTTP side is started) and often several machines behind a load balancer. A reader connected to process 1 must still see the review posted through process 2. Handlers cannot call each other across processes, so the processes exchange messages through a shared broker, and each process delivers to its own local members.
(Reader A)"] H1["Hub 1
local rooms"] end subgraph P2["Process 2"] S2["ReviewRoomSocket
(Reader B)"] H2["Hub 2
local rooms"] end BROKER[("Redis pub/sub
channel room:book-7")] S1 -- "on_message" --> H1 H1 -- "PUBLISH" --> BROKER BROKER -- "message" --> H1 BROKER -- "message" --> H2 H1 -- "write_message" --> S1 H2 -- "write_message" --> S2
Source: Redis: Pub/Sub
The design rules are:
-
A handler never delivers locally on its own. It only publishes; the process’s subscriber task receives the message back (including its own) and delivers to local members. One path means no duplicates and no divergence between a single-process and a multi-process deployment.
-
One long-running subscriber coroutine per process starts when the application starts (in a
main()coroutine beforeasyncio.Event().wait(), see Getting Started). -
Redis pub/sub is fire-and-forget: a process that is down or disconnected misses messages, and nothing is stored. Clients re-sync after reconnecting by fetching missed data over HTTP (the database is the source of truth). Streams or a message broker (see Messaging) add durability when it is needed.
-
Sticky sessions are not required for correctness: a WebSocket stays on one connection once upgraded, and any process can serve any room because delivery goes through the broker.
The client library for Redis in Python is redis-py (import redis.asyncio as redis), whose asyncio API integrates
directly with Tornado’s event loop. The page cannot run a Redis server, so the next example codes the hub against a
tiny interface (publish and an async iterator of messages) and runs it with an in-memory fake that behaves like
a broker. The real adapter, shown afterwards, is a few lines. For Redis itself see Redis.
class FakeBroker:
"""In-memory stand-in for Redis pub/sub, shared by the 'processes' below."""
def __init__(self):
self._queues: list[asyncio.Queue] = []
async def publish(self, channel: str, data: str) -> None:
for queue in list(self._queues):
queue.put_nowait((channel, data))
async def messages(self):
queue: asyncio.Queue = asyncio.Queue()
self._queues.append(queue)
try:
while True:
yield await queue.get()
finally:
self._queues.remove(queue)
class Hub(Rooms):
"""A process-local Rooms registry fed by a broker subscription."""
def __init__(self, broker):
super().__init__()
self.broker = broker
self._task = None
async def publish(self, room: str, payload: dict) -> None:
await self.broker.publish(f"room:{room}", json.dumps(payload))
def start(self) -> None:
self._task = asyncio.create_task(self._listen()) # keep the reference
async def _listen(self) -> None:
async for channel, data in self.broker.messages():
room = channel.removeprefix("room:")
await self.broadcast(room, json.loads(data))
def stop(self) -> None:
if self._task:
self._task.cancel()
class ClusterSocket(ReviewRoomSocket):
def initialize(self, rooms: Hub):
super().initialize(rooms)
async def on_message(self, message):
try:
text = str(json.loads(message)["text"])[:500]
except (ValueError, KeyError, TypeError):
return self.write_message({"type": "error"})
await self.rooms.publish(self.room, {"type": "review", "text": text})
async def cluster_demo():
broker = FakeBroker()
hubs, servers, ports = [], [], []
for _ in range(2): # two "worker processes"
hub = Hub(broker)
hub.start()
server, port = await serve(
web.Application([(r"/ws/books/(\d+)/live", ClusterSocket, dict(rooms=hub))])
)
hubs.append(hub)
servers.append(server)
ports.append(port)
await asyncio.sleep(0.05)
a = await websocket_connect(f"ws://127.0.0.1:{ports[0]}/ws/books/7/live")
b = await websocket_connect(f"ws://127.0.0.1:{ports[1]}/ws/books/7/live")
await asyncio.sleep(0.05)
await a.write_message(json.dumps({"text": "Seen on another process"}))
print("B (other process) got:", await asyncio.wait_for(b.read_message(), 2))
print("A (sender) got:", await asyncio.wait_for(a.read_message(), 2))
for conn in (a, b):
conn.close()
for hub in hubs:
hub.stop()
for server in servers:
server.stop()
asyncio.run(cluster_demo())
Source: Tornado: tornado.websocket
The Redis adapter matches the same two operations. pubsub.listen() yields dicts with a type key; only the
message entries carry data, and the subscription uses the pattern form so one subscription covers every room.
# norun: needs a running Redis server and the redis package
import os
import redis.asyncio as redis
class RedisBroker:
def __init__(self, url: str | None = None):
self._redis = redis.from_url(url or os.environ["REDIS_URL"], decode_responses=True)
async def publish(self, channel: str, data: str) -> None:
await self._redis.publish(channel, data)
async def messages(self):
pubsub = self._redis.pubsub()
await pubsub.psubscribe("room:*")
try:
async for item in pubsub.listen():
if item["type"] == "pmessage":
yield item["channel"], item["data"]
finally:
await pubsub.aclose()
Source: redis-py: Asyncio examples
Other options are mentioned for orientation only: a broker such as RabbitMQ or Kafka for durable fan-out, or
PostgreSQL LISTEN/NOTIFY for small deployments; the Tornado side stays the same because the hub depends only on
publish and messages.
Running Behind a Reverse Proxy
WebSockets pass through proxies only when the proxy forwards the upgrade headers and does not apply the usual HTTP
timeouts. For nginx this means HTTP/1.1, the Upgrade and Connection headers, and a longer read timeout than the
ping interval:
location /ws/ {
proxy_pass http://tornado_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 120s;
}
Source: nginx: WebSocket proxying
The Host header must reach Tornado unchanged for the default check_origin to see the right host (or the explicit
origin allow-list must not depend on it). Terminate TLS at the proxy and use wss:// in pages served over HTTPS:
browsers block mixed content. On shutdown, send close(1001, "going away") to every connection (the hub knows them
all) before stopping the server, so clients reconnect immediately instead of waiting for a timeout. Kubernetes and
Docker deployments follow the same rules; see Kubernetes and
Docker for the container side.
Testing WebSocket Handlers
AsyncHTTPTestCase starts the application on a loopback port; get_http_port() gives the port, and
websocket_connect talks to it. Decorate coroutine tests with @gen_test so they run on the test’s event loop with
a timeout (the ASYNC_TEST_TIMEOUT environment variable adjusts it). Test the unhappy paths: bad origin, anonymous
user, oversized message, malformed JSON.
import unittest
from tornado.testing import AsyncHTTPTestCase, gen_test
class LiveTest(AsyncHTTPTestCase):
def get_app(self):
self.rooms = Rooms()
return web.Application(
[
(r"/echo", EchoSocket),
(r"/live", ReviewSocket),
(r"/ws/books/(\d+)/live", ReviewRoomSocket, dict(rooms=self.rooms)),
]
)
def ws_url(self, path: str) -> str:
return f"ws://127.0.0.1:{self.get_http_port()}{path}"
@gen_test
async def test_echo(self):
conn = await websocket_connect(self.ws_url("/echo"))
await conn.read_message() # the hello message
await conn.write_message("ping")
self.assertEqual(await conn.read_message(), "You said: ping")
conn.close()
@gen_test
async def test_foreign_origin_is_rejected(self):
request = HTTPRequest(self.ws_url("/live"), headers={"Origin": "https://evil.example"})
with self.assertRaises(HTTPClientError) as ctx:
await websocket_connect(request)
self.assertEqual(ctx.exception.code, 403)
@gen_test
async def test_room_fan_out(self):
a = await websocket_connect(self.ws_url("/ws/books/1/live"))
b = await websocket_connect(self.ws_url("/ws/books/1/live"))
await a.write_message(json.dumps({"text": "hi"}))
reply = json.loads(await b.read_message())
self.assertEqual(reply, {"type": "review", "text": "hi"})
a.close()
b.close()
suite = unittest.defaultTestLoader.loadTestsFromTestCase(LiveTest)
result = unittest.TextTestRunner(verbosity=0).run(suite)
assert result.wasSuccessful()
Testing covers AsyncTestCase, ExpectLog, running under pytest and mocking
external services in general; the pattern above is the part specific to sockets. Tests that exercise the Redis hub
use the FakeBroker above, so they need no server.
Pitfalls
-
403 on connect. The
check_origindefault rejects anOriginwhose host differs fromHost; this includes a front end on another port (localhost:3000againstlocalhost:8888) and a proxy that rewritesHost. Fix the allow-list, do not blindly returnTrue. -
Returning
Truefromcheck_originwith cookie authentication is the CSWSH hole described above. -
Blocking the loop in a callback. A synchronous call in
on_messagestalls every connection, not just one. Await asynchronous clients; push unavoidable blocking work to a thread (see Coroutines & asyncio). -
Slow work in
on_messagealso delays the same connection’s later messages, because messages are processed in order. Start a task for long jobs and keep a reference to it, as in the example below. -
Forgetting
on_closecleanup. Handlers left in a room’s set are memory leaks, and writes to them raiseWebSocketClosedError. Always leave inon_closeand tolerate the error in broadcasts. -
Using
self.write,finishorredirect. They are not valid after the upgrade; usewrite_messageandclose. -
Writing from other threads. Not thread-safe. Hop onto the loop with
IOLoop.add_callback. -
Unbounded input. Lower
websocket_max_message_size, bound string lengths in handlers and rate-limit per connection; a text frame is parsed fully beforeon_messagesees it. -
Per-process state. Counters, presence lists and rooms kept in memory are per process; use the broker (or a database) once there is more than one process.
-
Idle connections dying. Set
websocket_ping_intervalbelow every proxy’s idle timeout. -
Secrets in the URL. Tokens in query strings leak into logs; prefer cookies, headers on non-browser clients or a first message.
-
Debug mode.
debug=True(autoreload) restarts the process on every code change and drops every socket; never use it in production.
class SlowJobSocket(WebSocketHandler):
_tasks: set[asyncio.Task] = set() # the loop keeps only weak references to tasks
async def on_message(self, message):
if message == "slow":
task = asyncio.create_task(self._slow_job())
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
await self.write_message("started")
else:
await self.write_message("fast reply")
async def _slow_job(self):
await asyncio.sleep(0.2)
try:
await self.write_message("slow done")
except WebSocketClosedError:
pass # the client left meanwhile
async def pitfall_demo():
server, port = await serve(web.Application([(r"/slow", SlowJobSocket)]))
conn = await websocket_connect(f"ws://127.0.0.1:{port}/slow")
await conn.write_message("slow")
await conn.write_message("hello")
for _ in range(3):
print(await conn.read_message()) # started, fast reply, slow done
conn.close()
server.stop()
asyncio.run(pitfall_demo())
Source: Python: asyncio.create_task
Security Checklist
-
Restrict
check_originto the exact origins of your front ends (or totrusted_originsonce 6.6 is released). -
Authenticate in
prepare(cookie, ticket or token) and authorize per room inopen. -
Use
wss://only; terminate TLS at a proxy or inHTTPServer(ssl_options=...). -
Cap message size and rate; validate every JSON field; never put user text into HTML on the client without escaping (messages are an XSS vector like any other input).
-
Do not log full messages or tokens. Do not reflect internal exception text to clients.
-
Keep
cookie_secretout of source control and rotate it with the key-dictionary form of the setting when needed. -
Consider the OWASP WebSocket Security Cheat Sheet for the broader list (rate limits, message validation, handshake hardening).
Comparison with Other Frameworks
| Framework | WebSockets |
|---|---|
Tornado |
Built in ( |
Flask |
Not part of the WSGI request model. Add |
FastAPI / Starlette |
|
Django |
Django Channels (ASGI consumers and channel layers); see Django. |
Tornado’s registry-in-memory plus broker approach is the same idea that Channels' channel layers and Flask-SocketIO’s message queue implement; the difference is that in Tornado you write those few lines yourself.
Recent Changes and Version Notes
| Version | Change |
|---|---|
Tornado 3.2 |
|
Tornado 4.0 |
|
Tornado 4.1 |
Compression ( |
Tornado 4.5 |
|
Tornado 5.0 |
Runs on the asyncio loop; |
Tornado 5.1 |
|
Tornado 6.3 |
Client |
Tornado 6.5.0 |
The ping timeout defaults to the ping interval and can no longer exceed it. The client’s |
Tornado 6.6 (alpha only) |
Upcoming, not released: |
Source: Tornado: Release notes
Related Reading
-
Asynchronous I/O & the Event Loop explains the loop that all sockets share.
-
Queues & Locks provides the bounded queues used for slow consumers.
-
Error Handling covers
HTTPErrorraised inprepare. -
Streaming, Long Polling & SSE covers simpler one-way alternatives, Testing the general testing conventions, XSRF & Cross-Origin Protection the CSRF and cross-origin side, and Running & Deploying production deployment.
-
The chat, presence and delivery-guarantee design that sits above this wiring is the subject of issue #264 and is intentionally not covered.