Right, so you’ve decided you need real-time, two-way communication. Good for you. REST polling is for chumps and people who hate their server’s sanity. You’re reaching for WebSockets, and in the Python world, that almost certainly means the websockets library. It’s the workhorse. It’s brilliant, but it has opinions, and you need to understand them or it will quietly, and politely, ruin your day.

Let’s get one thing straight: websockets is an asynchronous library. If you’re still running a synchronous Flask dev server with threading, turn back now. You need an async ecosystem—asyncio at its core, and probably an ASGI server like Uvicorn or Daphne. This isn’t a limitation; it’s the only sane way to handle thousands of persistent, mostly-idle connections without setting your RAM on fire.

The Absolute Bare Minimum: Echo Server

First, let’s prove we can even make a connection. Here’s the canonical “echo server”—the WebSocket equivalent of “Hello World”. You’ll need to pip install websockets first.

server.py

import asyncio
import websockets

async def echo_handler(websocket):
    print("Client connected!")
    try:
        async for message in websocket:
            print(f"Received: {message}")
            await websocket.send(f"Echo: {message}")
    except websockets.ConnectionClosed:
        print("Client disconnected.")

async def main():
    async with websockets.serve(echo_handler, "localhost", 8765):
        print("Server running on ws://localhost:8765")
        await asyncio.Future()  # run forever

if __name__ == "__main__":
    asyncio.run(main())

client.py

import asyncio
import websockets

async def client():
    uri = "ws://localhost:8765"
    async with websockets.connect(uri) as websocket:
        await websocket.send("Hello, server!")
        response = await websocket.recv()
        print(f"Received: {response}")

asyncio.run(client())

Run the server, then run the client. If you see your echo, congratulations, you’re in the real-time business. Notice the async with blocks? They’re crucial. They ensure the connection is properly closed, even if something goes wrong. The async for message in websocket: line is the magic incantation for listening for incoming messages in a loop without blocking everything else.

Handling More Than One Client (Without Thinking)

The beautiful thing about the async approach is that this simple server can handle thousands of simultaneous connections on a single thread. Each echo_handler is an independent task managed by the asyncio event loop. When one is waiting for a message (await websocket.recv()), it yields control to the loop, which can go check on all the other connected clients. It’s efficient by design. Don’t try to do this with threads. Just don’t.

The Two Ways You’ll Mess Up: Exceptions and Timeouts

Here’s where the library stops holding your hand. The most common pitfall is not handling disconnections. A client can vanish at any time: their network drops, they close the tab, their laptop dies. If you don’t catch websockets.ConnectionClosedError (or its more general parent, websockets.ConnectionClosed), your consumer task will crash and bring down the entire event loop if unhandled. Always wrap your message loop in a try/except.

The other silent killer is timeouts. By default, websockets is incredibly patient. It will wait forever for a ping or a message. This is terrible for production. You must set timeouts.

# A more robust handler
async def robust_handler(websocket):
    try:
        # Set a timeout for receiving a message (e.g., 60 seconds)
        await asyncio.wait_for(websocket.recv(), timeout=60.0)
        # ... do stuff
    except asyncio.TimeoutError:
        print("Client was idle for too long. Bootin' 'em.")
    except websockets.ConnectionClosed:
        print("Client left normally.")

You can also configure the underlying ping/pong mechanism, which is the protocol’s way of checking if a connection is still alive. The library does this automatically, but you should tune it.

# Server with explicit ping/pong settings
start_server = websockets.serve(
    handler,
    "localhost", 8765,
    ping_interval=20,   # Send a ping every 20 seconds
    ping_timeout=20,    # Wait 20 seconds for a pong response before closing
    close_timeout=10,   # Wait 10 seconds for a close handshake before giving up
)

Broadcasting: The “Gotcha”

You want to build a chat app, right? Everyone does. This is where you’ll hit the library’s first major design choice: it gives you a connection, not a room. There is no built-in server.broadcast("Hello!") method. You have to manage the list of connected clients yourself. This is actually a feature, not a bug—it gives you maximum flexibility but adds a bit of boilerplate.

connected_clients = set()

async def chat_handler(websocket):
    connected_clients.add(websocket)
    print("New user joined. Total:", len(connected_clients))
    try:
        async for message in websocket:
            # Broadcast to all OTHER clients
            await asyncio.gather(
                *[client.send(f"User says: {message}") for client in connected_clients if client != websocket]
            )
    finally:
        # This runs even if the connection crashes, ensuring we remove the client
        connected_clients.remove(websocket)
        print("User left. Total:", len(connected_clients))

The asyncio.gather(*tasks) is the key here. It fires off all the send operations concurrently. If you used a loop with await for each one, you’d send to clients sequentially, which would be painfully slow for a large audience.

The Bottom Line

The websockets library is a precision tool. It doesn’t assume your use case, so it makes you do a little more work. This is infinitely better than a framework that makes a bunch of wrong choices for you. Embrace the async model, handle your exceptions, manage your state explicitly, and you’ll have a rock-solid foundation for anything real-time.