WebSockets ยท GraphQL ยท JSON-RPC โ live comparison
๐ฌ Real-Time Chat โ 3 Protocols Side by Side
One Python server. Three different ways clients can talk to it. Below you'll
find flow diagrams, "why we chose this stack" notes, and interactive demos
that light up the exact server code as it runs.
๐๏ธ Overall Architecture
Every client (browser, mobile app, CLI) can pick the protocol that
best fits the task. All three protocols share the same in-memory data
(MESSAGES, ROOMS, USER_STATS).
๐ก Key insight: the protocol is just a "delivery vehicle." All three
end up calling normal Python functions that read/write the same lists.
Choosing a protocol is really about who initiates, how often,
and what shape the data needs to be in.
๐ค Why Python + FastAPI? What Else Could We Use?
๐ Why we chose Python + FastAPI
Async built-in:async/await handles thousands of open WebSockets on one process.
One framework, three protocols: HTTP, WS and GraphQL all mount on the same FastAPI app.
Type hints โ auto validation + docs (Pydantic, Strawberry, Swagger UI at /docs).
Huge learning ecosystem โ perfect for teaching without drowning students in boilerplate.
๐ข Node.js (Express + Socket.IO + Apollo)
Pro: JavaScript on both client & server โ one language everywhere.
@app.websocket(...) โ tells FastAPI "this URL uses the WebSocket protocol, not HTTP GET."
manager.connect() โ completes the WS handshake and stores the socket in a dict keyed by room name.
await ws.receive_text() โ suspends this coroutine until the client sends a frame. Python doesn't block โ thousands of other sockets can be waiting at the same time.
MESSAGES.append(msg) โ persists to the shared "database."
manager.broadcast() โ pushes the same JSON to every socket in the room, including other tabs.
๐ก Why async? Regular Python sockets would block one thread per user. asyncio lets us handle 10 000+ open WebSockets on a single process.
๐ฌ Chat window (open 2 tabs to see live broadcasting):
GraphQL โ one endpoint, pick your fields
The client sends a query describing exactly the shape it wants back.
๐ฅ๏ธ Client
Step 1 ยท Build query
{ messages(room:...) { text } }
Step 2 ยท POST /graphql
fetch("/graphql", {body: {query}})
Step 6 ยท Render JSON
data.messages.forEach(...)
๐ Single HTTP POST
๐
Content-Type: application/json
๐ ๏ธ Server (Strawberry)
Step 3 ยท Parse query
strawberry validates schema
Step 4 ยท Resolve fields
Query.messages() + Query.rooms()
Step 5 ยท Serialize
{ "data": { ... } }
๐ Server code that runs:
@strawberry.typeclass Query:@strawberry.fielddef messages(self, room: str, limit: int = 20): filtered = [m for m in MESSAGES if m["room"] == room]return [Message(**m) for m in filtered[-limit:]]@strawberry.fielddef rooms(self):return [Room(name=r, message_count=...) for r in ROOMS]
๐ง What each part means
@strawberry.type โ declares a GraphQL type. Fields you list here are the only ones clients can request.
@strawberry.field โ turns a Python method into a resolver. The client's query decides which resolvers run.
Method arguments (room, limit) become query arguments in GraphQL automatically.
Return type List[Message] becomes the GraphQL schema type โ validation is free.
๐ก Why GraphQL beats REST here: a chat sidebar needs only user + text; a moderation dashboard needs everything. With REST you'd need two endpoints; with GraphQL one query serves both by asking for different fields.
๐ฌ Server response:
// click Run above
๐ก Also try /graphql in a new tab โ
Strawberry ships a built-in GraphiQL IDE with autocomplete.
JSON-RPC โ call a named function on the server
Client sends {"method":"โฆ","params":{โฆ}}. Server routes it to
a Python function and returns the result.
๐ฅ๏ธ Client
Step 1 ยท Build envelope
{jsonrpc:"2.0", method, params, id}
Step 2 ยท POST /rpc
fetch("/rpc", {body: JSON})
Step 6 ยท Read result / error
resp.result or resp.error
๐ Network
๐ฏ
JSON-RPC 2.0 envelope
๐ ๏ธ Server (jsonrpcserver)
Step 3 ยท Decode envelope
async_dispatch(body)
Step 4 ยท Look up @method
method_name โ Python fn
Step 5 ยท Run + wrap result
Success({...}) / Error(code,msg)
๐ Server code that runs:
@methodasync def create_room(name):if name in ROOMS:return Error(code=1, message="already exists") ROOMS.add(name)return Success({"created": name, "total_rooms": len(ROOMS)})@app.post("/rpc")async def rpc_endpoint(request): body = await request.body()return JSONResponse(json.loads(await async_dispatch(body)))
๐ง How dispatch works
Every function decorated with @method gets added to a global registry keyed by function name.
async_dispatch(body) reads method from the JSON envelope and looks it up in that registry.
If found โ runs the function with **params. If not โ returns error -32601 "Method not found".
Success(...) / Error(...) wrap the response in the standard {jsonrpc, result|error, id} shape.
๐ก Why RPC vs REST? "Kick this user" or "Approve this payment" aren't CRUD โ they're actions. RPC's method + params shape maps 1-to-1 to a function call, so the code stays intention-revealing.