WebSockets
Build WebSocket clients and standalone servers with tokio-tungstenite when you are not using Axum's built-in WebSocket support.
Recipe
Quick-reference recipe card - copy-paste ready.
use tokio_tungstenite::connect_async;
use futures_util::{SinkExt, StreamExt};
use tungstenite::Message;
let (ws, _) = connect_async("wss://echo.websocket.events").await?;
let (mut write, mut read) = ws.split();
write.send(Message::Text("hello".into())).await?;
if let Some(Ok(Message::Text(reply))) = read.next().await {
println!("{reply}");
}When to reach for this: Standalone WebSocket clients, custom servers, or protocols outside Axum.
Working Example
use futures_util::{SinkExt, StreamExt};
use tokio::net::{TcpListener, TcpStream};
use tokio_tungstenite::accept_async;
use tungstenite::Message;
async fn handle_connection(stream: TcpStream) -> Result<(), Box<dyn std::error::Error>> {
let ws = accept_async(stream).await?;
let (mut write, mut read) = ws.split();
while let Some(msg) = read.next().await {
match msg? {
Message::Text(t) => write.send(Message::Text(format!("echo: {t}").into())).await?,
Message::Close(_) => break,
_ => {}
}
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:9002").await?;
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(handle_connection(stream));
}
Ok(())
}What this demonstrates:
accept_asynccompletes server-side handshake on TCP stream.- Split sink/stream for concurrent read/write tasks if needed.
- Handle
TextandClosemessages explicitly. - Client uses
connect_asyncsymmetrically.
Deep Dive
How It Works
- WebSocket starts as HTTP Upgrade request.
- Frames carry Text, Binary, Ping, Pong, Close opcodes.
tokio-tungsteniteintegrates with Tokio async IO.
Axum vs tungstenite
| Layer | Tool |
|---|---|
| Axum route | WebSocketUpgrade extractor |
| Raw TCP server | tokio-tungstenite::accept_async |
| Client | connect_async |
Rust Notes
// Ping keepalive
write.send(Message::Ping(vec![].into())).await?;Gotchas
- No TLS in accept_async - Plain TCP only; wrap with
tokio-rustlsfor WSS. Fix: TLS terminator or rustls handshake before accept. - Message size bombs - Default max message size can be exceeded maliciously. Fix: Configure
max_message_size/max_frame_size. - Half-open connections - Peer gone without Close frame. Fix: Periodic Ping and read timeout.
- Blocking on slow consumer - Unbounded channel to write loop. Fix: Bounded channel with drop policy.
- Origin not checked - CSWSH vulnerability on servers. Fix: Validate
Originheader during handshake.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Axum WebSocket | Already on Axum | Non-HTTP bootstrap |
| SSE | Server-to-client only | Bidirectional chat |
| WebTransport | Modern browsers, QUIC | Broad legacy support needed |
FAQs
wss client?
connect_async to wss:// with rustls-enabled connector feature.
Binary frames?
Message::Binary for protobuf or compressed payloads.
Authentication?
Query token at upgrade or cookie on initial HTTP request.
Scale horizontally?
Redis pub/sub bridge between server instances.
Compression?
permessage-deflate extension - enable carefully for CPU trade-off.
Test client?
tokio-tungstenite in integration test against local server.
Close codes?
Send CloseFrame with reason for debugging clients.
Axum page?
See WebSockets & SSE in web-backends for Axum-native pattern.
HTTP/2 WebSocket?
Rare; most WS is HTTP/1.1 upgrade path.
Idle timeout?
tokio::time::timeout around read.next().
Related
- WebSockets & SSE - Axum integration
- TLS with rustls - WSS encryption
- Low-Level Sockets - TCP beneath WS
- Authentication & Sessions - Securing upgrades
- Networking Best Practices - Real-time ops
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.