WebSockets
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9 (feature ws, built on tokio-tungstenite 0.26) on Rust 1.99 (edition 2024)
with tokio 1.53, checked on 2026-10-06. Bookshelf pushes newly posted reviews to every open catalogue page through a
WebSocket. For one-way server-to-client updates, Server-Sent Events are often simpler; the comparison is on
Server-Sent Events & Streaming.
Where it runs in the request
In the request lifecycle (tokio accepts
the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and
extractors run, the handler runs, the response travels back out), a WebSocket starts as an ordinary GET that goes
through every step — routing, middleware (authentication, CORS origin checks), the WebSocketUpgrade extractor and
the handler — and the handler’s response is 101 Switching Protocols. After that, the connection leaves the
request lifecycle: hyper hands the raw connection to the upgrade, and the socket task runs outside the tower stack, so
middleware, timeouts and graceful shutdown no longer apply to it (see
Graceful Shutdown for closing sockets on shutdown).
The upgrade handshake and the connection lifecycle
A WebSocket starts as an HTTP GET with Upgrade: websocket. The WebSocketUpgrade extractor validates the handshake
headers; the handler returns ws.on_upgrade(callback), which sends 101 Switching Protocols and later runs the
callback with the upgraded WebSocket on its own task. Everything after that is frames, not HTTP:
The Bookshelf review feed
use std::time::Duration;
use axum::{
Router,
extract::{
State,
ws::{Message, Utf8Bytes, WebSocket, WebSocketUpgrade},
},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
routing::{any, post},
};
use futures::{SinkExt, StreamExt};
use tokio::sync::broadcast;
const PING_EVERY: Duration = Duration::from_secs(30);
const IDLE_TIMEOUT: Duration = Duration::from_secs(60);
const ALLOWED_ORIGINS: [&str; 2] = ["https://bookshelf.example", "http://localhost:3000"];
#[derive(Clone)]
struct AppState {
// one Sender, cloned into state: broadcast::Sender is already cheap to clone and thread-safe
reviews: broadcast::Sender<String>,
}
async fn ws_handler(State(state): State<AppState>, headers: HeaderMap, ws: WebSocketUpgrade) -> Response {
// Browsers send Origin on the upgrade; reject cross-site pages (CORS does not apply to WebSockets).
let origin = headers.get(header::ORIGIN).and_then(|o| o.to_str().ok());
if origin.is_some_and(|o| !ALLOWED_ORIGINS.contains(&o)) {
return StatusCode::FORBIDDEN.into_response();
}
// (authenticate here with the session cookie or a one-time ticket; see below)
ws.protocols(["bookshelf.reviews.v1"]) // subprotocol negotiation
.max_message_size(64 * 1024)
.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: AppState) {
let (mut sink, mut stream) = socket.split();
let mut feed = state.reviews.subscribe();
// writer: broadcast messages and periodic pings -> client
let mut writer = tokio::spawn(async move {
let mut ping = tokio::time::interval(PING_EVERY);
loop {
tokio::select! {
msg = feed.recv() => match msg {
Ok(review) => {
if sink.send(Message::Text(Utf8Bytes::from(review))).await.is_err() {
break; // client gone
}
}
// a slow client missed messages: tell it, keep going (or close it)
Err(broadcast::error::RecvError::Lagged(n)) => {
let notice = format!("{{\"type\":\"lagged\",\"missed\":{n}}}");
if sink.send(Message::Text(notice.into())).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Closed) => break,
},
_ = ping.tick() => {
if sink.send(Message::Ping(axum::body::Bytes::from_static(b"keepalive"))).await.is_err() {
break;
}
}
}
}
});
// reader: client -> server, with an idle timeout (pongs count as activity)
let mut reader = tokio::spawn(async move {
loop {
match tokio::time::timeout(IDLE_TIMEOUT, stream.next()).await {
Ok(Some(Ok(Message::Text(text)))) => tracing::debug!(%text, "client message"),
Ok(Some(Ok(Message::Close(_)))) | Ok(None) | Ok(Some(Err(_))) => break,
Ok(Some(Ok(_))) => {} // Ping (answered automatically), Pong, Binary
Err(_) => break, // idle for too long
}
}
});
// when either side finishes, stop the other
tokio::select! {
_ = &mut writer => reader.abort(),
_ = &mut reader => writer.abort(),
}
}
async fn post_review(State(state): State<AppState>, body: String) -> StatusCode {
let _ = state.reviews.send(body); // Err only if nobody is subscribed
StatusCode::ACCEPTED
}
#[tokio::main]
async fn main() {
let (reviews, _) = broadcast::channel(256);
let app = Router::new()
.route("/ws", any(ws_handler)) // any(): HTTP/1.1 GET upgrades and HTTP/2 CONNECT (RFC 8441)
.route("/api/v1/reviews", post(post_review))
.with_state(AppState { reviews });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum: extract::ws, axum example: websockets and chat
$ python3 -c '
import asyncio, websockets
async def main():
async with websockets.connect("ws://localhost:3000/ws", subprotocols=["bookshelf.reviews.v1"]) as ws:
print("subprotocol:", ws.subprotocol)
print("received:", await ws.recv())
asyncio.run(main())' &
$ curl -s -d 'Dune: 5 stars' localhost:3000/api/v1/reviews
subprotocol: bookshelf.reviews.v1
received: Dune: 5 stars
Source: axum: extract::ws, axum example: websockets and chat
Messages
Message has five variants: Text(Utf8Bytes), Binary(Bytes), Ping(Bytes), Pong(Bytes) and Close(Option<CloseFrame>).
In axum 0.8, Text holds a Utf8Bytes and Binary a Bytes instead of String and Vec<u8>: messages can be cloned
and sent to many clients without copying. Convert with .into() from String/&'static str, and read with
text.as_str(). Ping frames from the client are answered with pongs automatically; you only send pings yourself, as
the writer does, to detect dead connections behind proxies that drop idle TCP connections silently. Axum 0.8 also
removed WebSocket::close: send a Message::Close frame to close cleanly.
Splitting into reader and writer
A WebSocket is both a Stream of incoming messages and a Sink of outgoing ones. split() (from
futures::StreamExt) separates the two halves, so one task can read while another writes — the standard structure
for anything more than echo. tokio::select! on the two JoinHandles ends the connection as soon as either side
stops, and abort() cancels the other; otherwise a closed client would leave a writer task subscribed forever.
Fan-out: broadcast, slow consumers and several instances
tokio::sync::broadcast delivers every message to every subscriber, with a bounded buffer per channel. A subscriber
that falls more than the capacity behind gets RecvError::Lagged(n) and continues from the oldest retained message;
the writer above notifies the client, which can reload the list. Dropping slow clients instead (break) is equally
valid. This bounded behaviour is the backpressure strategy: one slow phone on a bad network can never make the server
buffer unboundedly or slow down everyone else.
The Sender is placed in state directly; it is Clone, Send and Sync, so it needs no Arc<Mutex<...>>. With
several replicas behind a load balancer, each process has its own channel; bridge them with Redis pub/sub as shown on
Redis & Caching.
Subprotocols (0.8.9)
Clients can propose application protocols in Sec-WebSocket-Protocol. ws.protocols([...]) selects the first
server-supported protocol the client offered and echoes it in the response; if none matches, the upgrade still succeeds
without one. Since 0.8.9, ws.requested_protocols() and ws.set_selected_protocol(...) allow custom selection logic
(for example by version number).
WebSockets over HTTP/2
axum 0.8 supports RFC 8441 WebSockets over HTTP/2, where the upgrade is an extended CONNECT request rather than a
GET. Route the endpoint with any(handler) instead of get(handler) and enable the http2 feature; HTTP/1.1
clients keep working. The official websockets-http2 example shows a browser connecting through TLS with ALPN h2.
Authenticating browser clients
The browser WebSocket API cannot set an Authorization header. Use one of:
-
the session cookie, which the browser sends with the upgrade request: extract the session (or
CurrentUser) inws_handlerexactly as in an HTTP handler, and checkOriginas above — otherwise any website could open a socket with the user’s cookies ("cross-site WebSocket hijacking"); -
a short-lived ticket: an authenticated
POST /api/v1/ws-ticketreturns a random single-use token, the client connects to/ws?ticket=..., and the handler validates and deletes it before upgrading.
Authentication happens before on_upgrade, while you can still answer with an HTTP status. Long-lived tokens in the
URL leak into logs; tickets expire within seconds.
Testing
The official testing-websockets example tests a WebSocket handler in two ways: against a real server bound to port 0
with tokio-tungstenite as the client, and in process by writing the socket logic against generic Sink/Stream
traits so it can be fed by channels. The Testing page shows both.
Limits and idle timeouts
A socket is long-lived and unbounded by default, so the limits that protect HTTP endpoints have to be set on the
socket itself. WebSocketUpgrade configures the underlying tungstenite connection before on_upgrade: the defaults
allow 64 MiB messages and 16 MiB frames, far more than a review feed needs. An idle timeout around recv() closes
sockets whose client vanished without a close frame:
use std::time::Duration;
use axum::{
Router,
extract::ws::{Message, WebSocket, WebSocketUpgrade},
response::Response,
routing::get,
};
const IDLE: Duration = Duration::from_secs(60);
async fn ws_handler(ws: WebSocketUpgrade) -> Response {
ws.max_message_size(64 * 1024) // reviews are small: refuse anything larger
.max_frame_size(64 * 1024)
.on_upgrade(handle_socket)
}
async fn handle_socket(mut socket: WebSocket) {
loop {
match tokio::time::timeout(IDLE, socket.recv()).await {
Err(_elapsed) => {
// nothing received for IDLE (not even a pong): close and free the task
let _ = socket.send(Message::Close(None)).await;
break;
}
Ok(Some(Ok(Message::Text(text)))) => {
if socket.send(Message::Text(text)).await.is_err() {
break;
}
}
Ok(Some(Ok(_))) => {} // ping, pong, binary
Ok(Some(Err(_)) | None) => break, // protocol error, oversized message or closed
}
}
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/ws", get(ws_handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
-
A message larger than the limit makes
recv()return an error and ends the connection, so a single client cannot make the server buffer megabytes per socket. -
The idle timeout pairs with the writer’s periodic pings shown earlier: a live client answers pings with pongs, which count as received frames and reset the timer.
-
TimeoutLayerand the body limits do not apply after the upgrade; these settings are the socket’s equivalents. -
Count open sockets (a gauge incremented in
on_upgradeand decremented when the task ends) and cap them per user if clients can open many; each socket is a task and two buffers.
Common errors
| Response or symptom | Cause |
|---|---|
|
The client sent another method, or an HTTP/2 extended |
|
A proxy in front of the service dropped the hop-by-hop |
|
The request did not come through hyper’s upgrade machinery — typically a test calling the router with |
The socket closes after exactly 60 s (or the proxy’s timeout) of quiet |
An intermediary’s idle timeout. Send pings more often than the shortest idle timeout on the path. |
Clients see code 1006 on every deploy |
The socket tasks are not closed on shutdown; see Graceful Shutdown. |
Where the books are outdated
Yoon (Beginning Axum, chapter 5) wraps the broadcast::Sender in Arc<Mutex<...>>, which is unnecessary (the
sender is already shareable) and adds lock contention; it also authenticates the socket with an Authorization header,
which browsers cannot send. Code written for axum 0.7 must change Message::Text(String) to Utf8Bytes and
Message::Binary(Vec<u8>) to Bytes, and replace WebSocket::close() with sending a close frame.