WebSocket Basics in FastAPI — Connection Leakage
A WebSocket connection is a fragile, two-way channel that can be abruptly severed by either client or server. If the client disconnects, the server-side loop, unaware of the broken pipe, will eventually try to write, resulting in an error because the underlying network connection is no longer there.
The Setup
You want to transmit a real-time system metrics stream to a dashboard. You set up a WebSocket endpoint in FastAPI that loops indefinitely to transmit health status packets at scheduled intervals.
What Does This Print?
from fastapi import FastAPI, WebSocket
import asyncio
app = FastAPI()
@app.websocket("/ws/metrics")
async def get_metrics(websocket: WebSocket):
await websocket.accept()
while True:
# Simulating telemetry retrieval
metrics = {"cpu": 22.4, "memory": 65.1}
# Sending payload to the websocket client
await websocket.send_json(metrics)
await asyncio.sleep(1)
The Output
The server logs a runtime traceback error and continues executing the infinite loop in the background: The socket is closed on the client side, but the route function process remains active and continues trying to send data, consuming server resources.
Why Python Does This
FastAPI WebSockets rely on Starlette's WebSocket implementation. When a client closes the network connection, Starlette updates its connection state, but it cannot prematurely kill the running Python coroutine. The next time the loop calls await websocket.send_json(), Starlette raises a WebSocketDisconnect exception (or subsequent RuntimeError). If this exception is not caught within the route function block, the coroutine execution frame remains suspended or repeatedly spawns errors in the ASGI runner thread, leaking memory.
The Fix
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
import asyncio
app = FastAPI()
@app.websocket("/ws/metrics")
async def get_metrics(websocket: WebSocket):
await websocket.accept()
try:
while True:
metrics = {"cpu": 22.4, "memory": 65.1}
await websocket.send_json(metrics)
await asyncio.sleep(1)
except WebSocketDisconnect:
# Clean shutdown when client disconnects
print("Client disconnected from metrics channel.")
finally:
# Run teardown and close connection explicitly if still active
pass
Wrapping the WebSocket communication loop (while True) in a try...except WebSocketDisconnect block allows the server to gracefully catch the exception that occurs when the client closes the connection. This enables the server to break out of the loop and perform any necessary cleanup, preventing further attempts to send data to a non-existent socket.
How This Fails in Real Systems
A crypto tracking app broadcasted prices to hundreds of real-time charts. Due to uncaught disconnect exceptions, terminated user sessions remained active as zombie loop threads on the server, eventually exhausting the server's CPU capacity and causing API crashes.
Key Takeaway
WebSocketDisconnect exceptions within an infinite WebSocket communication loop, leading to the server attempting to send data over a closed connection.