When polling isn't enough
You are building a live dashboard. The server tracks sensor data, and the client needs updates the moment values change. Polling the server every second wastes bandwidth, drains battery, and introduces lag. You need a persistent channel. The server pushes updates to the client instantly. The client sends commands, and the server replies without waiting for a new request.
That is WebSockets. In Rust, you do not write the handshake logic yourself. You use an extractor to handle the upgrade request and a handler to manage the message stream. The framework negotiates the protocol switch, then hands you a bidirectional stream you can read and write concurrently.
The upgrade handshake
HTTP works like a letter. You send a request, the server sends a response, and the connection closes. WebSockets starts as a letter but turns into a phone call. The browser sends a standard HTTP GET request with special headers asking to upgrade the connection. The server responds with a 101 Switching Protocols status. The connection stays open. Both sides can send data at any time.
This is called full-duplex communication. The "upgrade" is the critical moment. The client proposes the switch. The server accepts it. The underlying TCP connection remains, but the protocol changes from HTTP to WebSocket frames. Rust libraries handle the cryptographic key exchange and header validation. You focus on the application logic once the stream is open.
Minimal echo server
Start with a simple echo server. The server receives a message and sends it back immediately. This verifies the connection works and shows the basic API surface.
use axum::{
extract::ws::{WebSocket, WebSocketUpgrade},
response::Response,
routing::get,
Router,
};
#[tokio::main]
async fn main() {
// Build the router with a single WebSocket route.
// The handler expects a WebSocketUpgrade extractor.
let app = Router::new().route("/ws", get(ws_handler));
// Bind to localhost on port 3000.
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
/// Handles the WebSocket upgrade request and echoes messages.
async fn ws_handler(ws: WebSocketUpgrade) -> Response {
// on_upgrade registers a closure to run after the handshake completes.
// The closure receives the open WebSocket stream.
ws.on_upgrade(|mut socket| async move {
// Loop until the connection closes or an error occurs.
while let Some(Ok(msg)) = socket.recv().await {
// Echo the message back to the client.
// If send fails, the connection is broken, so break the loop.
if socket.send(msg).await.is_err() {
break;
}
}
})
}
Keep the handler focused. The upgrade logic is just the door; the handler is the room.
How the flow works
The client sends an HTTP GET to /ws. The request includes Upgrade: websocket and Connection: Upgrade. Axum matches the route and sees the WebSocketUpgrade type in the handler signature. It extracts the upgrade request from the HTTP headers.
You call on_upgrade. This returns a Response immediately. Axum sends the 101 Switching Protocols response to the client. The handshake completes. Axum then invokes the closure you passed to on_upgrade, providing a WebSocket object.
The WebSocket object is a stream of frames. You call recv().await to wait for the next message. It returns Option<Result<Message, Error>>. None means the peer closed the connection gracefully. Some(Ok(msg)) is a valid message. Some(Err(e)) is a protocol error or network failure. You call send().await to write a frame. The method returns a Result. An error means the send failed, usually because the connection dropped.
Realistic pattern: split and spawn
The echo server blocks on send while waiting for recv. If the client sends messages faster than the server can echo them, the send buffer fills up. The send call blocks. The recv loop stops. The connection deadlocks.
Real applications split the socket into separate read and write halves. This allows concurrent tasks to handle incoming and outgoing data. You spawn a task for receiving messages and another for sending.
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::response::Response;
use tokio::sync::mpsc;
/// Handles the WebSocket connection with split streams.
async fn ws_handler(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(|socket| async move {
// Split the socket into independent sender and receiver handles.
// This allows concurrent tasks to read and write without blocking each other.
let (mut sender, mut receiver) = socket.split();
// Create a channel for the receiver to send messages to the sender task.
// The channel decouples the read loop from the write loop.
let (tx, mut rx) = mpsc::channel::<Message>(32);
// Spawn a task to handle incoming messages.
// This task reads from the socket and forwards messages to the channel.
tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.recv().await {
// Forward the message to the sender task.
// If the channel is full or disconnected, log and continue.
if tx.send(msg).await.is_err() {
break;
}
}
});
// Main task handles outgoing messages from the channel.
// This keeps the write path separate and responsive.
while let Some(msg) = rx.recv().await {
if sender.send(msg).await.is_err() {
break;
}
}
})
}
Split the socket early. A single stream that tries to read and write at once will deadlock the moment traffic picks up.
Heartbeats and backpressure
Browsers and load balancers drop idle connections. If no data flows for a while, the connection dies. You must implement heartbeats. Send a Ping frame periodically. The client automatically replies with Pong. If you do not receive a Pong within a timeout, close the connection.
Backpressure is another concern. If the sender task produces messages faster than the network can transmit them, the send buffer fills up. The send call blocks. The channel fills up. The receiver task blocks on tx.send. The whole pipeline stalls. Handle backpressure by dropping old messages or pausing the producer.
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::response::Response;
use tokio::time::{interval, Duration};
/// Handles the WebSocket connection with heartbeats.
async fn ws_handler(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(|mut socket| async move {
// Create a ping interval for heartbeats.
// Ping every 30 seconds to keep the connection alive.
let mut ping_interval = interval(Duration::from_secs(30));
loop {
tokio::select! {
// Wait for incoming messages.
result = socket.recv() => {
match result {
Some(Ok(msg)) => {
// Process message...
if socket.send(msg).await.is_err() {
break;
}
}
Some(Err(_)) => {
// Error occurred, break the loop.
break;
}
None => {
// Connection closed gracefully.
break;
}
}
}
// Send ping on interval.
_ = ping_interval.tick() => {
if socket.send(Message::Ping(vec![])).await.is_err() {
break;
}
}
}
}
})
}
Heartbeats keep the connection alive. Without them, your server will see phantom disconnects under load balancers.
Pitfalls and compiler errors
The borrow checker protects you from common concurrency mistakes. If you try to send while holding a mutable borrow of the socket for recv, the compiler rejects you with E0502 (cannot borrow as mutable because it is also borrowed as immutable). You must split the socket or restructure the code to release borrows.
If you forget to await a future, the compiler warns you. If you try to use a WebSocket in a non-async context, you get trait bound errors. The WebSocket type implements Stream and Sink, which require async methods.
Generic code often triggers E0277 (trait bound not satisfied) when you try to pass a WebSocket through a function that expects a specific trait. Ensure your functions accept the correct types or use trait objects.
Convention aside: Use Text messages for JSON or human-readable data. Use Binary messages for raw bytes. Mixing types without agreement between client and server causes parsing errors. The Rust client and server should agree on the message format.
Convention aside: Keep unsafe out of WebSocket code. The tungstenite and axum crates handle the protocol safely. You do not need raw pointers or manual memory management for standard WebSocket usage.
Trust the borrow checker on split. It forces you to write concurrent code that actually works.
Choosing your stack
Use Axum's WebSocketUpgrade when you are building a web server with Axum and want ergonomic integration with routes, middleware, and extractors.
Use tokio-tungstenite when you need a low-level WebSocket implementation, such as a standalone client, a custom protocol handler, or integration with a framework other than Axum.
Use actix-web WebSockets when your project is already built on the Actix ecosystem and you prefer its actor-based concurrency model.
Reach for HTTP long-polling when you must traverse restrictive firewalls or proxies that block WebSocket connections, accepting the overhead of repeated HTTP requests.
Pick the tool that matches your stack. Do not pull in a full framework for a simple client.