Skip to content

Handling WebSockets in FastAPI and Starlette

FastAPI and Starlette expose WebSockets through a WebSocket object with accept, receive_*, send_* and close, running on whatever ASGI server you deploy — usually Uvicorn. The happy path is a loop of receive and send; the work is in everything around it: noticing that the client left, pushing data when the client never sends, writing from several tasks, and knowing what each connection costs. Tested with Starlette 1.7 and FastAPI 0.142 on Uvicorn 0.54: a receive loop got WebSocketDisconnect with code 1000 on a clean close; a push-only endpoint that never receives found out on its next send, which raised WebSocketDisconnect, both for a clean close and for a dropped TCP connection; and 2,000 idle connections added 118.7 MiB to the server process, about 60.8 KiB each. This guide builds endpoints that behave well on each of those paths.

Prerequisites

1. Write the receive loop and catch the disconnect

Accept, loop on receive, and treat WebSocketDisconnect as the normal end of the connection:

from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()


@app.websocket("/ws/echo")
async def echo(ws: WebSocket) -> None:
    await ws.accept()
    try:
        while True:
            text = await ws.receive_text()
            await ws.send_text(text)
    except WebSocketDisconnect as exc:
        log.info("client left with code %s", exc.code)      # measured: 1000 on a clean close
    finally:
        await cleanup_subscriptions(ws)

WebSocketDisconnect is not an error; logging it at error level fills logs with every user closing a tab. Put cleanup — unsubscribing, removing the socket from broadcast sets, releasing per-connection resources — in finally, so it runs whether the client left, the server raised, or the task was cancelled during shutdown. FastAPI dependencies work on WebSocket routes too (Depends for a database pool or the authenticated user), with the authentication options from authenticating WebSocket connections.

Verify: closing the browser tab logs one info line and runs the cleanup exactly once.

2. Watch for disconnects in push-only endpoints

An endpoint that only sends — live prices, progress updates, notifications — never calls receive, so it can only learn about a disconnect when a send fails:

@app.websocket("/ws/ticks")
async def ticks(ws: WebSocket) -> None:
    await ws.accept()

    async def push() -> None:
        while True:
            await ws.send_json(await next_tick())

    async def watch() -> None:
        while True:
            message = await ws.receive()                     # also drains client pings/messages
            if message["type"] == "websocket.disconnect":
                return

    pusher = asyncio.create_task(push())
    try:
        await watch()                                        # returns as soon as the client leaves
    finally:
        pusher.cancel()

Tested on Uvicorn 0.54: a push-only loop sending every 0.2 s raised WebSocketDisconnect on the first send after the client closed, and also after the client's TCP connection was aborted without a close frame. That is prompt when pushes are frequent, but an endpoint that pushes once a minute would hold its resources — subscriptions, database cursors — for up to a minute after the client left. The watcher task notices the disconnect message immediately, so cleanup does not wait for the next push. A dead peer that sends no FIN at all is detected by the server's ping timeout (Uvicorn's --ws-ping-interval and --ws-ping-timeout, 20 s each by default).

Verify: after a client closes, the server's subscription to its data source ends within milliseconds, not at the next push.

How the endpoint learns the client is gone A grid of 3 rows by 3 columns. How the endpoint learns the client is gone endpoint style client closes cleanly TCP dropped, no close frame receive loop WebSocketDisconnect(1000) at once at once or by ping timeout push only next send raises next send raised (tested) push + watcher task watcher returns at once at once or by ping timeout Starlette 1.7 on Uvicorn 0.54; Uvicorn pings every 20 s by default.

3. Send from one task per connection

When several tasks produce messages for the same client — a broadcast relay, a per-user notification stream, replies to requests — let a single writer task own the socket and feed it through a bounded queue:

class Connection:
    def __init__(self, ws: WebSocket, max_pending: int = 100) -> None:
        self.ws = ws
        self.outbox: asyncio.Queue = asyncio.Queue(maxsize=max_pending)

    def offer(self, message: dict) -> bool:
        try:
            self.outbox.put_nowait(message)
            return True
        except asyncio.QueueFull:
            return False                       # slow client: drop, or disconnect it

    async def writer(self) -> None:
        while True:
            await self.ws.send_json(await self.outbox.get())

One writer keeps frames in order and avoids interleaving concurrent sends on the same connection, which ASGI does not promise to make safe. The bounded queue turns a slow client into a visible decision — drop messages, or close the connection with 1008 — instead of unbounded memory growth, as covered in handling WebSocket backpressure with slow consumers.

Verify: a test client that reads slowly gets messages in order, and the server's memory stays flat while it lags.

4. Manage connections in application state

Broadcasts, presence and per-user delivery need a registry of open connections. Keep it in the application, create it in the lifespan, and connect it to the cross-process relay:

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.hub = Hub()                                   # room -> set[Connection]
    relay = asyncio.create_task(app.state.hub.relay_from_redis())
    yield
    relay.cancel()
    await app.state.hub.close_all(code=1001)                # "going away" on shutdown


@app.websocket("/ws/rooms/{room}")
async def room(ws: WebSocket, room: str) -> None:
    await ws.accept()
    conn = Connection(ws)
    hub: Hub = ws.app.state.hub
    await hub.join(room, conn)
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(conn.writer())
            async for text in ws.iter_text():               # ends on disconnect
                await hub.publish(room, {"from": conn.id, "text": text})
            raise asyncio.CancelledError                    # stop the writer when the client leaves
    except (WebSocketDisconnect, asyncio.CancelledError):
        pass
    finally:
        await hub.leave(room, conn)

With more than one worker process, the hub's publish goes through Redis so every process delivers to its own members, as in scaling WebSockets across processes with Redis pub/sub. Closing with 1001 on shutdown tells clients to reconnect — to another instance, during a rolling deploy.

Verify: a rolling restart closes connections with 1001, clients reconnect with backoff, and room membership is rebuilt.

The life of one room connection A flow of 5 stages. The life of one room connection accept() register in hub writer task one sender per socket iter_text() publish to room disconnect / 1001 stop writer finally leave room Registration and cleanup bracket the connection; one task writes.

5. Size workers for connection count

Each open WebSocket holds memory for the connection, its buffers, the handler's task and anything you attach to it. Measured with Starlette on Uvicorn, 2,000 idle connections added 118.7 MiB to the server, about 60.8 KiB each:

# Capacity per worker = memory budget / per-connection cost (+ headroom for traffic)
#   2 GiB budget / ~61 KiB  ~ 34,000 idle connections - before your own per-connection state
uvicorn app:app --workers 4 --ws-ping-interval 20 --ws-ping-timeout 20 \
        --ws-max-size 1048576 --limit-concurrency 10000

Measure with your own handlers, because per-connection state usually dominates the framework's share. --ws-max-size caps incoming message size (1 MiB above; the default is 16 MiB), which bounds what one client can make the server buffer. --limit-concurrency caps connections per worker, refusing extras rather than running out of memory. File-descriptor limits must be raised for tens of thousands of sockets. Long-lived connections also change deployment: the load balancer's idle timeout must exceed the ping interval, and draining a worker takes as long as clients take to reconnect.

Verify: a soak test at the target connection count stays within the memory budget, and the 10,001st connection is refused cleanly.

What does this WebSocket endpoint need? A decision on How does data flow with 4 outcomes. What does this WebSocket endpoint need? How does data flow? client asks, server answers receive loop catch WebSocketDisconnect server pushes only pusher + watcher task prompt cleanup many sources to one client single writer + bounded queue ordered, bounded rooms across workers hub + Redis relay every process delivers Most WebSocket bugs live in the paths around the happy loop.

Verification

WebSocket endpoints are robust when:

  • Disconnects are handled as normal endings, with cleanup in finally.
  • Push-only endpoints watch for disconnects instead of waiting for the next send.
  • One task writes to each socket, through a bounded queue.
  • Workers are sized and limited by measured per-connection memory.

Diagnostic Hook: export open connections per worker, close codes by count, and outbox depth per connection. Connections that stay open far longer than sessions should point at missing disconnect detection; many 1006 (abnormal) closes point at proxies or load balancers timing out idle connections before the ping interval.

Pitfalls & edge cases

  • Logging disconnects as errors. They are the normal way connections end.
  • Push-only loops with long intervals. Cleanup waits until the next send fails.
  • Several tasks sending on one socket. Use a single writer.
  • Default 16 MiB message limit. Lower --ws-max-size to what clients really send.

Frequently Asked Questions

How do I detect a WebSocket disconnect in FastAPI?

receive_text and the other receive methods raise WebSocketDisconnect when the client leaves; catch it around the receive loop. For push-only endpoints, run a task that awaits ws.receive() and returns on websocket.disconnect.

Does sending to a closed WebSocket raise in Starlette?

Yes, on Uvicorn 0.54 the next send raised WebSocketDisconnect in testing, both after a clean close and after the client's TCP connection was dropped.

Can several tasks send on the same FastAPI WebSocket?

Route their messages through one writer task and a bounded queue instead, which keeps frames ordered and makes slow clients a visible, bounded problem.

How much memory does each FastAPI WebSocket connection use?

In testing, about 60.8 KiB per idle connection with Starlette on Uvicorn, before any state your application attaches. Measure with your own handlers.