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

WebSocketHandler

One instance per connection. open() runs after the handshake, on_message() for every message, on_close() once when the connection ends.

write_message(message, binary=False)

Sends a message. A str goes out as a text frame, bytes with binary=True as a binary frame, and a dict is encoded as JSON for you. Returns a Future that can be awaited for flow control and raises WebSocketClosedError if the connection is already closed.

close(code=None, reason=None)

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.

ping(data=b""), on_ping, on_pong

Control frames. ping sends one (up to 125 bytes of payload); Tornado answers the peer’s pings automatically.

check_origin(origin)

The browser-origin check performed during the handshake (see Origin Checks and Cross-Site WebSocket Hijacking).

select_subprotocol, get_compression_options

Handshake negotiation hooks (see Subprotocols and Compression).

Application settings websocket_ping_interval, websocket_ping_timeout, websocket_max_message_size

Keep-alive and size limits (see Keep-Alives, Timeouts and Size Limits).

websocket_connect(url, ...)

Client: returns a Future of a WebSocketClientConnection with read_message, write_message, close, ping.

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())

Points that surprise newcomers:

  • open and on_message may be coroutines (since Tornado 5.1 and 4.5). on_message is not called until open has returned, and, because the protocol loop waits for the coroutine, a connection processes its messages one at a time and in order. A slow on_message therefore delays the next message of the same connection, not other connections (see Pitfalls).

  • on_close runs exactly once, whether the peer closed cleanly, the network dropped, or the server called close. 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_message on a closed connection raises WebSocketClosedError (since 5.0 consistently; before that it could surface as an AttributeError or a stream error). Code that writes from outside the callbacks, such as a broadcast loop, must expect it.

  • A WebSocket handler is a normal RequestHandler for routing: it can take initialize(...) keyword arguments from the route’s third element, and prepare() runs before the handshake, which is the place to reject the connection with an HTTPError (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 Origin header, 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 Host header; otherwise the handshake answers 403. 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_origin to allow other origins. Returning True for 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

trusted_origins

A list of origins in the exact form of the Origin header (https://example.com, no trailing slash). The default check_origin accepts any origin in the list, for WebSockets and for cross_origin_protection.

cross_origin_protection

When True, the same check_origin is also applied to state-changing non-WebSocket requests (POST, PUT, DELETE…​), an easier alternative to xsrf_cookies. Expected to become the default in Tornado 6.7.

check_origin on RequestHandler

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

websocket_ping_interval

none (no pings)

Seconds between pings the server sends. Floating-point values are allowed; 0 disables pings.

websocket_ping_timeout

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); 0 disables the timeout.

websocket_max_message_size

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).

sequenceDiagram autonumber participant A as Reader A (browser) participant H as ReviewSocket (A) participant R as Room book-7 participant B as ReviewSocket (B) participant C as ReviewSocket (C) A->>H: review message as JSON H->>H: validate and parse JSON H->>R: broadcast(payload, skip=H) par concurrent writes R->>B: write_message(payload) and R->>C: write_message(payload) end Note over R: a closed member raises
WebSocketClosedError and is dropped B-->>R: Future resolved (flow control) C-->>R: Future resolved (flow control)
Figure 1. Fan-out of one chat or review message to the other members of a room

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())

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.Queue drained 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_message must run on the event loop thread. Code in a worker thread (a ThreadPoolExecutor job, a Kafka consumer thread) hands off with IOLoop.current().add_callback(...) captured on the loop thread beforehand.

  • Authorization per room. Validate in open that 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:

  1. A normal POST /login handler validates credentials and sets the signed cookie (with HttpOnly, Secure in production and SameSite).

  2. get_current_user (or prepare) reads the cookie for the handshake request.

  3. prepare() raises HTTPError(401) when nobody is logged in. It runs before the upgrade, so the browser sees a failed handshake instead of an open socket.

  4. check_origin is 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 WebSocket API cannot set custom headers, so token-based APIs often accept the token as the first message, or in the Sec-WebSocket-Protocol header, 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 POST returns a single-use ticket, valid for a few seconds, which the page passes in the WebSocket URL; the server consumes it in prepare. It avoids exposing the session and works across origins.

  • Cookies cross subdomains and ports depending on the Domain attribute and the browser rules; SameSite does 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 next str or bytes, and None once the connection is closed, which is the loop’s exit condition.

  • Push style: pass on_message_callback, which is called with every message and with None on close. Then read_message never 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.

flowchart LR subgraph P1["Process 1"] S1["ReviewRoomSocket
(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
Figure 2. Cross-process fan-out: publish once, every process delivers to its own sockets

Source: Redis: Pub/Sub

The design rules are:

  1. 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.

  2. One long-running subscriber coroutine per process starts when the application starts (in a main() coroutine before asyncio.Event().wait(), see Getting Started).

  3. 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.

  4. 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())

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()

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;
}

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_origin default rejects an Origin whose host differs from Host; this includes a front end on another port (localhost:3000 against localhost:8888) and a proxy that rewrites Host. Fix the allow-list, do not blindly return True.

  • Returning True from check_origin with cookie authentication is the CSWSH hole described above.

  • Blocking the loop in a callback. A synchronous call in on_message stalls every connection, not just one. Await asynchronous clients; push unavoidable blocking work to a thread (see Coroutines & asyncio).

  • Slow work in on_message also 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_close cleanup. Handlers left in a room’s set are memory leaks, and writes to them raise WebSocketClosedError. Always leave in on_close and tolerate the error in broadcasts.

  • Using self.write, finish or redirect. They are not valid after the upgrade; use write_message and close.

  • 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 before on_message sees 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_interval below 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())

Security Checklist

  • Restrict check_origin to the exact origins of your front ends (or to trusted_origins once 6.6 is released).

  • Authenticate in prepare (cookie, ticket or token) and authorize per room in open.

  • Use wss:// only; terminate TLS at a proxy or in HTTPServer(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_secret out 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 (WebSocketHandler), same event loop and routing as HTTP; one process, many idle connections.

Flask

Not part of the WSGI request model. Add flask-sock or Flask-SocketIO (with gevent or eventlet workers), or switch to Quart; see the Flask section’s Streaming & Server-Sent Events page.

FastAPI / Starlette

@app.websocket endpoints in an ASGI server; see Python FastAPI.

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

WebSocketClosedError added.

Tornado 4.0

check_origin introduced; cross-origin WebSockets are rejected by default. close accepts a code and reason and close_code / close_reason appear.

Tornado 4.1

Compression (get_compression_options) and the client’s on_message_callback.

Tornado 4.5

websocket_ping_interval, websocket_ping_timeout, websocket_max_message_size; on_message may be a coroutine.

Tornado 5.0

Runs on the asyncio loop; write_message consistently raises WebSocketClosedError.

Tornado 5.1

open may be a coroutine; subprotocol selection (select_subprotocol, selected_subprotocol, client subprotocols).

Tornado 6.3

Client resolver argument; get_secure_cookie renamed get_signed_cookie (old name kept as an alias).

Tornado 6.5.0

The ping timeout defaults to the ping interval and can no longer exceed it. The client’s callback argument is deprecated for removal in 7.0.

Tornado 6.6 (alpha only)

Upcoming, not released: trusted_origins and cross_origin_protection settings, check_origin moved to RequestHandler and skipped for same-origin requests, correct handling of asyncio.CancelledError in the WebSocket loop, Python 3.11 minimum.

Related Reading