REST clients and HTTP libraries all follow the same shape: send a request, get a response, connection closed. WebSockets are architecturally different ā one persistent connection where either side can send a message at any time. This lesson covers when that model is actually needed, and how to use it correctly with Python's websockets library.
1The Fundamental Shift: Persistent, Bidirectional, Not Request/Response
Every HTTP-based pattern covered so far in this section ā requests, httpx, REST clients ā follows the same fundamental shape: the client sends one request, the server sends back exactly one response, and the underlying connection either closes or (with keep-alive/connection reuse) sits idle until the *next independent* request. The server can never initiate sending data to the client outside of directly responding to something the client asked for.
A WebSocket connection begins its life as a specially-flagged HTTP request that the server *upgrades* into a fundamentally different kind of connection: persistent (staying open across many message exchanges, not closing after one exchange) and bidirectional (either side ā client or server ā can send a message at any moment, entirely independent of whether the other side has sent anything recently). websockets.connect()'s connection, used as an async with context manager, stays open for as long as that block runs, not just for the duration of a single send()/recv() pair.
This architectural shift is precisely why WebSockets exist as a genuinely different tool, not just 'HTTP but faster': they solve a problem HTTP's request/response model structurally cannot ā a server needing to push data to a client the moment something happens, without waiting for the client to ask. Chat applications, live notifications, collaborative editing, and real-time dashboards are the canonical use cases where this capability is genuinely required, not merely convenient.
import asyncio
import websockets
async def echo_client():
async with websockets.connect("wss://echo.example.com") as ws:
await ws.send("Hello, server!")
response = await ws.recv()
print(response) # the connection STAYS OPEN after this exchange
asyncio.run(echo_client())Stays open across many exchanges ā not one-request-one-response
2Send and Receive as Independent, Concurrent Operations
Because a WebSocket connection doesn't enforce HTTP's strict request-then-response ordering, sending and receiving genuinely need to be handled as two independent, potentially concurrent operations ā which is precisely why WebSocket client code so naturally reaches for the asyncio patterns covered in the Concurrency & Parallelism section. async for message in ws: inside the listen() coroutine continuously receives messages as they arrive, at unpredictable times, entirely independent of whatever the main coroutine is doing.
Running listen() as a separate task via asyncio.create_task(listen()) lets it run concurrently with the main coroutine's own await ws.send("Hi everyone!") ā the send operation doesn't need to wait for listen() to be between iterations, and listen() doesn't block the ability to send. This mirrors exactly the asyncio.create_task() pattern from the async/await lesson: start a coroutine running concurrently, then continue with other work, rather than sequentially awaiting one operation fully before starting the next.
This structural need for concurrent send/receive handling is a direct, practical reason WebSocket client code in Python is built on asyncio rather than synchronous code ā a purely synchronous client would need to somehow simultaneously block waiting to receive *and* remain ready to send at any moment, which a single synchronous thread genuinely cannot do without additional threading complexity that asyncio's cooperative model handles naturally.
async def chat_client():
async with websockets.connect("wss://chat.example.com") as ws:
async def listen():
async for message in ws: # runs continuously, receiving ANYTIME
print(f"Received: {message}")
listen_task = asyncio.create_task(listen())
await ws.send("Hi everyone!") # sending doesn't block receiving
await listen_taskBoth active simultaneously, neither blocking the other
3A WebSocket Server: Many Independent, Long-Lived Connections
websockets.serve(handler, "localhost", 8765) starts a server where handler runs as a separate coroutine *for each individual client connection*, staying alive for the entire duration that specific client remains connected ā potentially minutes or hours, unlike an HTTP request handler that completes and returns within milliseconds to seconds. Managing many such long-lived, independent connections simultaneously is exactly the shape of problem asyncio's event loop (from the Concurrency & Parallelism section) is specifically designed for: thousands of coroutines, each mostly idle waiting for the next message, multiplexed efficiently on a single thread.
The broadcast pattern shown ā connected_clients, a shared set tracking every currently-connected client, iterated over inside each handler to relay a received message to every other connected client ā is the foundational building block behind chat applications, live collaboration tools, and any 'push this update to everyone currently connected' feature. Each client's handler coroutine independently receives its own messages via async for message in websocket:, and independently sends to every *other* client in connected_clients when it receives one ā a genuinely different concurrency shape than a typical HTTP server, where each request is handled in isolation with no persistent, ongoing relationship to any other concurrent request.
The try/finally ensuring connected_clients.remove(websocket) runs even if the connection drops unexpectedly (a network interruption, a client closing their browser tab) is essential cleanup ā without it, connected_clients would accumulate stale, disconnected connections indefinitely, eventually causing broadcast attempts to that dead connection to fail or the set to grow unboundedly, a resource leak directly analogous to the context manager guarantees covered in the Advanced Python module.
import asyncio
import websockets
connected_clients = set()
async def handler(websocket):
connected_clients.add(websocket)
try:
async for message in websocket:
for client in connected_clients:
await client.send(f"Broadcast: {message}") # relay to everyone
finally:
connected_clients.remove(websocket)
async def main():
async with websockets.serve(handler, "localhost", 8765):
await asyncio.Future() # run forever
asyncio.run(main())Many persistent connections, managed concurrently via asyncio
4Step-by-Step Breakdown
HTTP is a conversation where you ask a question and get exactly one answer. WebSockets are a phone call that stays open ā either side can speak at any moment. That's a fundamentally different tool for a fundamentally different problem.
A WebSocket connection starts as an HTTP request that UPGRADES to a persistent, bidirectional connection -- fundamentally unlike a normal request/response cycle.
Checkpoint: After ws.send() and ws.recv() complete in the echo_client example, is the connection automatically closed like an HTTP request would be?
- āNo ā the WebSocket connection remains open until explicitly closed (or the
async withblock ends) - āYes ā like HTTP, each send/receive exchange automatically closes the connection afterward
Unlike HTTP, EITHER side can send a message at any time -- a common pattern is a background task listening while the main task sends.
Checkpoint: In chat_client(), why does sending a message not have to wait for the listen() task to finish first?
- āSending and receiving are independent operations that can happen concurrently on the same persistent connection, unlike HTTP's strict request-then-response order
- ālisten() actually opens its own SEPARATE WebSocket connection from ws.send()
A WebSocket SERVER handles many concurrent client connections, each running independently -- a natural fit for asyncio's concurrency model.
WebSockets complete this section's coverage of communication protocols; Async HTTP Clients closes Networking with the concurrent request patterns that tie the whole section together.
Process Real Queued Messages. Finish process_messages(): this loop-and-handle pattern is the core of any WebSocket server.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Reach for WebSockets specifically when the server genuinely needs to push data without the client asking first
For anything that fits a request/response pattern, HTTP (requests/httpx) remains simpler and more appropriate ā WebSockets earn their added complexity specifically for real-time, server-initiated communication needs.
Always clean up connection state (like a connected_clients set) in a finally block or equivalent
A connection can drop unexpectedly at any time ā without guaranteed cleanup, server-side state tracking connected clients accumulates stale entries indefinitely, a genuine resource leak.
Frequent Bugs
Choosing WebSockets for a feature that's actually a simple request/response pattern, adding the complexity of persistent connection management, reconnection logic, and concurrent send/receive handling for a problem HTTP would have solved more simply.
Default to HTTP (requests/httpx) unless the feature genuinely requires the server to push data to the client without an immediately preceding request ā reserve WebSockets specifically for that real-time, bidirectional need.
Real-World Examples
A Live Price Update Feed
A trading dashboard needs to receive price updates the instant they happen on the server side, without polling the server repeatedly and without the client needing to request each update explicitly.
import asyncio
import websockets
import json
async def price_feed_client(symbols: list[str]):
async with websockets.connect("wss://prices.example.com/feed") as ws:
await ws.send(json.dumps({"subscribe": symbols}))
async for message in ws:
update = json.loads(message)
print(f"{update['symbol']}: {update['price']}") # pushed by the server, not polled