python-socketio

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

This page is written against python-socketio 5.17.0 (released 2026-09-14) and python-engineio 4.14.0, which speak Socket.IO protocol v5 over Engine.IO protocol v4, and was checked on 2026-10-24. The examples use Uvicorn 0.54.0, FastAPI 0.162.0, redis-py 8.1.0, Redis 8.8.3 and, for the browser-side half, socket.io-client 4.8.4. They need Python 3.10 or newer (the code uses the str | None syntax) and were run on Python 3.13 in Docker containers, with two server instances behind one Redis and a Node.js 24 test client. The package metadata still says Python 3.8 or newer, but the project dropped Python 3.8 and 3.9 from its test matrix in 5.16.0, so use a newer interpreter.

python-socketio is the Python implementation of the Socket.IO server and client by Miguel Grinberg, who also wrote python-engineio (the Engine.IO transport layer underneath). It is the Python counterpart of the Node.js server on Socket.IO (Node.js). What Socket.IO adds to a WebSocket, and the wire protocol that makes a plain WebSocket client unable to talk to it, are explained on Socket.IO Protocol and Clients; this page covers the Python side and builds the Chatterbox chat server on it.

When to choose it

Choose python-socketio when the clients are already Socket.IO clients (a JavaScript front end, a mobile SDK) or when you want its conveniences without writing them yourself: named events with acknowledgements, rooms, namespaces, automatic reconnection on the client, and a long-polling fallback. For raw WebSockets with a hand-made protocol in Python, the framework pages are a better start: FastAPI WebSockets (Starlette WebSocket endpoints) and Tornado WebSockets. The decision table across all stacks is on Choosing a WebSocket Stack.

Of the three scenarios of this section, it fits Chatterbox well (rooms, presence, acknowledgements, a Python back end that already exists) and Huddle as a signaling channel for offers, answers and ICE candidates (see Signaling and Perfect Negotiation). It is a poor fit for Pixel Arena: a game loop that broadcasts state at 20 to 60 Hz is dominated by per-message overhead, Python’s event loop and the Socket.IO packet layer, which is the reason the arena server uses uWebSockets.js.

Compatibility

python-socketio 5.x pairs with python-engineio 4.x and interoperates with the JavaScript 3.x and 4.x client and server; the 4.x line of python-socketio is for JavaScript 1.x and 2.x, and Socket.IO 0.9 is not supported.

Table 1. Version compatibility (from the python-socketio documentation)
JavaScript Socket.IO Socket.IO protocol Engine.IO protocol python-socketio python-engineio

0.9.x

1, 2

1, 2

not supported

not supported

1.x and 2.x

3, 4

3

4.x

3.x

3.x and 4.x

5

4

5.x

4.x

Install the library and the pieces for the server flavour you use. The extras client and asyncio_client are only needed for the Python clients; the servers need nothing else beyond a web server and, with several processes, a client manager package.

# requirements.txt
python-socketio==5.17.0
python-engineio==4.14.0
uvicorn==0.54.0
websockets==17.3
fastapi==0.162.0
redis==8.1.0

Uvicorn needs a WebSocket implementation (websockets here, or wsproto) to serve the WebSocket transport; without one, clients stay on long polling or fail to upgrade. A synchronous server in threading mode needs simple-websocket for the same reason.

Server classes and async modes

There are two servers with the same API: socketio.Server for threads and greenlets, and socketio.AsyncServer for asyncio, where most methods are coroutines. Each runs on a web server selected with async_mode.

Table 2. async_mode values
Server async_mode Notes

Server

threading

The default when no other concurrency library is installed. Runs on multi-threaded WSGI servers such as Gunicorn with threads.

Server

gevent, gevent_uwsgi

Chosen automatically when gevent is installed. Greenlets, so blocking code must be cooperative or monkey-patched.

Server

eventlet

Chosen automatically when eventlet is installed, but the documentation does not recommend it for new projects because eventlet is in maintenance mode.

AsyncServer

asgi

Any ASGI server: Uvicorn, Hypercorn, Daphne, or Gunicorn with the Uvicorn worker.

AsyncServer

aiohttp, tornado

Attach to an aiohttp application or route /socket.io/ to a Tornado handler. sanic also exists but the documentation recommends asgi instead.

The constructor options that matter for real-time work, with the defaults of 5.17.0:

Table 3. AsyncServer options
Option Default Meaning

cors_allowed_origins

same origin only

An origin or list of origins. A foreign Origin header is refused; '*' allows everyone and the documentation warns about cross-site request forgery. Requests with no Origin header (non-browser clients) are not restricted.

transports

polling and websocket

["websocket"] disables long polling. It is required when several worker processes serve the same clients, because polling requests of one session must reach the same process.

ping_interval, ping_timeout

25 s, 20 s

Engine.IO heartbeat: the server pings every interval, and the connection is dropped when the answer takes longer than the timeout.

max_http_buffer_size

1,000,000 bytes

Largest accepted incoming message, for polling and WebSocket alike.

async_handlers

True

Run each event handler of a client as a separate task. False handles a client’s events one at a time, in arrival order.

always_connect

False

When True the connection is accepted at once and a connect handler that returns False triggers a disconnect; by default the connection stays provisional until the handler returns.

namespaces

only / and those with handlers

Extra namespaces to accept, or '*' for all.

client_manager

in-process manager

A message-queue manager for several processes (see below).

A first server: events, rooms, sessions, auth and namespaces

The smallest useful server shows every building block. Handlers are registered with @sio.event (the function name is the event name) or @sio.on("name"). The return value of a handler becomes the acknowledgement the client’s callback receives. connect(sid, environ, auth) receives the auth object the client passed to io(), and raising ConnectionRefusedError rejects the connection and sends the error data to the client as connect_error. The session holds per-connection data on the server and disappears on disconnect. A class-based namespace groups the handlers of one namespace as on_* methods; the instance is a singleton shared by all clients, so per-client data must not be stored on it.

import socketio

sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins=["https://chat.example.test"])
app = socketio.ASGIApp(sio)


@sio.event
async def connect(sid, environ, auth):
    if not auth or not auth.get("name"):
        raise socketio.exceptions.ConnectionRefusedError("name required")  # the client gets this as connect_error
    async with sio.session(sid) as session:
        session["name"] = auth["name"]


@sio.event
async def join(sid, room):
    await sio.enter_room(sid, room)
    return {"rooms": sio.rooms(sid)}  # the return value is the acknowledgement


@sio.event
async def say(sid, data):
    async with sio.session(sid) as session:
        name = session["name"]
    await sio.emit("said", {"name": name, "text": data["text"]}, room=data["room"], skip_sid=sid)
    answer = await sio.call("whoami", to=sid, timeout=5)  # server-to-client request/response
    return {"delivered": True, "you_are": answer}


@sio.event
async def disconnect(sid, reason):
    print("bye", sid, reason)


class AdminNamespace(socketio.AsyncNamespace):
    def __init__(self, namespace):
        super().__init__(namespace)
        self.admins = set()  # the instance is a singleton shared by every client: keep only aggregates here

    async def on_connect(self, sid, environ, auth):
        if not auth or auth.get("key") != "change-me":
            raise socketio.exceptions.ConnectionRefusedError("forbidden")
        self.admins.add(sid)

    async def on_disconnect(self, sid, reason):
        self.admins.discard(sid)

    async def on_stats(self, sid):
        return {"admins": len(self.admins)}


sio.register_namespace(AdminNamespace("/admin"))

The Python client mirrors the API. It needs the asyncio_client extra (python-socketio[asyncio_client]), and call() emits an event and waits for the acknowledgement. This script connects two clients and exercises a room broadcast, a server-to-client request (sio.call in the say handler), the admin namespace (connected with the same auth object) and a refused connection.

import asyncio

import socketio


async def main():
    alice, bob = socketio.AsyncClient(), socketio.AsyncClient()
    heard = asyncio.Queue()

    @bob.on("said")
    async def said(data):
        await heard.put(data)

    @alice.on("whoami")
    async def whoami():
        return "alice"

    await alice.connect("http://127.0.0.1:8000", auth={"name": "alice", "key": "change-me"},
                        namespaces=["/", "/admin"], transports=["websocket"], wait_timeout=5)
    await bob.connect("http://127.0.0.1:8000", auth={"name": "bob"}, transports=["websocket"], wait_timeout=5)
    print("join ack:", await alice.call("join", "lobby"), await bob.call("join", "lobby"))
    print("say ack:", await alice.call("say", {"room": "lobby", "text": "hi"}))
    print("bob heard:", await asyncio.wait_for(heard.get(), 2))
    print("admin stats:", await alice.call("stats", namespace="/admin"))
    refused = socketio.AsyncClient()

    @refused.event
    async def connect_error(data):
        print("connect_error:", data)

    try:
        await refused.connect("http://127.0.0.1:8000", transports=["websocket"], wait_timeout=5)
    except socketio.exceptions.ConnectionError as exc:
        print("refused:", exc)
    await alice.disconnect()
    await bob.disconnect()


asyncio.run(main())

Running the server with uvicorn echo_server:app and the client in the same container printed:

join ack: {'rooms': ['ZSGSknN9rwbq6OzpAAAB', 'lobby']} {'rooms': ['5ssbZs7iSrRobBuMAAAE', 'lobby']}
say ack: {'delivered': True, 'you_are': 'alice'}
bob heard: {'name': 'alice', 'text': 'hi'}
admin stats: {'admins': 1}
connect_error: {'message': 'name required'}
refused: One or more namespaces failed to connect: /

Points this run makes visible. Every connection is automatically in a room named after its own sid, which is how to=sid works, so rooms(sid) lists the private room first. A catch-all handler @sio.on("*") does not receive connect or disconnect; those are always defined explicitly. The disconnect handler receives a reason (one of the constants in sio.reason, such as CLIENT_DISCONNECT or SERVER_DISCONNECT) since 5.12.0.

ASGI: Uvicorn and FastAPI

socketio.ASGIApp(sio, other_asgi_app=None, static_files=None, socketio_path="socket.io", on_startup=None, on_shutdown=None) is the ASGI application. It serves /socket.io/ itself and forwards every other path to other_asgi_app. Run it with uvicorn module:asgi. Static files are for development only.

There are two ways to combine it with FastAPI, and they behave differently in one important way.

  • Wrap FastAPI (used by the Chatterbox server below): asgi = socketio.ASGIApp(sio, other_asgi_app=api). Requests to /socket.io/ go to Socket.IO and everything else to FastAPI. The ASGI lifespan messages are handled by the Socket.IO wrapper and are not forwarded: the FastAPI lifespan context manager never runs, so startup and shutdown work belongs in on_startup and on_shutdown. Running Uvicorn with a FastAPI app that had a lifespan, wrapped this way, printed only the on_startup and on_shutdown messages.

  • Mount in FastAPI: api.mount("/ws", socketio.ASGIApp(sio, socketio_path="ws/socket.io")). The FastAPI lifespan runs normally. The mounted application sees the full request path, so socketio_path must include the mount prefix (ws/socket.io), and the client must connect with its path option set to /ws/socket.io. With the default socketio_path the same mount answered 404; with the prefix included it answered 200.

For everything FastAPI-specific (dependencies, authentication, deployment) use the FastAPI section, in particular WebSockets and Deployment.

WSGI: Flask, threads and greenlets

socketio.WSGIApp(sio, wsgi_app, static_files, socketio_path) does the same job for the synchronous Server. With Flask the application is passed as the second argument. The handlers are plain functions, and emit is synchronous.

import socketio
from flask import Flask, jsonify

sio = socketio.Server(async_mode="threading")
flask_app = Flask(__name__)


@flask_app.get("/healthz")
def healthz():
    return jsonify(ok=True)


@sio.event
def echo(sid, data):
    sio.emit("echoed", data, to=sid)  # synchronous API: no await
    return {"len": len(str(data))}


app = socketio.WSGIApp(sio, flask_app)
import socketio

sio = socketio.Client()
sio.on("echoed", lambda data: print("echoed:", data))
sio.connect("http://127.0.0.1:8000")
print("ack:", sio.call("echo", {"n": 1}))
sio.sleep(1)
print("transport:", sio.transport())
sio.disconnect()

This ran under gunicorn --workers 1 --threads 100 --bind 127.0.0.1:8000 wsgi_server:app with simple-websocket installed, and the client reported transport: websocket: the Engine.IO upgrade from polling succeeded in the Gunicorn threaded worker. One worker with many threads is the documented layout; the capacity is roughly workers times threads, and with more than one worker you need a message queue, transports=["websocket"] and no long polling. gevent and eventlet servers use greenlets instead of threads: the documentation says monkey patching is optional for gevent and describes eventlet as in maintenance mode, so prefer gevent or the asyncio server for new work. Flask itself (and what async views, gevent and Quart change) is on Async, gevent and Quart and Deployment, WSGI Servers and Proxies.

One interoperability detail seen while testing: the Python client sends an Origin header derived from the server URL, so a server configured with an explicit cors_allowed_origins list refuses a Python client unless its own URL is on the list (the server logged http://127.0.0.1:8000 is not an accepted origin). The Node.js client sends no Origin and is accepted.

Several processes: client managers

Rooms live in the memory of the process that holds the sockets. To broadcast to a room whose members are spread over several processes or machines, give every process the same client manager. The manager publishes each emit to a message queue and every process delivers it to its own sockets.

Table 4. Client managers
Manager Install Notes

AsyncRedisManager(url), RedisManager(url)

pip install redis

The AsyncServer and the Server flavours. The URL accepts rediss:// for TLS and redis+sentinel://; 5.14.0 added Valkey support. redis_options is passed to the Redis client.

AsyncAioPikaManager(url)

pip install aio_pika

RabbitMQ, asynchronous servers only.

KombuManager(url)

pip install kombu

Many brokers through Kombu, synchronous servers only.

KafkaManager(url)

pip install kafka-python

Kafka, synchronous servers only.

Every manager takes a channel (default socketio, the Redis channel or the RabbitMQ exchange; it must be the same for all servers) and write_only. A write_only=True manager only publishes. It is the right choice for processes that do not hold client connections, such as a Celery worker, a cron job or a command-line tool, which can then emit into rooms with a plain server object; the price is that they cannot receive acknowledgement callbacks.

Two Uvicorn processes each with an ASGIApp, a Socket.IO AsyncServer and a FastAPI app share one Redis that carries the pub/sub channel and the Chatterbox state, and a write-only announcer process publishes into it

Facts that matter in production: since 5.14.0 the queue messages are JSON (earlier releases used pickle, and loading pickle from a channel that others can write to is unsafe); because the encoding changed, do not mix servers from before and after 5.14.0 on one queue during a rolling upgrade. From 5.15.0 the initial Redis connection is retried, and from 5.16.3 the Redis and RabbitMQ managers catch every exception in their listener and keep going. The documentation asks that the queue is kept off public interfaces and protected with authentication and encryption. Long polling with several processes additionally needs sticky sessions at the load balancer, so the Chatterbox example disables polling. The same topology in Node.js is described on Socket.IO (Node.js), and the general problem on Scaling WebSockets.

Chatterbox on python-socketio

The Chatterbox protocol is defined on Message Design and Serialization: a JSON envelope with v, type, id, room, ts and payload, twelve message types, a per-room seq, acknowledgements and errors with fixed codes, idempotent ids and close codes. The server below implements hello, join, leave, message, history, presence, typing, ack and error, one-time tickets, idempotency, resume with replay, rate limiting and the size limit. edit, react and receipt follow the same shape (a handler, one store function, a broadcast) and are answered with bad_request here, to keep the page readable.

Socket.IO is not a WebSocket subprotocol, so the mapping needs a few decisions. They are the main thing to carry over to any other Socket.IO back end.

Table 5. How the protocol maps onto Socket.IO
Chatterbox On Socket.IO

One envelope per frame

One Socket.IO event named chatterbox whose single argument is the envelope object, in both directions.

ack and error envelopes

Ordinary envelopes in the same event, not Socket.IO acknowledgement callbacks, so the contract and the test client are identical on every stack.

Subprotocol chatterbox.v1 and version negotiation

Not available; the v field carries the version and a wrong one gets unsupported_version.

Close codes (4401, 4408, 4426, 4429)

Socket.IO cannot send application close codes. The server emits an event chatterbox.close with the code and reason, then disconnects; the client sees the reason io server disconnect and, by design, does not reconnect by itself.

WebSocket ping and pong

Engine.IO ping and pong: ping_interval and ping_timeout are both 20 s, so a silent client is dropped after 40 s, as in the protocol.

Max frame 16384 bytes

max_http_buffer_size=16384. A larger packet makes Engine.IO drop the connection (client reason transport close) with no too_large envelope; the in-handler check only catches envelopes that pass the transport limit.

Origin allow-list

cors_allowed_origins. A foreign Origin on the WebSocket upgrade gets HTTP 403.

Ticket authentication

POST /ticket on the FastAPI part, and the ticket in the hello payload.

Project files

The dependencies are the pinned list shown earlier. The Dockerfile copies three modules:

FROM python:3.13-slim
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY store.py server.py announce.py ./
EXPOSE 8000
CMD ["uvicorn", "server:asgi", "--host", "0.0.0.0", "--port", "8000"]

The Compose file runs Redis (host port 6330), two server instances (host ports 8130 and 8131) and an nginx proxy (host port 8132) in front of both. The presence grace period is shortened to one second so a demonstration finishes quickly; the protocol value is 15 seconds and is the default in the code.

services:
  redis:
    image: redis:8.8.3-alpine
    ports:
      - "127.0.0.1:6330:6379"
  chatterbox-1:
    build: .
    environment: &env
      REDIS_URL: redis://redis:6379/0
      ALLOWED_ORIGINS: https://chat.example.test
      PRESENCE_GRACE_SECONDS: "1"   # the protocol says 15; shortened so the demo finishes quickly
    ports:
      - "127.0.0.1:8130:8000"
    depends_on: [redis]
  chatterbox-2:
    build: .
    environment: *env
    ports:
      - "127.0.0.1:8131:8000"
    depends_on: [redis]
  proxy:
    image: nginx:1.28-alpine
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "127.0.0.1:8132:80"
    depends_on: [chatterbox-1, chatterbox-2]

The state store

All state lives in Redis so that any instance can serve any client: the next seq of a room comes from INCR, the messages and the replay log are sorted sets scored by seq, tickets are keys with a 30 second expiry read with GETDEL (atomic, so a ticket works once), and acknowledgements are cached for 24 hours under the user and envelope id. Only acknowledgements are cached; an error is not, so a retry after a failure is processed again.

"""Redis-backed state for the Chatterbox server: tickets, rooms, seq, history, idempotency, presence."""
import json
import os
import re
import secrets
import time

import redis.asyncio as aioredis

REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
r = aioredis.from_url(REDIS_URL, decode_responses=True)

CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
ROOM_RE = re.compile(r"^[a-z0-9-]{1,64}$")
USER_RE = re.compile(r"^[a-z0-9]{1,32}$")
HISTORY_PAGE_BYTES = 262144
EVENT = "chatterbox"  # every envelope travels as the single argument of this Socket.IO event


def ulid() -> str:
    """26-character ULID: 48-bit millisecond timestamp followed by 80 random bits."""
    n = (int(time.time() * 1000) << 80) | secrets.randbits(80)
    return "".join(CROCKFORD[(n >> (5 * i)) & 31] for i in range(25, -1, -1))


def server_id() -> str:
    return "s_" + ulid()


def now_ms() -> int:
    return int(time.time() * 1000)


def dumps(value) -> str:
    return json.dumps(value, ensure_ascii=False, separators=(",", ":"))


def utf16_len(text: str) -> int:
    return len(text.encode("utf-16-le")) // 2  # same unit as JavaScript String.length


class ChatError(Exception):
    CODES = {"bad_request": False, "unsupported_version": False, "unauthorized": False,
             "forbidden": False, "not_found": False, "conflict": False, "too_large": False,
             "rate_limited": True, "internal": True, "unavailable": True}

    def __init__(self, code: str, message: str):
        super().__init__(message)
        self.code, self.message, self.retryable = code, message, self.CODES[code]


def envelope(type_, room, payload, id_=None):
    return {"v": 1, "type": type_, "id": id_ or server_id(), "room": room, "ts": now_ms(), "payload": payload}


def display_name(user_id: str) -> str:
    return user_id.capitalize()


# --- tickets (one-time, 30 s) ---------------------------------------------------------------
async def issue_ticket(user_id: str) -> str:
    if not USER_RE.match(user_id):
        raise ChatError("bad_request", "user must match [a-z0-9]{1,32}")
    ticket = "tk_" + secrets.token_urlsafe(24)
    await r.set(f"ticket:{ticket}", user_id, ex=30)
    return ticket


async def redeem_ticket(ticket: str):
    return await r.getdel(f"ticket:{ticket}")  # atomic read-and-delete: a replay finds nothing


# --- idempotency ----------------------------------------------------------------------------
async def cached_ack(user_id: str, env_id: str):
    raw = await r.get(f"idem:{user_id}:{env_id}")
    return json.loads(raw) if raw else None


async def remember_ack(user_id: str, env_id: str, ack_payload: dict):
    await r.set(f"idem:{user_id}:{env_id}", dumps(ack_payload), ex=86400)


# --- rooms and membership -------------------------------------------------------------------
def check_room_access(room: str, user_id: str):
    if not ROOM_RE.match(room):
        raise ChatError("bad_request", "room must match [a-z0-9-]{1,64}")
    if room.startswith("dm-"):
        parts = room.split("-")
        if len(parts) != 3 or parts[1] > parts[2] or user_id not in parts[1:]:
            raise ChatError("forbidden", "direct-message room")


async def add_member(room: str, user_id: str) -> bool:
    added = await r.sadd(f"room:{room}:members", user_id)
    await r.sadd(f"user:{user_id}:rooms", room)
    return bool(added)


async def remove_member(room: str, user_id: str) -> bool:
    removed = await r.srem(f"room:{room}:members", user_id)
    await r.srem(f"user:{user_id}:rooms", room)
    return bool(removed)


async def is_member(room: str, user_id: str) -> bool:
    return bool(await r.sismember(f"room:{room}:members", user_id))


async def user_rooms(user_id: str) -> list[str]:
    return sorted(await r.smembers(f"user:{user_id}:rooms"))


async def members(room: str) -> list[dict]:
    out = []
    for uid in sorted(await r.smembers(f"room:{room}:members")):
        out.append({"userId": uid, "name": display_name(uid),
                    "status": await r.hget("presence", uid) or "offline"})
    return out


async def last_seq(room: str) -> int:
    return int(await r.get(f"room:{room}:seq") or 0)


# --- messages -------------------------------------------------------------------------------
async def append_message(room, user_id, msg_id, text, reply_to=None, attachments=None):
    """Assign the next seq, store the message and return the broadcast envelope."""
    if reply_to is not None and not await r.hexists(f"room:{room}:ids", reply_to):
        raise ChatError("not_found", "replyTo is not a message of this room")
    seq = await r.incr(f"room:{room}:seq")
    if not await r.hsetnx(f"room:{room}:ids", msg_id, seq):
        raise ChatError("conflict", "message id already used")
    env = envelope("message", room, {
        "from": {"userId": user_id, "name": display_name(user_id)}, "text": text,
        "replyTo": reply_to, "attachments": attachments or [], "seq": seq, "edited": False}, id_=msg_id)
    member = dumps(env)
    await r.zadd(f"room:{room}:msgs", {member: seq})
    await r.zadd(f"room:{room}:log", {member: seq})
    return env


async def history(room, before, limit):
    upper = "+inf" if before is None else f"({before}"
    raws = await r.zrevrangebyscore(f"room:{room}:msgs", upper, "-inf", start=0, num=limit + 1)
    page, size = [], 0
    for raw in raws[:limit]:
        size += len(raw.encode())
        if page and size > HISTORY_PAGE_BYTES:  # keep a page under 256 KiB, return fewer messages
            break
        page.append(json.loads(raw))
    has_more = len(page) < len(raws)
    page.reverse()
    return {"messages": page, "hasMore": has_more, "next": page[0]["payload"]["seq"] if has_more else None}


async def log_since(room, since):
    return [json.loads(x) for x in await r.zrangebyscore(f"room:{room}:log", f"({since}", "+inf")]


# --- sessions and presence --------------------------------------------------------------------
async def save_session(session_id, user_id, ttl=None):
    await r.set(f"sess:{session_id}", user_id, ex=ttl or 120)


async def session_owner(session_id):
    return await r.get(f"sess:{session_id}")


async def set_status(user_id, status) -> bool:
    """Returns True when the status changed (and so must be broadcast)."""
    previous = await r.hget("presence", user_id)
    await r.hset("presence", user_id, status)
    return previous != status

The server

Read it from the bottom up: asgi is the application, on_envelope is the single entry point, and the handlers return (seq, result) for the acknowledgement. Decisions worth noting:

  • async_handlers=False makes the server process one client’s events strictly in arrival order, which the protocol needs: a client retries unacknowledged envelopes in their original order. With the default True, handlers of one client run concurrently and two quickly sent messages could be given their seq in a different order. The cost is that a slow handler delays that client’s following events, so handlers must stay short.

  • to=sid sends use ignore_queue=True: the socket is on this process, so the message skips the Redis round trip. Room emits go through the manager and reach every instance, the sender’s other instance included.

  • Rate limiting is a token bucket per connection (20 per second, capacity 40) checked before deduplication, and ten rate_limited errors within ten seconds close the connection with 4429.

  • Presence uses a per-user connection counter in Redis. When it falls to zero a background task waits for the grace period and only then marks the user offline, so a page reload does not flash an offline notice.

"""Chatterbox protocol v1 over Socket.IO: one event name, one envelope per event."""
import logging
import os
import time
from dataclasses import dataclass, field

import socketio
from redis.exceptions import RedisError
from fastapi import FastAPI, HTTPException

import store
from store import EVENT, ChatError, envelope

log = logging.getLogger("chatterbox")
CLOSE_EVENT = "chatterbox.close"  # Socket.IO has no close codes, so the code travels in this event
HEARTBEAT_MS = 20000
MAX_FRAME = 16384
GRACE = float(os.environ.get("PRESENCE_GRACE_SECONDS", "15"))
ORIGINS = os.environ.get("ALLOWED_ORIGINS", "https://chat.example.test").split(",")

mgr = socketio.AsyncRedisManager(store.REDIS_URL)
sio = socketio.AsyncServer(
    async_mode="asgi",
    client_manager=mgr,
    cors_allowed_origins=ORIGINS,
    transports=["websocket"],           # required with several workers or instances
    ping_interval=HEARTBEAT_MS / 1000,
    ping_timeout=HEARTBEAT_MS / 1000,   # silent for 2 x heartbeatMs -> disconnected
    max_http_buffer_size=MAX_FRAME,
    async_handlers=False,               # one client's envelopes are handled strictly in arrival order
)


@dataclass
class Conn:
    user_id: str | None = None
    session_id: str | None = None
    ready: bool = False
    tokens: float = 40.0
    stamp: float = field(default_factory=time.monotonic)
    limited: list = field(default_factory=list)


CONNS: dict[str, Conn] = {}


# --- sending ------------------------------------------------------------------------------------
async def send(sid, env):
    await sio.emit(EVENT, env, to=sid, ignore_queue=True)  # sid lives on this process: skip Redis


async def send_error(sid, ref, err: ChatError, room=None):
    await send(sid, envelope("error", room, {"ref": ref, "code": err.code, "message": err.message,
                                             "retryable": err.retryable}))


async def close(sid, code, reason):
    await sio.emit(CLOSE_EVENT, {"code": code, "reason": reason}, to=sid, ignore_queue=True)
    await sio.disconnect(sid)


async def to_room(room, env, skip_sid=None):
    await sio.emit(EVENT, env, room=room, skip_sid=skip_sid)  # goes through Redis to every instance


async def presence_env(room, user_id, status):
    return envelope("presence", room, {"userId": user_id, "name": store.display_name(user_id), "status": status})


async def broadcast_presence(user_id, status, skip_sid=None):
    for room in await store.user_rooms(user_id):
        await to_room(room, await presence_env(room, user_id, status), skip_sid)


# --- connection lifecycle -----------------------------------------------------------------------
@sio.event
async def connect(sid, environ, auth):
    CONNS[sid] = Conn()
    sio.start_background_task(hello_deadline, sid)


async def hello_deadline(sid):
    await sio.sleep(5)
    conn = CONNS.get(sid)
    if conn and not conn.ready:
        await send_error(sid, None, ChatError("unauthorized", "hello expected within 5 s"))
        await close(sid, 4408, "hello timeout")


@sio.event
async def disconnect(sid, reason):
    conn = CONNS.pop(sid, None)
    if not conn or not conn.ready:
        return
    await store.save_session(conn.session_id, conn.user_id)  # keeps the session for the 120 s resume window
    if await store.r.decr(f"conns:{conn.user_id}") <= 0:
        sio.start_background_task(go_offline_later, conn.user_id)


async def go_offline_later(user_id):
    await sio.sleep(GRACE)
    if int(await store.r.get(f"conns:{user_id}") or 0) <= 0 and await store.set_status(user_id, "offline"):
        await broadcast_presence(user_id, "offline")


# --- the one event ------------------------------------------------------------------------------
@sio.on(EVENT)
async def on_envelope(sid, data):
    conn = CONNS.get(sid)
    if conn is None:
        return
    ref = data.get("id") if isinstance(data, dict) else None
    room = data.get("room") if isinstance(data, dict) else None
    try:
        if not take_token(conn):
            conn.limited = [t for t in conn.limited if time.monotonic() - t < 10] + [time.monotonic()]
            if len(conn.limited) >= 10:
                return await close(sid, 4429, "rate limited")
            raise ChatError("rate_limited", "slow down")
        validate(data)
        if data["v"] != 1:
            await send_error(sid, ref, ChatError("unsupported_version", "v must be 1"))
            return await close(sid, 4426, "unsupported version")
        kind = data["type"]
        if kind == "hello":
            return await do_hello(sid, conn, data)
        if not conn.ready:
            await send_error(sid, ref, ChatError("unauthorized", "hello first"))
            return await close(sid, 4401, "hello required")
        if kind in FIRE_AND_FORGET:
            return await FIRE_AND_FORGET[kind](sid, conn, data)
        handler = ACKED.get(kind)
        if handler is None:
            raise ChatError("bad_request", f"unknown type {kind!r}")
        cached = await store.cached_ack(conn.user_id, data["id"])
        if cached:
            cached["dup"] = True
            return await send(sid, envelope("ack", room, cached))
        seq, result = await handler(sid, conn, data)
        ack = {"ref": data["id"], "ok": True, "seq": seq, "dup": False, "result": result}
        await store.remember_ack(conn.user_id, data["id"], ack)
        await send(sid, envelope("ack", room, ack))
        if kind == "join" and isinstance(data["payload"].get("since"), int):
            for missed in await store.log_since(data["room"], data["payload"]["since"]):
                await send(sid, missed)  # replay follows the ack
    except ChatError as err:
        await send_error(sid, ref, err, room if isinstance(room, str) else None)
    except RedisError:
        log.warning("redis unavailable")
        await send_error(sid, ref, ChatError("unavailable", "state store unavailable"), room if isinstance(room, str) else None)
    except Exception:
        log.exception("handler failed")
        await send_error(sid, ref, ChatError("internal", "internal error"))


def take_token(conn: Conn) -> bool:
    now = time.monotonic()
    conn.tokens = min(40.0, conn.tokens + (now - conn.stamp) * 20)
    conn.stamp = now
    if conn.tokens >= 1:
        conn.tokens -= 1
        return True
    return False


def validate(data):
    if not isinstance(data, dict) or not isinstance(data.get("v"), int):
        raise ChatError("bad_request", "envelope must be an object with an integer v")
    if not isinstance(data.get("type"), str) or not isinstance(data.get("payload"), dict):
        raise ChatError("bad_request", "type and payload are required")
    if not isinstance(data.get("id"), str) or not 26 <= len(data["id"]) <= 36:
        raise ChatError("bad_request", "id must be 26 to 36 characters")
    if data.get("room") is not None and not isinstance(data["room"], str):
        raise ChatError("bad_request", "room must be a string or null")
    if len(store.dumps(data).encode()) > MAX_FRAME:
        raise ChatError("too_large", "frame exceeds 16384 bytes")


# --- handlers -----------------------------------------------------------------------------------
async def do_hello(sid, conn, env):
    if conn.ready:
        raise ChatError("conflict", "already authenticated")
    payload = env["payload"]
    user_id = await store.redeem_ticket(str(payload.get("token", "")))
    if not user_id:
        await send_error(sid, env["id"], ChatError("unauthorized", "invalid or expired ticket"))
        return await close(sid, 4401, "unauthorized")
    resume = payload.get("resume") or {}
    resumed = bool(resume.get("sessionId")) and await store.session_owner(resume["sessionId"]) == user_id
    conn.user_id, conn.ready = user_id, True
    conn.session_id = resume["sessionId"] if resumed else store.ulid()
    await store.save_session(conn.session_id, user_id)
    rooms = await store.user_rooms(user_id)
    for room in rooms:
        await sio.enter_room(sid, room)
    if await store.r.incr(f"conns:{user_id}") == 1 and await store.set_status(user_id, "online"):
        await broadcast_presence(user_id, "online", skip_sid=sid)
    await send(sid, envelope("hello", None, {
        "ref": env["id"], "userId": user_id, "name": store.display_name(user_id), "sessionId": conn.session_id,
        "heartbeatMs": HEARTBEAT_MS, "maxFrameBytes": MAX_FRAME, "resumed": resumed, "rooms": rooms}))
    if resumed:
        for room, seen in (resume.get("rooms") or {}).items():
            if room in rooms and isinstance(seen, int):
                for missed in await store.log_since(room, seen):
                    await send(sid, missed)


def require_room(env) -> str:
    room = env.get("room")
    if not room:
        raise ChatError("bad_request", "room is required")
    return room


async def need_member(conn, room):
    if not await store.is_member(room, conn.user_id):
        raise ChatError("forbidden", "join the room first")


async def do_join(sid, conn, env):
    room = require_room(env)
    store.check_room_access(room, conn.user_id)
    since = env["payload"].get("since")
    if since is not None and not isinstance(since, int):
        raise ChatError("bad_request", "since must be an integer or null")
    newly = await store.add_member(room, conn.user_id)
    await sio.enter_room(sid, room)
    if newly:
        status = await store.r.hget("presence", conn.user_id) or "online"
        await to_room(room, await presence_env(room, conn.user_id, status), skip_sid=sid)
    return None, {"members": await store.members(room), "lastSeq": await store.last_seq(room)}


async def do_leave(sid, conn, env):
    room = require_room(env)
    if not await store.remove_member(room, conn.user_id):
        raise ChatError("not_found", "not a member")
    await sio.leave_room(sid, room)
    return None, {}


async def do_message(sid, conn, env):
    room, p = require_room(env), env["payload"]
    await need_member(conn, room)
    text = p.get("text")
    if not isinstance(text, str) or not 1 <= store.utf16_len(text) <= 4000:
        raise ChatError("bad_request", "text must be 1 to 4000 characters")
    attachments = p.get("attachments") or []
    if not isinstance(attachments, list) or len(attachments) > 5 or not all(
            isinstance(a, dict) and str(a.get("url", "")).startswith("https://") for a in attachments):
        raise ChatError("bad_request", "at most 5 attachments with https urls")
    msg = await store.append_message(room, conn.user_id, env["id"], text, p.get("replyTo"), attachments)
    await to_room(room, msg)
    return msg["payload"]["seq"], {}


async def do_presence(sid, conn, env):
    status = env["payload"].get("status")
    if status not in ("online", "away"):
        raise ChatError("bad_request", "status must be online or away")
    await store.save_session(conn.session_id, conn.user_id)  # the heartbeat keeps the session alive
    if await store.set_status(conn.user_id, status):
        await broadcast_presence(conn.user_id, status, skip_sid=sid)
    return None, {}


async def do_history(sid, conn, env):
    room, p = require_room(env), env["payload"]
    await need_member(conn, room)
    limit = p.get("limit", 50)
    if not isinstance(limit, int) or not 1 <= limit <= 100:
        raise ChatError("bad_request", "limit must be 1 to 100")
    return None, await store.history(room, p.get("before"), limit)


async def do_typing(sid, conn, env):
    room = env.get("room")
    if room and await store.is_member(room, conn.user_id) and env["payload"].get("state") in ("start", "stop"):
        await to_room(room, envelope("typing", room, {"userId": conn.user_id, "state": env["payload"]["state"]}),
                      skip_sid=sid)


ACKED = {"join": do_join, "leave": do_leave, "message": do_message, "presence": do_presence, "history": do_history}
FIRE_AND_FORGET = {"typing": do_typing}


# --- HTTP part (FastAPI) and the combined ASGI app ----------------------------------------------
api = FastAPI(title="Chatterbox")


@api.post("/ticket")
async def ticket(user: str):
    try:
        return {"ticket": await store.issue_ticket(user)}
    except ChatError as err:
        raise HTTPException(400, err.message)
    except RedisError:
        raise HTTPException(503, "state store unavailable")


@api.get("/healthz")
async def healthz():
    return {"ok": True}


asgi = socketio.ASGIApp(sio, other_asgi_app=api)

Posting from outside the server

A deployment job, a moderation tool or a Celery task can post into a room without holding a socket. This script uses a write-only manager and the same store function, so the message gets a real seq and is part of history. It ran with docker compose run --rm chatterbox-1 python announce.py with the room and the text as the two arguments, and a client connected to the other instance received it with seq 35.

"""Post a system message into a room from a process that holds no connections."""
import asyncio
import sys

import socketio

import store


async def main(room: str, text: str):
    # write_only=True: publish to Redis only, never subscribe (and so never receive callbacks)
    external = socketio.AsyncServer(client_manager=socketio.AsyncRedisManager(store.REDIS_URL, write_only=True))
    msg = await store.append_message(room, "system", store.ulid(), text)
    await external.emit(store.EVENT, msg, room=room)
    print("announced seq", msg["payload"]["seq"])


asyncio.run(main(sys.argv[1], sys.argv[2]))

nginx in front of both instances

upstream chatterbox {
    server chatterbox-1:8000;
    server chatterbox-2:8000;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ""      close;
}

server {
    listen 80;

    location /socket.io/ {
        proxy_pass http://chatterbox;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 75s;   # must exceed the 20 s Engine.IO ping interval
    }

    location / {
        proxy_pass http://chatterbox;
        proxy_set_header Host $host;
    }
}

Because the transport is WebSocket only, the two instances need no sticky sessions: nginx can balance each new connection freely, and the ticket (stored in Redis) can be redeemed on whichever instance receives the hello. The whole test below passed unchanged against the proxy, with the two clients landing on different instances.

Testing with a Node.js client

Python has no in-process test client for Socket.IO. The practical options are an AsyncClient against a real server (started on a free port, as in the echo example) or, as here, an independent client in another language, which also proves the protocol mapping and the JavaScript compatibility. This Node.js 24 script uses socket.io-client 4.8.4 (npm install socket.io-client@4.8.4 in a directory whose package.json sets the module type to module) and checks tickets, cross-instance broadcast, acknowledgements, duplicates, errors, ordering, history and resume. Alice is connected to instance 1 and Bob to instance 2.

// Test client: Node 24 + socket.io-client 4.8.4 against two Chatterbox instances.
import { io } from "socket.io-client";
import { randomBytes } from "node:crypto";

const A = process.env.A ?? "http://127.0.0.1:8130";
const B = process.env.B ?? "http://127.0.0.1:8131";
const ORIGIN = "https://chat.example.test";
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const ulid = () => {
  let n = (BigInt(Date.now()) << 80n) | BigInt("0x" + randomBytes(10).toString("hex"));
  let out = "";
  for (let i = 0; i < 26; i++) { out = CROCKFORD[Number(n & 31n)] + out; n >>= 5n; }
  return out;
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let failures = 0;
const check = (ok, label) => { console.log(`${ok ? "PASS" : "FAIL"} ${label}`); if (!ok) failures++; };

class Client {
  constructor(base, user) { this.base = base; this.user = user; this.inbox = []; this.closes = []; this.waiters = []; }
  async connect(resume) {
    const { ticket } = await (await fetch(`${this.base}/ticket?user=${this.user}`, { method: "POST" })).json();
    this.socket = io(this.base, { transports: ["websocket"], extraHeaders: { Origin: ORIGIN }, reconnection: false });
    this.socket.on("chatterbox", (env) => { this.inbox.push(env); this.waiters.forEach((w) => w()); });
    this.socket.on("chatterbox.close", (c) => this.closes.push(c));
    await new Promise((res, rej) => { this.socket.once("connect", res); this.socket.once("connect_error", rej); });
    return this.hello(ticket, resume);
  }
  async hello(token, resume) {
    const id = ulid();
    this.send({ v: 1, type: "hello", id, room: null, ts: 0, payload: { token, client: "node/1.0", ...(resume && { resume }) } });
    return this.until((e) => e.type === "hello" && e.payload.ref === id);
  }
  send(env) { this.socket.emit("chatterbox", env); return env.id; }
  req(type, room, payload, id = ulid()) { this.send({ v: 1, type, id, room, ts: 0, payload }); return this.reply(id); }
  reply(id) { return this.until((e) => (e.type === "ack" || e.type === "error") && e.payload.ref === id); }
  async until(pred, ms = 4000) {
    const end = Date.now() + ms;
    for (;;) {
      const i = this.inbox.findIndex(pred);
      if (i >= 0) return this.inbox.splice(i, 1)[0];
      if (Date.now() > end) throw new Error("timeout waiting for envelope; inbox=" + JSON.stringify(this.inbox.map((e) => e.type)));
      await new Promise((r) => { this.waiters.push(r); setTimeout(r, 50); });
    }
  }
  async quiet(pred, ms = 400) { await sleep(ms); return !this.inbox.some(pred); }
}

const alice = new Client(A, "alice");
const bob = new Client(B, "bob");   // a different instance: fan-out must cross Redis

const h1 = await alice.connect();
check(h1.payload.userId === "alice" && h1.payload.resumed === false && h1.payload.heartbeatMs === 20000, "hello: alice authenticated with a one-time ticket");
const h2 = await bob.connect();
check(h2.payload.userId === "bob", "hello: bob authenticated on the second instance");

const j1 = await alice.req("join", "general", { since: null });
check(j1.type === "ack" && j1.payload.ok && j1.payload.result.lastSeq === 0, "join: alice joins general");
const j2 = await bob.req("join", "general", { since: null });
check(j2.payload.result.members.length === 2, "join: bob sees two members");
const pres = await alice.until((e) => e.type === "presence" && e.payload.userId === "bob");
check(pres.payload.status === "online", "presence: alice sees bob online across instances");

const mid = ulid();
const mack = await alice.req("message", "general", { text: "Hello, Chatterbox", replyTo: null, attachments: [] }, mid);
check(mack.payload.ok && mack.payload.seq === 1 && !mack.payload.dup, "message: ack carries seq 1");
const got = await bob.until((e) => e.type === "message" && e.id === mid);
check(got.payload.seq === 1 && got.payload.from.userId === "alice", "message: bob (other instance) receives the broadcast");
const own = await alice.until((e) => e.type === "message" && e.id === mid);
check(own.payload.text === "Hello, Chatterbox", "message: the sender receives its own copy");

const dup = await alice.req("message", "general", { text: "Hello, Chatterbox", replyTo: null, attachments: [] }, mid);
check(dup.payload.dup === true && dup.payload.seq === 1, "dup: same id returns the original ack with dup=true");
check(await bob.quiet((e) => e.type === "message"), "dup: nothing is broadcast again");

check((await alice.req("message", "general", { text: "", replyTo: null, attachments: [] })).payload.code === "bad_request", "error: empty text is bad_request");
check((await alice.req("message", "nowhere", { text: "hi" })).payload.code === "forbidden", "error: message to a room without joining is forbidden");
check((await alice.req("join", "dm-bob-carol", { since: null })).payload.code === "forbidden", "error: someone else's DM room is forbidden");
check((await alice.req("edit", "general", { target: mid, text: "x" })).payload.code === "bad_request", "error: a type this server does not implement is bad_request");

const ids = Array.from({ length: 30 }, () => ulid());
ids.forEach((id) => alice.send({ v: 1, type: "message", id, room: "general", ts: 0, payload: { text: "burst " + id } }));
const seqs = [];
for (const id of ids) seqs.push((await alice.reply(id)).payload.seq);
check(seqs.every((s, i) => i === 0 || s === seqs[i - 1] + 1), "order: 30 pipelined messages get strictly increasing consecutive seq");
bob.inbox.length = 0; alice.inbox.length = 0;

const hp = await bob.req("history", "general", { before: null, limit: 5 });
check(hp.payload.result.messages.length === 5 && hp.payload.result.hasMore && hp.payload.result.next !== null, "history: first page of 5 has more");
const oldest = await bob.req("history", "general", { before: hp.payload.result.next, limit: 100 });
const all = [...oldest.payload.result.messages, ...hp.payload.result.messages];
check(!oldest.payload.result.hasMore && all[0].id === mid, "history: page back to the start");

// resume: bob drops, alice keeps talking, bob returns with its session id
const sessionId = h2.payload.sessionId;
bob.socket.disconnect();
await sleep(200);
const missedId = ulid();
await alice.req("message", "general", { text: "sent while bob was away" }, missedId);
const lastSeq = (await alice.req("history", "general", { before: null, limit: 1 })).payload.result.messages[0].payload.seq - 1;
const bob2 = new Client(B, "bob");
const h3 = await bob2.connect({ sessionId, rooms: { general: lastSeq } });
check(h3.payload.resumed === true && h3.payload.rooms.includes("general"), "resume: session kept, rooms restored");
const replayed = await bob2.until((e) => e.type === "message" && e.id === missedId);
check(replayed.payload.seq === lastSeq + 1, "resume: the missed message is replayed with its original seq");

// an unknown ticket
const bad = new Client(A, "mallory");
bad.socket = io(A, { transports: ["websocket"], extraHeaders: { Origin: ORIGIN }, reconnection: false });
bad.socket.on("chatterbox", (e) => bad.inbox.push(e));
bad.socket.on("chatterbox.close", (c) => bad.closes.push(c));
await new Promise((r) => bad.socket.once("connect", r));
bad.send({ v: 1, type: "hello", id: ulid(), room: null, ts: 0, payload: { token: "tk_replayed" } });
await sleep(400);
check(bad.inbox[0]?.payload.code === "unauthorized" && bad.closes[0]?.code === 4401, "auth: an unknown ticket gets unauthorized and close 4401");

[alice, bob2].forEach((c) => c.socket.disconnect());
console.log(failures === 0 ? "ALL PASSED" : `${failures} FAILED`);
process.exit(failures ? 1 : 0);

All twenty checks printed PASS against the two instances directly and again through nginx. A longer variant of the same script also confirmed: a typing indicator reaching the other member; a frame sent before hello getting unauthorized and close 4401; v: 2 getting unsupported_version and close 4426; a connection that never sends hello receiving unauthorized and close 4408 after 5 seconds; a foreign Origin refused on the WebSocket upgrade, while a client without Origin and one with the allowed Origin connect; an oversized packet dropping the connection; offline presence broadcast after the grace period; and 80 rapid envelopes producing rate_limited errors and close 4429. Stopping Redis made envelope handlers answer unavailable (retryable) and POST /ticket answer 503 while the sockets stayed connected; after Redis returned, resending the same envelope id succeeded with dup false, because errors are not cached.

Security

  • Origin. Browsers send Origin on the WebSocket handshake and do not apply the same-origin policy to it, so a page on any site can open a socket to your server with the visitor’s cookies. Keep cors_allowed_origins to an explicit list; '*' is only for public data. Requests without an Origin are accepted by design (non-browser clients), so authentication cannot rely on Origin alone.

  • Authentication. Pass credentials in the auth object of the client or, as here, in the first message. The one-time ticket keeps tokens out of URLs and logs and is redeemed with an atomic read-and-delete. The reasoning is on Authentication and Authorization; the general threat list is on Security. FastAPI’s own security tools are on Security Fundamentals.

  • Authorize every event. A connection is authenticated once, but each event must check what that user may do (the handlers above check room membership; direct-message rooms are open only to the two named users). Do not trust the room name or user id sent by the client.

  • Session room hijack. Every socket is in a room named after its sid. Version 5.17.0 blocks a client from joining another user’s sid room, which would otherwise let it receive what is sent privately to that user; on earlier 5.x versions guard enter_room calls that take a room name from the client.

  • The queue is trusted. Anyone who can publish to the Redis channel can inject events into any room. Keep Redis on a private network with authentication and TLS, and give the announcer its own credentials.

  • Admin UI. sio.instrument(auth=...) enables the Socket.IO admin UI hosted at admin.socket.io; it is off by default and must be protected and allowed through CORS if used.

  • Size and rate. Keep max_http_buffer_size small (the default is a megabyte) and rate limit per connection.

Common errors and pitfalls

  • Messages arrive twice or out of order. Handlers run concurrently by default (async_handlers=True); for a protocol that needs per-client ordering set False, or serialize inside the handler with a lock.

  • Several workers and long polling. With more than one process and polling enabled, a polling request that reaches a different process than the one holding the session fails, so clients see HTTP 400 errors or random disconnects. Set transports=["websocket"] on server and client, or use sticky sessions.

  • No broadcast across processes. Forgetting the client_manager on one of the processes makes its rooms invisible to the others. Every process, including write_only ones, needs the same channel.

  • The client does not reconnect after a server disconnect. sio.disconnect(sid) makes the browser client stop reconnecting (reason io server disconnect). Call socket.connect() yourself in that case, with backoff.

  • FastAPI startup code never runs. Wrapping FastAPI in ASGIApp swallows the lifespan; use on_startup or mount the Socket.IO app inside FastAPI.

  • Blocking calls in async handlers. A synchronous database or HTTP call in an AsyncServer handler stalls the event loop, including the Engine.IO ping of every client. Use async drivers or run_in_executor (see Async and Concurrency).

  • Class-based namespaces share state. A namespace instance serves every client; store per-client data in the session or an external store.

  • Mixed versions on one queue. Servers before and after 5.14.0 cannot exchange queue messages (pickle against JSON).

  • Redis counters drift. The connection counter used for presence is wrong after a crashed process that never ran disconnect. Production code should keep per-connection keys with an expiry refreshed by the heartbeat.

  • Silent drops. Packets above max_http_buffer_size close the connection without an application error. Log the disconnect reason and size your limit above the largest legitimate envelope.

Version notes

Table 6. Changes that affect the code on this page
Release Change

5.12.0 (2024-12)

The disconnect handler receives a reason.

5.14.0 (2025-09-30)

Message-queue messages are JSON instead of pickle; Valkey supported by the Redis managers.

5.14.3 (2025-10-29)

Python’s built-in ConnectionRefusedError can reject a connection, besides the socketio.exceptions class.

5.15.0 (2025-11-22)

The first Redis connection is retried.

5.16.0 (2025-12-24)

Python 3.8 and 3.9 dropped from continuous integration.

5.16.3 (2026-06-15)

Redis and RabbitMQ managers catch every exception in their listeners.

5.17.0 (2026-09-14)

A client cannot join another user’s sid room; better handling of legacy connect handlers.