Skip to content

Rust WebSocket Guide: tokio-tungstenite, axum & JoinSet

Use tokio-tungstenite for standalone WebSocket servers and clients. Use tungstenite (without tokio) if you need blocking I/O. If you’re building a web application, use axum — it wraps tungstenite and gives you routing, middleware, and WebSocket upgrades in one framework.

Add the dependencies:

[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.26"
futures-util = "0.3"

A broadcast server that upgrades connections, splits read/write halves, and fans out messages:

use tokio::net::TcpListener;
use tokio::sync::broadcast;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
use futures_util::{SinkExt, StreamExt};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
let (tx, _) = broadcast::channel::<String>(256);
eprintln!("listening on 127.0.0.1:8080");
loop {
let (stream, addr) = listener.accept().await?;
let tx = tx.clone();
let mut rx = tx.subscribe();
tokio::spawn(async move {
let Ok(ws) = accept_async(stream).await else {
eprintln!("{addr}: handshake failed");
return;
};
let (mut sink, mut source) = ws.split();
let write = tokio::spawn(async move {
while let Ok(msg) = rx.recv().await {
if sink.send(Message::text(msg)).await.is_err() {
break;
}
}
});
while let Some(Ok(msg)) = source.next().await {
if let Message::Text(text) = msg {
let _ = tx.send(text.into());
}
}
write.abort();
eprintln!("{addr} disconnected");
});
}
}

ws.split() gives you two halves you can move into separate tasks. The broadcast channel handles fan-out. When a client disconnects, the read loop exits and the write task is aborted.

The server above runs forever. In production, you need to drain connections on SIGTERM. JoinSet tracks spawned tasks and lets you wait for all of them to finish:

use tokio::net::TcpListener;
use tokio::signal;
use tokio::task::JoinSet;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
use futures_util::{SinkExt, StreamExt};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
let mut tasks = JoinSet::new();
loop {
tokio::select! {
Ok((stream, addr)) = listener.accept() => {
tasks.spawn(async move {
let Ok(ws) = accept_async(stream).await else {
return;
};
let (mut sink, mut source) = ws.split();
while let Some(Ok(msg)) = source.next().await {
if let Message::Text(text) = msg {
let _ = sink.send(
Message::text(text.into())
).await;
}
}
eprintln!("{addr} disconnected");
});
}
_ = signal::ctrl_c() => {
eprintln!("shutting down, draining connections");
break;
}
}
}
// Wait for all active connections to finish
while tasks.join_next().await.is_some() {}
Ok(())
}

tokio::select! waits on both new connections and Ctrl+C. When the signal arrives, the loop breaks. JoinSet::join_next then waits for every in-flight connection to close. No tasks leak, no connections drop mid-message.

use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use futures_util::{SinkExt, StreamExt};
use std::time::Duration;
use rand::Rng;
async fn connect_with_backoff(url: &str) {
let mut delay = Duration::from_secs(1);
loop {
match connect_async(url).await {
Ok((ws, _)) => {
delay = Duration::from_secs(1);
let (mut sink, mut source) = ws.split();
let _ = sink.send(Message::text("hello")).await;
while let Some(Ok(msg)) = source.next().await {
eprintln!("received: {msg}");
}
eprintln!("disconnected, reconnecting...");
}
Err(e) => eprintln!("connect failed: {e}"),
}
// Jitter prevents all clients reconnecting at once
let jitter = rand::rng().random_range(0..500);
let wait = delay + Duration::from_millis(jitter);
tokio::time::sleep(wait).await;
delay = (delay * 2).min(Duration::from_secs(30));
}
}

Always add jitter. Without it, a server restart causes every client to reconnect at the same instant. That thundering herd can take down the new server before it finishes booting.

These are the problems that catch experienced developers who are new to Rust WebSocket code.

Split streams have different types. ws.split() returns a SplitSink and SplitStream with different concrete types. You can’t put them back together easily, and you can’t clone either half. Once you split, commit to it. If you need both read and write in the same task, use ws.next() and ws.send() directly instead of splitting.

Arc<Mutex<T>> vs channels. Your instinct from other languages is to wrap shared state in a mutex. In async Rust, holding a tokio::sync::Mutex across an .await is fine but blocks other tasks waiting on that lock. For fan-out (one message to many clients), use broadcast::channel. For request-response between tasks, use mpsc::channel. Reserve Arc<RwLock<T>> for data that is read often and written rarely, like a connection registry.

tokio::select! drops unfinished futures. When one branch completes, the other is cancelled. If you’re writing to a WebSocket in one branch and reading in another, the write may be dropped mid-send. Pin your futures if they hold state you care about, or restructure so each future is idempotent.

Backpressure is your problem. broadcast::channel drops messages when the receiver falls behind (it returns RecvError::Lagged). If you ignore this, slow clients silently miss messages. Either handle Lagged by catching up or disconnecting, or use an unbounded channel and accept the memory risk. There’s no free lunch.

Message::Text owns its data. Each Message::Text allocates a new String. For high-throughput servers, this allocation pressure adds up. Consider Message::Binary with a serialization format like MessagePack or Protobuf for hot paths.

Two patterns cover most use cases:

use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
use std::collections::HashMap;
// Fan-out: one message to many clients
// Use for chat, live updates, pub/sub
let (tx, _rx) = broadcast::channel::<String>(256);
// Registry: track connected clients
// Use for presence, targeted messaging
type Clients = Arc<RwLock<HashMap<String, broadcast::Sender<String>>>>;

For high-throughput registries, dashmap avoids holding a lock across .await points. This matters because a standard RwLock held across an await can block the tokio runtime’s thread pool if contention is high.

WebSocket servers are I/O-bound. Your server spends most of its time waiting on network reads, not doing computation. Rust’s CPU performance advantage barely matters when the bottleneck is the network.

Go handles tens of thousands of WebSocket connections with goroutines. Node.js does the same with its event loop. Both get you to production faster with more library options.

Use Rust for WebSockets when you need sub-millisecond latency consistency (trading systems, competitive gaming) or when each message requires CPU-heavy work (compression, encryption, real-time audio transforms). If you’re mostly routing messages between clients, your architecture matters more than your language: horizontal scaling, state management, and a protocol layer on top of raw WebSockets.

Rust also has no equivalent of Socket.IO or Phoenix Channels. No off-the-shelf reconnection with message replay, room management, or presence tracking. You build all of that yourself. For most teams, this cost outweighs the runtime advantage. If you need that infrastructure without building it, managed WebSocket services handle connection management, ordering, and failover across any language.

tokio-tungstenite for async. It’s the most downloaded, best maintained, and works directly with tokio. If you’re building a web app with routing and middleware, use axum instead — it wraps tungstenite internally and gives you WebSocket upgrades alongside your HTTP routes. For the rare case where you need synchronous (blocking) WebSockets, use tungstenite directly.

actix-web also has WebSocket support, but axum has overtaken it in adoption and is where the Rust web ecosystem is heading. actix-web’s actor model adds complexity that most WebSocket servers don’t need. New projects should default to axum.

For latency-critical workloads, yes. No GC pauses means your p99 latencies stay flat under sustained load. Memory usage is predictable and low — a connection costs kilobytes, not megabytes.

The question is whether you need that. A Go WebSocket server handles 100K concurrent connections on modest hardware. Rust might handle 200K. But your architecture doesn’t change at either scale. You still need horizontal scaling, health checks, and a reconnection strategy. Pick Rust when latency predictability is a hard requirement, not when “more connections” sounds appealing.

How do I handle multiple connections in Rust?

Section titled “How do I handle multiple connections in Rust?”

Spawn one tokio task per connection. Each task owns its half of the split WebSocket stream. Share state between tasks using broadcast::channel for fan-out or Arc<RwLock<T>> for a connection registry. The borrow checker prevents data races at compile time — if it compiles, you don’t have a race condition in your shared state access.

For graceful shutdown, use JoinSet to track all spawned tasks and drain them on SIGTERM. Without this, a kill or deployment drops every active connection immediately.

How does Rust WebSocket performance compare?

Section titled “How does Rust WebSocket performance compare?”

Rust has the lowest latency and most predictable throughput of any mainstream language for WebSocket servers. No garbage collector means no tail-latency spikes. But the performance gap only matters for specific workloads. If your server processes messages (compression, ML inference, real-time encoding), Rust’s advantage is real. If your server just routes messages between clients, Go or Node.js will be within 10-20% of Rust’s throughput and get you to production months faster.