Practical WebSocket Implementation
Table of Contents
This guide is for developers who understand bidirectional communication but are unsure how to manage authentication and connections in a service. Using a browser client and Django Channels, we will identify the user, recover lost connections, and deliver messages across servers.
The diagrams are local simulations. Use their controls to change conditions and follow each stage; they do not connect to a live server.
HTTP APIs often carry tokens in an Authorization header. WebSocket also uses headers during its HTTP handshake, but the browser’s native WebSocket API does not let you set a custom Authorization header. Server-side client libraries may support it.
Browsers have several ways to pass credentials.
| Delivery method | What to consider |
|---|---|
| Cookie | Can reuse session authentication. Configure cookie scope and validate the connection’s Origin on the server. |
| URL query parameter | Can be checked during the handshake, but credentials may appear in proxy and server URL logs. This also affects server-to-server traffic. |
| First message after connecting | Keeps the token out of the URL. Requires blocking service access until authentication and limiting the time allowed to authenticate. |
We will send a token in the first message after the connection opens. JWT is a token format; a query parameter is a delivery mechanism. A JWT can also be sent in a URL, so these are not competing categories of authentication.
Token expiry or rotation does not automatically close an existing WebSocket. Decide whether the server should close it or allow reauthentication. Authentication delivery options
Accepting a connection does not authenticate its user. Compare the valid and expired token paths below.
Connected does not mean authenticated
Choose a token, then follow its first message to the server.
tokenValid token- Connect
- Send token
- Validate
- Result
Accepting a WebSocket connection only opens the transport. Service messages are still blocked.
A local simulation of first-message authentication, not a connection to a live service.
The client sends its first message after open and waits for an authenticated response before sending service messages. These message types are application conventions defined for this article, not built-in WebSocket messages.
socket.addEventListener("open", () => {
socket.send(JSON.stringify({
type: "authenticate",
token: accessToken,
}));
});
You can inspect this exchange in DevTools. The guide uses Chrome’s English menu labels. Chrome’s message inspection reference
Read the authentication exchange
Use Chrome DevTools to find the connection and inspect its messages.
wss://example.com/ws/chat/101websocketWS narrows the request list to WebSocket connections.
Open Network, select the WS filter, then reload or reconnect while DevTools is open.
This panel is an example, not an embedded DevTools session. Menu labels may differ by Chrome version and language.
A Channels Consumer handles events for a connection and stores connection information in scope. The presence of scope["user"] alone does not prove authentication: session middleware can populate it with AnonymousUser.
The example below tracks first-message authentication explicitly. The parent handles authentication and passes only authenticated service messages to receive_authenticated(). A child cannot accidentally continue with service logic just because a call to its parent’s receive() returned.
This example uses an asynchronous Channels Consumer and PyJWT 2.x. Install channels and PyJWT in your Django project and configure these assumptions:
- Set
WS_JWT_KEY,WS_JWT_ISSUER, andWS_JWT_AUDIENCEin Django settings to match the issuer. Keep the signing key in server configuration such as an environment variable. - Tokens use
HS256. Theirsubis the user’s primary key as a string;exp,iss, andaudare required. Adapt the validation configuration and lookup to your actual issuer. - The user model has
is_active. Route WebSocket requests toMyServiceConsumer.as_asgi()and wrap routing withAllowedHostsOriginValidatoror an explicitOriginValidator.
import asyncio
import json
import time
from contextlib import suppress
import jwt
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
@database_sync_to_async
def fetch_user_from_token(token):
claims = jwt.decode(
token,
settings.WS_JWT_KEY,
algorithms=["HS256"],
issuer=settings.WS_JWT_ISSUER,
audience=settings.WS_JWT_AUDIENCE,
options={"require": ["exp", "sub", "iss", "aud"]},
)
User = get_user_model()
user = User.objects.get(pk=claims["sub"], is_active=True)
return user, float(claims["exp"])
class AuthWebsocketConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.authenticated = False
self.closing = False
self.deadline = time.monotonic() + 5
await self.accept()
self.auth_timeout = asyncio.create_task(self.expire_authentication())
async def expire_authentication(self):
await asyncio.sleep(max(0, self.deadline - time.monotonic()))
if not self.authenticated:
await self.reject()
async def reject(self, code=1008):
self.closing = True
await self.close(code=code)
async def receive(self, text_data=None, bytes_data=None):
if self.closing:
return
if text_data is None:
await self.reject(code=1003)
return
try:
message = json.loads(text_data)
except json.JSONDecodeError:
await self.reject(code=1007)
return
if not isinstance(message, dict):
await self.reject(code=1007)
return
if not self.authenticated:
token = message.get("token")
if (time.monotonic() >= self.deadline
or message.get("type") != "authenticate"
or not isinstance(token, str)):
await self.reject()
return
try:
user, expires_at = await fetch_user_from_token(token)
except (jwt.InvalidTokenError, get_user_model().DoesNotExist,
ValueError, TypeError, ValidationError):
await self.reject()
return
# The timeout may have fired while the database query was running.
if self.closing or time.monotonic() >= self.deadline:
if not self.closing:
await self.reject()
return
if expires_at <= time.time():
await self.reject()
return
self.scope["user"] = user
self.expires_at = expires_at
self.authenticated = True
self.auth_timeout.cancel()
await self.send(text_data=json.dumps({"type": "authenticated"}))
return # Do not pass the authentication message to the service.
if time.time() >= self.expires_at:
await self.reject()
return
await self.receive_authenticated(message)
async def receive_authenticated(self, message):
raise NotImplementedError
async def disconnect(self, close_code):
self.closing = True
self.auth_timeout.cancel()
with suppress(asyncio.CancelledError):
await self.auth_timeout
class MyServiceConsumer(AuthWebsocketConsumer):
async def receive_authenticated(self, message):
# Validate each operation and its permissions here.
await self.send(text_data=json.dumps({"type": "received"}))
Malformed JSON, an unexpected first message, and an invalid token close the connection and return. Calling close() alone does not stop the remaining Python code. We also check the deadline after the database lookup so a slow lookup cannot approve authentication after the timeout.
The expiry policy here is to check exp on every incoming service message and close an expired connection. Closing an idle connection at the exact expiry time requires a separate timer. Authentication also does not replace permission checks for room membership or individual operations.
Channels authentication · Origin validation · PyJWT validation options
Network changes, temporary outages, and server restarts can interrupt a connection. The simplest response is to reconnect on close.
function connect() {
const socket = new WebSocket("wss://example.com/ws/chat/");
socket.addEventListener("close", connect);
}
During a sustained outage, this retries without a pause. If many clients disconnect together, they can overwhelm a recovering server. Instead, increase the delay limit after each failure and add randomness, or jitter, to the actual wait.
The following example doubles the delay limit from one second up to sixteen seconds. It waits between half the limit and just below the limit, distributing even the first retry. The limit resets only after an authenticated connection stays open for thirty seconds, so rapidly opening and closing connections do not continually reset the backoff.
function createConnection(url, getToken, onMessage, onAuthRequired) {
const initialDelay = 1000;
const maxDelay = 16000;
let delayLimit = initialDelay;
let socket = null;
let retryTimer = null;
let stableTimer = null;
let stopped = false;
let authenticated = false;
function connect() {
if (stopped) return;
const current = new WebSocket(url);
socket = current;
current.addEventListener("open", () => {
if (stopped || socket !== current) return;
// getToken synchronously returns the current access token.
try {
current.send(JSON.stringify({
type: "authenticate", token: getToken(),
}));
} catch {
current.close(1008);
}
});
current.addEventListener("message", (event) => {
if (stopped || socket !== current) return;
let message;
try {
message = JSON.parse(event.data);
if (!message || typeof message !== "object") throw new Error();
} catch {
current.close(1007);
return;
}
if (message.type === "authenticated") {
if (authenticated) return;
authenticated = true;
// Reset only after 30 seconds of a stable, authenticated connection.
stableTimer = setTimeout(() => { delayLimit = initialDelay; }, 30000);
return;
}
if (authenticated) onMessage(message);
});
current.addEventListener("close", (event) => {
if (socket !== current) return;
clearTimeout(stableTimer);
authenticated = false;
socket = null;
if (stopped) return;
if ([1000, 1002, 1003, 1007, 1008, 1009].includes(event.code)) {
if (event.code === 1008) onAuthRequired();
return;
}
// Equal jitter: wait between half the limit and the full limit.
const delay = Math.floor(delayLimit / 2 + Math.random() * delayLimit / 2);
delayLimit = Math.min(maxDelay, delayLimit * 2);
retryTimer = setTimeout(connect, delay);
});
}
connect();
return {
send(message) {
if (!authenticated || socket?.readyState !== WebSocket.OPEN) return false;
socket.send(JSON.stringify(message));
return true;
},
stop() {
stopped = true;
authenticated = false;
clearTimeout(retryTimer);
clearTimeout(stableTimer);
socket?.close(1000);
},
};
}
getToken() is an application function that synchronously returns the current access token. Complete any asynchronous token acquisition or refresh beforehand. onMessage receives authenticated service messages; onAuthRequired can initiate token refresh or login. Call the old connection’s stop() before creating a replacement.
Normal closure (1000) and authentication, protocol, or message-format failures are not retried automatically. In particular, 1008 is a general policy-violation code: adapt its handling to your server’s contract. The server in this article also uses it for failed or expired authentication. Calling stop() when leaving the screen cancels any scheduled retry.
WebSocket close codes · Backoff and jitter
Reopening a connection does not recover messages sent while it was down. Authenticate the new connection and rejoin the required rooms. Recovering missed messages requires server-side storage and a query based on the last processed message ID or cursor.
If the client resends unsent messages, define message IDs and deduplication behavior so the same operation is not applied twice. Reconnection restores the transport; these steps restore service data.
More simultaneous connections or message traffic can exhaust a server’s resources. Vertical scaling adds resources such as CPU and memory to one server. Horizontal scaling adds application servers.
More resources, or more servers?
Start with one server and apply an expansion.
Both strategies start from the same setup.
A structural comparison, not a capacity benchmark. Neither strategy offers unlimited capacity.
Vertical scaling keeps the deployment relatively simple but has per-machine limits. Horizontal scaling distributes load across instances, while adding coordination costs and potential database or broker bottlenecks. Choose based on the measured bottleneck and operational cost; neither approach offers unlimited capacity.
Suppose Cheolsu and Yeonghui are in room A, while Cheolsu and Minsu are in room B. Cheolsu and Yeonghui connect to server 1; Minsu connects to server 2.
If room state lives only in each server’s memory, server 1 can send only to its own connections. Cheolsu’s message to room B has no route to Minsu on server 2.
Can a message reach another server?
Cheolsu and Minsu share room B but connect to different servers.
Send a message, then advance one step at a time. Compare both modes.
A local simulation. The broker represents the shared channel layer; message persistence and replay require separate design.
Pub/Sub distributes published events to their subscribers. In this example, the server-to-server recipient is a Consumer running on another server. Browsers continue to communicate with their own WebSocket servers.
Django Channels provides a channel layer and group APIs for this. A Redis-backed channels_redis layer can provide shared delivery in production. An in-memory layer cannot communicate across processes.
Can a message reach another server?
Cheolsu and Minsu share room B but connect to different servers.
Send a message, then advance one step at a time. Compare both modes.
A local simulation. The broker represents the shared channel layer; message persistence and replay require separate design.
Install channels_redis and configure all application servers to use the same Redis layer. The localhost address below is for local development.
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {"hosts": [("127.0.0.1", 6379)]},
},
}
After authenticating and checking room access, a Consumer adds its channel_name to the group. After checking the sender’s membership and message, group_send() delivers an event to group members, whose Consumers handle it on their respective servers.
The following is an excerpt of the relevant calls and event handlers, to integrate into the Consumer above. Put group_add() in room-join handling and group_send() in service-message handling. Implement room selection and authorization for your application.
# Inside an authenticated AsyncWebsocketConsumer.
# First check that scope["user"] is allowed to join the requested room.
await self.channel_layer.group_add("room.B", self.channel_name)
# After checking the sender's room membership and message shape:
await self.channel_layer.group_send(
"room.B",
{"type": "chat.message", "text": "Hello, room B!"},
)
# Each recipient Consumer handles the event on its own server.
async def chat_message(self, event):
if self.closing or not self.authenticated:
return
if time.time() >= self.expires_at:
await self.reject()
return
await self.send(text_data=json.dumps({
"type": "chat.message", "text": event["text"],
}))
# On disconnect, discard the group and run the parent's cleanup.
async def disconnect(self, close_code):
await self.channel_layer.group_discard("room.B", self.channel_name)
await super().disconnect(close_code)
An event with type: "chat.message" is dispatched to chat_message(). That handler calls self.send() to deliver it to the browser attached to its Consumer. Minsu receives the room B message; Yeonghui, who belongs only to room A, does not.
Group delivery is not a message history store. A shared layer does not automatically provide persistence or replay after a reconnect. If cursor-based recovery is required, add storage and a retrieval procedure. Channels groups and channel layers
- Confirm that invalid tokens and service messages sent before authentication never reach the service handler.
- Confirm that retries spread out during an outage and stop after an intentional disconnect.
- Confirm that users in the same room receive messages across servers, while users in other rooms do not.
- Confirm that reauthentication, room subscriptions, and missed-message recovery run in the required order after reconnecting.
Checking authentication, connection management, and cross-server delivery separately also makes it easier to locate the stage where a failure occurs.