Lesson 4 - WebSockets
On this page
Welcome to WebSockets
So far every endpoint in this course has followed the same shape: the client sends a request, the server sends one response, and the conversation is over. That covers most APIs beautifully — but some apps need more. A chat window, a live dashboard, a multiplayer game, a collaborative editor: in all of these, the server needs to push updates to the client whenever something happens, and the client needs to send messages back at any time too. A one-shot request/response can’t do that.
WebSockets are the answer. A WebSocket is a single, long-lived connection that stays open, and both sides can send messages over it whenever they like. In this lesson you’ll build the smallest possible WebSocket — an echo server — and learn the handful of moves that every WebSocket endpoint uses.
By the end of this lesson, you will be able to:
- Explain how a WebSocket differs from request/response and from one-way streaming (SSE)
- Write a WebSocket endpoint that accepts a connection and exchanges messages in a loop
- Handle a client disconnecting cleanly with
WebSocketDisconnect - Test a WebSocket from Python using the
TestClient
This is a gentle first look at real-time work. Let’s open a connection.
What a WebSocket Is
A normal HTTP request is like sending a letter: you write one message, mail it, and wait for exactly one reply. The exchange is finished as soon as the reply arrives. That’s request/response, and it’s what every @app.get and @app.post endpoint you’ve written does.
A WebSocket is more like a phone call. You dial once (the connection is established), and then the line stays open. Either side can speak whenever they want — the server can send you something without you asking, and you can talk back — until someone hangs up. That open, two-way channel is the whole point.
It helps to compare it to the streaming you may have seen with Server-Sent Events (SSE). SSE also keeps a connection open, but it only flows one way: the server streams data to the client, and the client can’t talk back over that channel. A WebSocket is bidirectional — both sides can send messages at any time. That’s why WebSockets power things like chat (you send a message and receive others’ messages) and collaborative apps (your edits go up, everyone else’s come down), where SSE alone wouldn’t be enough.
Three quick traits to keep in mind:
- Persistent — the connection opens once and stays open, instead of a fresh request per message.
- Bidirectional — server and client can each send messages independently.
- Message-based — you exchange discrete messages (text or bytes), not a single response body.
For our running Task Manager, a natural fit would be a “live updates” channel: a client opens a WebSocket and the server pushes a small message every time a task is created or completed, so a dashboard updates instantly without polling. We’ll keep the code in this lesson to a tiny echo server, but that’s the kind of feature WebSockets unlock.
The Echo Endpoint
A WebSocket endpoint looks a little different from the routes you know. Instead of @app.get, you use @app.websocket, the function is async def, and it takes a WebSocket object that represents the live connection.
Here’s the smallest useful example — a server that echoes back whatever you send it:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
@app.websocket("/ws")
async def ws_echo(websocket: WebSocket):
await websocket.accept()
try:
while True:
msg = await websocket.receive_text()
await websocket.send_text(f"echo: {msg}")
except WebSocketDisconnect:
passRead it top to bottom and the pattern is clear:
await websocket.accept()— this completes the handshake and opens the connection. Until you callaccept(), no messages flow. Think of it as picking up the phone.while True:— because the connection stays open, the endpoint loops instead of returning. Each pass through the loop handles one exchange.await websocket.receive_text()— wait for the next text message from the client. Theawaitmeans the worker isn’t blocked while waiting; it can serve other connections in the meantime (exactly the concurrency idea from Lesson 1).await websocket.send_text(...)— send a text message back over the same connection.
Notice there’s no return of a response body. A WebSocket endpoint’s job isn’t to produce one reply — it’s to keep the conversation going for as long as the connection lives.
Handling Disconnects
That while True loop runs forever — so what stops it? The client. When the browser tab closes, the network drops, or the client simply hangs up, the next await websocket.receive_text() can’t get a message. FastAPI signals this by raising a WebSocketDisconnect exception.
That’s exactly why the loop is wrapped in a try/except:
try:
while True:
msg = await websocket.receive_text()
await websocket.send_text(f"echo: {msg}")
except WebSocketDisconnect:
passWhen the client disconnects, receive_text() raises WebSocketDisconnect, the loop breaks out, and the except block catches it. Here we just pass — there’s nothing to clean up in an echo server. In a real app this is the perfect place to do housekeeping: remove the connection from a list of active dashboard viewers, log that a user left a chat room, or release any per-connection resources.
Catching WebSocketDisconnect is the one piece of error handling every WebSocket endpoint needs. Without it, a normal client hangup would surface as an unhandled exception in your logs. With it, disconnects are an ordinary, expected part of the connection’s life cycle.
Connecting and Testing
On the client side, a WebSocket uses its own URL scheme. Where HTTP uses http:// and https://, WebSockets use ws:// (unencrypted) and wss:// (encrypted over TLS). A browser would connect to our endpoint with something like new WebSocket("ws://localhost:8000/ws") in JavaScript.
But you don’t need a browser to try it. FastAPI’s TestClient can open a WebSocket connection directly in Python with websocket_connect, which is perfect for testing and for seeing the behavior without running a real server. You use it as a context manager, and inside the with block you call send_text and receive_text just like a real client:
from fastapi.testclient import TestClient
client = TestClient(app)
with client.websocket_connect("/ws") as ws:
ws.send_text("hello")
print(ws.receive_text())
ws.send_text("fastapi")
print(ws.receive_text())Running this prints:
echo: hello
echo: fastapiEach send_text goes to the server, the loop echoes it back with the echo: prefix, and receive_text reads the reply. The conversation is genuinely two-way and stateful: the same connection handled both exchanges.
And the disconnect handling? It’s already covered here. When the with block ends, the TestClient closes the connection. On the server, the next await websocket.receive_text() raises WebSocketDisconnect, the loop breaks, and the endpoint finishes cleanly — no error, no leak. That’s the full life cycle in one short test: connect, exchange messages, disconnect.
WebSocket vs SSE, and ws:// vs wss://
Reach for a WebSocket when both sides need to send messages — chat, live collaboration, interactive dashboards. If the data only ever flows server to client (a price ticker, a progress feed), one-way SSE is simpler and works over plain HTTP. On the URL scheme: use ws:// only for local development. In production, always use wss:// so the connection is encrypted with TLS — just as you’d use https:// over http://.
Practice Exercises
Exercise 1: Why a loop instead of a return?
A teammate writes a WebSocket endpoint that calls accept(), then receive_text() once, sends one reply, and the function ends. Clients say the connection closes after a single message. Why, and what’s the fix?
Hint
When the endpoint function returns, the connection closes — a WebSocket lives only as long as the handler runs. To keep it open for an ongoing conversation, wrap the receive/send in a while True: loop so the handler stays inside it, processing message after message until the client disconnects.
Exercise 2: WebSocket or SSE?
For each feature, decide whether a WebSocket or one-way SSE is the better fit: (a) a stock-price ticker the user only watches, (b) a two-player chess game, (c) a chat room.
Hint
(a) is server-to-client only — SSE is simpler and enough. (b) and (c) both need messages flowing in both directions (moves up and down, messages sent and received), so they need a WebSocket’s bidirectional channel.
Exercise 3: The missing except
An endpoint has the accept() and the while True: receive/send loop, but no try/except. It works fine in testing, yet production logs fill with errors every time a user closes the tab. What’s happening?
Hint
When a client disconnects, the next receive_text() raises WebSocketDisconnect. With no except to catch it, that becomes an unhandled exception logged as an error — even though a hangup is completely normal. Wrap the loop in try: ... except WebSocketDisconnect: so disconnects are handled gracefully (and put any cleanup there).
Summary
A WebSocket is a single, persistent, two-way connection — unlike one-shot request/response, and unlike one-way SSE, both the server and the client can send messages anytime. That makes it the right tool for chat, live dashboards, and collaborative apps. You build one with @app.websocket on an async def that takes a WebSocket: call await websocket.accept() to open the connection, then loop over await websocket.receive_text() and await websocket.send_text(...) to keep the conversation going. Because the loop runs until the client hangs up, you wrap it in a try/except for WebSocketDisconnect to handle the disconnect cleanly. Clients connect with ws:// (or wss:// in production), and you can test the whole life cycle in Python with the TestClient’s websocket_connect.
Key Concepts
- WebSocket — a persistent, bidirectional connection where both sides can send messages at any time.
accept()— completes the handshake and opens the connection; nothing flows until you call it.- Receive/send loop — a
while True:ofreceive_text()/send_text()keeps the conversation alive. WebSocketDisconnect— raised when the client hangs up; catch it to end the loop and clean up.ws://vswss://— the WebSocket URL schemes; use the encryptedwss://in production.
Why This Matters
Request/response is perfect for most APIs, but the moment your product needs real-time, two-way interaction — a live task dashboard, a chat, a notification feed users can also act on — WebSockets are how you deliver it without clumsy polling. The pattern is small and consistent: accept, loop, handle disconnect. Knowing it means you can add a “live updates” channel to a service like the Task Manager with just a few lines, building directly on the async event loop from earlier in this module.
Next Steps
Continue to Lesson 5 - Guided Project: Streaming Endpoint
Put it all together by building a streaming endpoint in a hands-on guided project.
Back to Module Overview
Return to the Async, Background Work, and Streaming module overview
Continue Building Your Skills
You can now open a persistent, two-way channel and exchange messages in real time — the foundation for chat, live dashboards, and collaborative features. Next you’ll bring the threads of this module together in a guided project, building a streaming endpoint end to end.