Socket.IO: rooms, namespaces and scaling
Socket.IO is not WebSocket. It is a protocol layered on top that adds reconnection, acknowledgements, rooms, namespaces and an HTTP long-polling fallback. Job descriptions name it specifically because those features are the ones teams end up needing.
The Python server
python-socketio runs as an ASGI app, so it mounts alongside FastAPI rather
than replacing it.
import socketio
from fastapi import FastAPI
sio = socketio.AsyncServer(
async_mode="asgi",
cors_allowed_origins=[],
)
api = FastAPI()
app = socketio.ASGIApp(sio, other_asgi_app=api)The HTTP routes stay on api; Socket.IO owns /socket.io/. One process, one
port, two protocols.
Namespaces and rooms
These two get conflated in interviews, and the distinction is worth stating cleanly.
| Namespace | Room | |
|---|---|---|
| Purpose | Separate the protocol | Group connections |
| Chosen by | The client, on connect | The server, at runtime |
| Analogy | A URL path | A mailing list |
A namespace is a separate communication channel over the same physical
connection - /chat and /admin have their own handlers, their own auth, and
their own event names. The client asks for one.
A room is a server-side set of session ids. The client cannot join one itself; it can only ask, and the server decides. That asymmetry is the security property, and it is the reason room membership is where authorisation belongs.
Authorising at join time
@sio.event(namespace="/chat")
async def join(sid, data):
user = await current_user(sid)
doc = data["doc_id"]
if not await can_read(user, doc):
raise ConnectionRefusedError("forbidden")
await sio.enter_room(
sid, f"doc:{doc}", namespace="/chat"
)
async def on_edit(doc_id: str, patch: dict):
await sio.emit(
"patch", patch,
room=f"doc:{doc_id}",
namespace="/chat",
)Note the room name is derived from a resource id, checked once at join time.
Emitting to doc:{id} is then safe without re-checking each message.
The per-session room
Every connection is automatically in a room named after its own sid, so
sio.emit(..., room=sid) is how you reply to one client. Adding a
user:{id} room on connect gives you the same thing across a user’s several
tabs and devices.
Authentication
There is no browser API to set headers on a WebSocket handshake, so the token
travels in the connect payload rather than an Authorization header.
@sio.event
async def connect(sid, environ, auth):
token = (auth or {}).get("token")
try:
claims = decode_jwt(token)
except InvalidTokenError:
raise ConnectionRefusedError("bad token")
await sio.save_session(sid, {"uid": claims["sub"]})Raising ConnectionRefusedError rejects the handshake, which is what you want
- an unauthenticated socket should never exist.
Two things people get wrong:
- A query-string token leaks into access logs and proxy logs. Use the
authpayload, which is sent in the connect packet body. - The token expires but the socket does not. A long-lived connection outlives a 15-minute JWT. Either re-verify periodically and disconnect, or push an expiry event and let the client reconnect with a fresh token. See WebSocket and SSE Authentication.
Scaling across workers
This is the part that breaks in production, and the reason is structural: a room is in-memory state in one process. Two Uvicorn workers means two disjoint sets of rooms, and a client on worker A never receives an emit issued on worker B.
The fix is a client manager backed by Redis, which turns every emit into a pub/sub message all workers consume.
mgr = socketio.AsyncRedisManager(
"redis://cache:6379/0"
)
sio = socketio.AsyncServer(
async_mode="asgi", client_manager=mgr
)With that in place any worker - or a Celery task, or a separate publisher process - can emit into a room it holds no connections for.
Sticky sessions
Redis fixes fan-out. It does not remove the need for sticky sessions, because Socket.IO’s handshake is multi-request when it falls back to polling: the client polls, gets a session id, then upgrades. If those requests land on different workers, the second one does not recognise the session and the handshake fails in a loop.
Two ways out:
- Sticky routing by cookie or IP hash at the load balancer, so a session stays on one worker.
- Force the WebSocket transport (
transports: ["websocket"]on the client), skipping polling entirely. One connection, one worker, nothing to pin - at the cost of the fallback for networks that block WebSocket.
The general scaling material - connection budgets, graceful shutdown, L4 versus L7 - is in Scaling WebSocket Applications.
When not to use it
Socket.IO costs you a non-standard protocol: only a Socket.IO client can talk to it, and the framing overhead is real. If you control both ends, need no fallback, and are building simple server push, plain WebSocket or even SSE is lighter. See WebSocket vs Alternatives.
Choose it when you want rooms, acknowledgements and reconnection without writing them, which is a reasonable trade for a collaborative app.
Related
Interview angle 5
- “Namespace or room?” - a namespace is a separate channel the client asks for on connect, with its own handlers and auth, like a URL path. A room is a server-side group of session ids that the client cannot join on its own. That asymmetry is why authorisation lives at room-join time.
- “How do you authenticate a Socket.IO connection?” - send the token in the connect
authpayload rather than a query string, which would leak into access logs, and reject withConnectionRefusedErrorso an unauthenticated socket never exists. Then handle the token outliving the connection, by re-verifying or forcing a reconnect. - “You added a second worker and messages stopped arriving. Why?” - rooms are per-process memory, so worker B has no idea about a room on worker A. Attach a Redis client manager so emits fan out over pub/sub and any worker can publish into any room.
- “Does Redis remove the need for sticky sessions?” - no. Redis fixes message fan-out; sticky sessions fix the handshake, which is multi-request when polling is used and fails if the requests hit different workers. Either pin sessions at the load balancer or force the WebSocket transport and drop the fallback.
- “Why not plain WebSocket?” - Socket.IO is a non-standard protocol requiring its client and adding framing overhead. It earns that when you want rooms, acknowledgements, reconnection and a polling fallback for free; for simple server push, plain WebSocket or SSE is lighter.