DH
10 min read

WebSockets in FastAPI and Next.js: Scaling Real-Time Connections Behind a Load Balancer

Build production WebSocket architectures that survive load balancers. Protocol fundamentals, connection management, authentication, and horizontal scaling without message loss.

fastapinextjsperformance

Real-time features are where a lot of backend architectures quietly fall apart. You add live notifications, collaborative editing, or a chat interface, and everything works beautifully on a single server. Then you put a load balancer in front and half your users stop receiving messages. It's not a FastAPI problem or a Next.js problem — it's an architectural problem that needs addressing before you write a single line of WebSocket code.

This tutorial covers FastAPI WebSockets from the ground up: the protocol, the endpoint, connection management, authentication, frontend integration, testing, and — critically — how to scale horizontally without losing messages.


What WebSockets Actually Are (and Why HTTP Polling Falls Short)

HTTP is request/response. The client asks, the server answers, and the connection closes. If you want live data over plain HTTP, you resort to polling — the client hammers the server every second or two asking "anything new?" This works, but it's wasteful, introduces latency, and burns through connections under load.

WebSockets solve this with a persistent, full-duplex connection. The browser sends an HTTP upgrade request (the handshake), the server agrees, and from that moment on both sides can send frames at any time without waiting for the other to ask. It's bi-directional in the truest sense: one open TCP connection, messages flowing in both directions simultaneously.

FastAPI has first-class support for WebSockets because it's built on Starlette, which handles the underlying ASGI WebSocket lifecycle. You get clean async primitives without any of the callback-soup you'd see in older frameworks.


Installation and Setup

Create a virtual environment and install your dependencies:

python -m venv .venv
source .venv/bin/activate
pip install fastapi uvicorn[standard] websockets

The websockets library is what Uvicorn uses under the hood for the WebSocket transport. Starlette (which FastAPI wraps) provides the WebSocket class you'll use directly.

Your minimal main.py to verify everything is wired up:

from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
async def health():
return {"status": "ok"}

Run it:

uvicorn main:app --reload --host 0.0.0.0 --port 8000

Creating a WebSocket Endpoint

The @app.websocket decorator defines a WebSocket route, much like @app.get or @app.post. The handler receives a WebSocket instance, and the first thing you must do is call websocket.accept() — this completes the handshake and opens the connection.

from fastapi import FastAPI, WebSocket

app = FastAPI()

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
await websocket.send_text("Connection established")
await websocket.close()

That's a valid WebSocket route, though not a terribly useful one. In practice you'll keep the connection alive in a loop.


Sending and Receiving Messages

Inside the handler you drive the conversation with await calls. The core methods are:

  • await websocket.receive_text() — waits for the next text frame from the client
  • await websocket.send_text(data) — pushes a text frame to the client
  • await websocket.receive_bytes() — waits for a binary frame
  • await websocket.send_bytes(data) — pushes a binary frame

A basic echo server that keeps the connection open:

@app.websocket("/ws/echo")
async def echo(websocket: WebSocket):
await websocket.accept()
while True:
message = await websocket.receive_text()
await websocket.send_text(f"Echo: {message}")

The while True loop is correct here. receive_text() suspends the coroutine until data arrives, so you're not busy-spinning — the event loop is free to handle other connections in the meantime. The loop only terminates when the connection closes.


Handling Disconnections

If the client closes the browser tab, loses network, or the connection is dropped for any other reason, the next call to receive_text() (or any other receive method) raises a WebSocketDisconnect exception. Fail to handle it and your handler crashes.

from fastapi import FastAPI, WebSocket
from starlette.websockets import WebSocketDisconnect

@app.websocket("/ws/echo")
async def echo(websocket: WebSocket):
await websocket.accept()
try:
while True:
message = await websocket.receive_text()
await websocket.send_text(f"Echo: {message}")
except WebSocketDisconnect:
print("Client disconnected")

Always wrap your receive loop in a try/except WebSocketDisconnect block. Any state cleanup — removing from a connection list, decrementing a counter, persisting a session — belongs in the except or a finally block.


Managing Multiple Connections with a Connection Manager

A single-endpoint echo server is fine for illustration. Real applications need to broadcast to multiple connected clients — a chat room, a live dashboard, a collaborative document.

The standard pattern is a ConnectionManager class that maintains a list of active_connections and exposes connect, disconnect, and broadcast methods:

from fastapi import FastAPI, WebSocket
from starlette.websockets import WebSocketDisconnect
from typing import List

class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []

async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)

def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)

async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)

manager = ConnectionManager()

@app.websocket("/ws/chat")
async def chat(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
await manager.broadcast(data)
except WebSocketDisconnect:
manager.disconnect(websocket)
await manager.broadcast("A user left the chat")

This pattern works correctly on a single process. The scaling problem surfaces the moment you run two or more Uvicorn workers — or two containers behind a load balancer. active_connections is in-process memory. Worker A has no idea about the connections held by Worker B. If you broadcast on Worker A, Worker B's clients see nothing.

Scaling with a Pub/Sub Broker

The canonical fix is to move the broadcast layer out of process memory and into a shared pub/sub broker — Redis being the most common choice. Each worker subscribes to a shared channel. When any worker receives a message, it publishes to Redis. Every other worker receives the publication and forwards it to its local connections.

import asyncio
import redis.asyncio as aioredis

redis_client = aioredis.from_url("redis://localhost:6379")

async def redis_listener(manager: ConnectionManager, channel: str):
pubsub = redis_client.pubsub()
await pubsub.subscribe(channel)
async for message in pubsub.listen():
if message["type"] == "message":
await manager.broadcast(message["data"].decode())

@app.on_event("startup")
async def startup():
asyncio.create_task(redis_listener(manager, "chat"))

With this in place, every worker broadcasts to its own local connections, but publishes first to Redis — ensuring all workers participate. The load balancer can now distribute WebSocket upgrade requests freely across workers without messages going missing.


WebSocket Authentication

HTTP headers are sent during the WebSocket handshake, but the browser's WebSocket constructor doesn't expose a way to set custom headers like Authorization: Bearer <token>. This is a well-known limitation of the browser WebSocket API.

Your options:

1. Pass the token as a query parameter. Simple, but the token appears in server access logs.

2. Use Sec-WebSocket-Protocol as a carrier. The browser sends the token as a subprotocol, and your server extracts it during the handshake.

3. Authenticate via a cookie. If your auth already sets an HttpOnly cookie, the browser sends it automatically on the WebSocket upgrade request. This is often the cleanest approach.

Using Depends with a query parameter:

from fastapi import WebSocket, Query, HTTPException, Depends

async def get_token(
websocket: WebSocket,
token: str = Query(...)
):
if token != "valid-secret":
await websocket.close(code=1008)
raise HTTPException(status_code=403, detail="Forbidden")
return token

@app.websocket("/ws/secure")
async def secure_ws(
websocket: WebSocket,
token: str = Depends(get_token)
):
await websocket.accept()
await websocket.send_text(f"Authenticated as {token}")
...

Depends works on WebSocket handlers exactly as it does on HTTP handlers — FastAPI's dependency injection system makes no distinction.

For cookie-based auth:

from fastapi import Cookie

async def verify_session(
websocket: WebSocket,
session_id: str = Cookie(None)
):
if not session_id or not is_valid_session(session_id):
await websocket.close(code=1008)
raise HTTPException(status_code=403)
return session_id

Frontend Integration with Next.js

On the Next.js side, you're working with the browser's native WebSocket API. Inside a React component:

"use client";

import { useEffect, useRef, useState } from "react";

export default function ChatComponent() {
const ws = useRef<WebSocket | null>(null);
const [messages, setMessages] = useState<string[]>([]);

useEffect(() => {
ws.current = new WebSocket("ws://localhost:8000/ws/chat");

ws.current.onmessage = (event) => {
setMessages((prev) => [...prev, event.data]);
};

ws.current.onclose = () => {
console.log("WebSocket closed");
};

return () => {
ws.current?.close();
};
}, []);

const sendMessage = (text: string) => {
ws.current?.send(text);
};

return (
<div>
{messages.map((msg, i) => <p key={i}>{msg}</p>)}
<button onClick={() => sendMessage("Hello")}>Send</button>
</div>
);
}

Use ws:// for local development and wss:// in production. The useRef pattern prevents the connection from being torn down and re-created on every render. The cleanup function in useEffect closes the socket when the component unmounts — important for avoiding connection leaks during hot module replacement.


Testing WebSocket Endpoints

FastAPI inherits Starlette's TestClient, which supports WebSocket connections in synchronous tests. You don't need a running server:

from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_echo():
with client.websocket_connect("/ws/echo") as websocket:
websocket.send_text("hello")
data = websocket.receive_text()
assert data == "Echo: hello"

def test_disconnect_handling():
with client.websocket_connect("/ws/chat") as websocket:
websocket.send_text("joining")
# Connection closes on context manager exit

websocket_connect returns a context manager. When you exit the with block, the client sends a close frame, which triggers WebSocketDisconnect on the server side. Your disconnect-handling code is exercised automatically.

For pytest, just drop these into a standard test file and run pytest. No additional fixtures are needed.


File Transfer Over WebSockets

Binary frames let you send raw bytes — file uploads, images, audio chunks — without base64 encoding overhead. On the server:

@app.websocket("/ws/upload")
async def file_upload(websocket: WebSocket):
await websocket.accept()
chunks = []
try:
while True:
chunk = await websocket.receive_bytes()
chunks.append(chunk)
await websocket.send_text(f"Received {len(chunk)} bytes")
except WebSocketDisconnect:
file_data = b"".join(chunks)
# persist file_data as needed

On the client, read the file as an ArrayBuffer and send it in chunks:

const handleUpload = async (file: File) => {
const CHUNK_SIZE = 64 * 1024;
const buffer = await file.arrayBuffer();
let offset = 0;
while (offset < buffer.byteLength) {
const chunk = buffer.slice(offset, offset + CHUNK_SIZE);
ws.current?.send(chunk);
offset += CHUNK_SIZE;
}
};

Chunking prevents large payloads from blocking the WebSocket connection for other clients. For production file transfer, add a framing protocol — a small binary header carrying chunk index, total chunk count, and a file identifier — so the server can reassemble out-of-order chunks safely.


Deployment and Security Considerations

Always use wss:// in production. Plain ws:// transmits data unencrypted. Terminate TLS at your load balancer (AWS ALB, Cloudflare, nginx), and proxy to your FastAPI workers over plain ws:// on the internal network.

Validate the Origin header server-side to prevent cross-site WebSocket hijacking. CORS doesn't apply to WebSocket upgrades in the same way it applies to HTTP requests. Starlette's CORSMiddleware does not cover WebSocket connections; write explicit origin validation in your accept logic.

Pub/sub via Redis is more resilient than sticky sessions. Some teams use sticky sessions at the load balancer — routing each client to the same worker consistently. This works until a worker restarts. Pub/sub via Redis (or a similar broker) handles worker churn gracefully and is the pattern to reach for in production.

Connection limits. Each open WebSocket is a file descriptor. On Linux, the default per-process limit is typically 1,024. For high-concurrency applications, raise the ulimit (or the container's nofile limit) and tune Uvicorn's --workers and --limit-concurrency flags.

Graceful shutdown. When Kubernetes or your process manager sends SIGTERM, Uvicorn begins refusing new connections, but existing WebSocket connections stay open until they close naturally. Set a drain timeout in your orchestrator — usually 30–60 seconds — and implement a server-side close frame so clients can reconnect cleanly rather than timing out.


Closing Thoughts

FastAPI WebSockets are well-designed and genuinely pleasant to work with. The sharp edges are almost entirely at the infrastructure layer: sticky sessions, cross-worker broadcasting, origin validation, and graceful drain on deploy. Nail those four things and you have a real-time backend that scales horizontally without drama.

The Next.js side is largely just the browser WebSocket API with React lifecycle management layered on top. Keep the socket in a useRef, clean up on unmount, and reconnect with exponential backoff when the connection drops — the rest is product.

Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps