How to Implement a WebSocket Server in Rust

Web
Implement a Rust WebSocket server using tokio-tungstenite to handle async connections and bidirectional messaging.

The persistent pipe

You are building a multiplayer game lobby or a live stock ticker. The client needs updates the millisecond they happen. HTTP requests feel like shouting across a canyon and waiting for an echo. You send a request, wait for a response, and the connection closes. If you want new data, you have to shout again. This polling pattern wastes bandwidth, introduces latency, and scales poorly. You need a persistent, two-way pipe where the server can push data without the client asking. That is a WebSocket.

WebSockets start as an HTTP request but upgrade to a different protocol. The client sends a special header asking to switch protocols. The server agrees, and the connection transforms. The HTTP framing disappears. You get a raw, full-duplex stream. Both sides can send messages at any time. The connection stays open until one side closes it.

Rust handles this with async I/O. You do not want a thread per connection. Threads are heavy. Each thread consumes stack memory and incurs context-switching costs. If you spawn a thread for every WebSocket, your server runs out of memory or CPU time long before you hit thousands of clients. You want one thread managing thousands of connections, switching between them only when data arrives. That is the async runtime.

WebSockets turn your server into a broadcaster, not just a responder.

Async and the runtime

Rust's standard library does not include an async runtime. The language provides the async and await keywords, but you need a runtime to execute the futures those keywords produce. The most common runtime is tokio. It creates a thread pool and an event loop. The event loop watches for I/O readiness. When a socket has data, the loop wakes up the task waiting on that socket.

Think of a restaurant kitchen with one chef. The chef starts cooking ten dishes. Some dishes need to bake in the oven. The chef does not stand there staring at the oven. The chef moves to the next dish that needs chopping. When the oven timer dings, the chef returns to that dish. The chef is the runtime thread. The dishes are the connections. The oven is the network. The chef switches tasks instantly when waiting, maximizing throughput.

tokio uses this model. You write code that looks synchronous, but every I/O operation yields control back to the runtime when it would block. The runtime schedules other work. When the I/O completes, your code resumes exactly where it left off. This gives you the mental model of blocking code with the performance of non-blocking I/O.

Async is the secret sauce. One thread, thousands of connections, zero wasted time.

Minimal echo server

The standard way to build a WebSocket server in Rust is the tokio-tungstenite crate. It combines tokio for the runtime with tungstenite, a pure Rust WebSocket implementation. Add these to your Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.24"

The full feature enables all tokio components. In production, trim this to rt-multi-thread, net, and macros to reduce compile times and binary size. For learning, full removes friction.

Here is a minimal server that echoes messages back to the client.

use tokio_tungstenite::tungstenite::Message;

/// Start a WebSocket server that echoes messages back to clients.
#[tokio::main]
async fn main() {
    // Bind to localhost on port 8080.
    // This creates a TCP listener that accepts incoming connections.
    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap();
    println!("Listening on ws://127.0.0.1:8080");

    loop {
        // Accept a new TCP connection.
        // This yields until a client connects.
        let (stream, _) = listener.accept().await.unwrap();
        
        // Upgrade the TCP stream to a WebSocket.
        // This performs the handshake and negotiates the protocol.
        let ws_stream = tokio_tungstenite::accept_async(stream).await.unwrap();
        
        // Spawn a task to handle this connection independently.
        // This allows the loop to accept new connections immediately.
        tokio::spawn(handle_connection(ws_stream));
    }
}

/// Handle a single WebSocket connection.
async fn handle_connection(mut ws_stream: tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
    // Read messages until the stream ends or an error occurs.
    while let Some(result) = ws_stream.next().await {
        match result {
            Ok(msg) => {
                // Echo the message back to the client.
                if ws_stream.send(msg).await.is_err() {
                    // Client disconnected or send failed.
                    break;
                }
            }
            Err(e) => {
                eprintln!("WebSocket error: {e}");
                break;
            }
        }
    }
}

The #[tokio::main] macro transforms main into an async entry point. It sets up the runtime and blocks the main thread until the future returned by main completes. TcpListener::bind creates a socket. The loop waits for connections. listener.accept() yields until a client connects. accept_async performs the WebSocket handshake. It checks headers, validates the Sec-WebSocket-Key, and transforms the raw TCP stream into a WebSocket stream.

tokio::spawn is critical. It creates a green thread, or task. This task runs concurrently with the main loop. If you did not spawn, the loop would block waiting for the first client to finish before accepting the second. spawn lets the loop go back to listening immediately. The spawned task owns the ws_stream. When the task finishes, the stream drops and the connection closes.

Inside the handler, ws_stream.next() reads messages. It returns an Option<Result<Message, Error>>. Some(Ok(msg)) means a message arrived. Some(Err(e)) means a protocol error occurred. None means the stream ended. The loop breaks on errors or disconnection. ws_stream.send() writes a message. If the client disconnected, send fails. Checking the error prevents panics.

Spawn early. If you do not spawn, your server handles one client and dies.

Realistic broadcast server

Real applications often need to broadcast messages to multiple clients. A chat server sends every message to all connected users. A live dashboard pushes updates to every viewer. You need a way to share state between tasks. Rust's ownership system prevents sharing mutable state across threads without synchronization. The idiomatic solution is channels.

tokio::sync::broadcast provides a multi-producer, multi-consumer channel. Producers send messages. Consumers receive copies of every message. If a consumer falls behind, it drops old messages. This fits WebSocket broadcasting perfectly.

use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::Message;

/// Start a WebSocket server that broadcasts messages to all clients.
#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap();
    println!("Listening on ws://127.0.0.1:8080");

    // Create a broadcast channel with a buffer of 100 messages.
    let (tx, _rx) = broadcast::channel(100);

    loop {
        let (stream, _) = listener.accept().await.unwrap();
        let ws_stream = tokio_tungstenite::accept_async(stream).await.unwrap();
        
        // Clone the sender for this task.
        // Cloning is cheap and does not duplicate the channel.
        let tx_clone = tx.clone();
        
        tokio::spawn(handle_connection(ws_stream, tx_clone));
    }
}

/// Handle a connection and broadcast messages.
async fn handle_connection(
    mut ws_stream: tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
    tx: broadcast::Sender<Message>,
) {
    // Split the stream to allow concurrent read and write.
    let (mut write, mut read) = ws_stream.split();
    
    // Create a receiver for this connection.
    let mut rx = tx.subscribe();

    // Merge read and write into a single loop.
    // This pattern avoids deadlocks and keeps code simple.
    loop {
        tokio::select! {
            // Try to read a message from the client.
            msg = read.next() => {
                match msg {
                    Some(Ok(message)) => {
                        // Broadcast the message to all other clients.
                        // Ignore errors from send; some receivers may be lagging.
                        let _ = tx.send(message);
                    }
                    Some(Err(e)) => {
                        eprintln!("Error: {e}");
                        break;
                    }
                    None => {
                        // Client disconnected.
                        break;
                    }
                }
            }
            // Try to receive a message from the channel.
            msg = rx.recv() => {
                match msg {
                    Ok(message) => {
                        // Send the message to this client.
                        if write.send(message).await.is_err() {
                            break;
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        // This client is falling behind.
                        // Drop old messages to catch up.
                        eprintln!("Client lagged by {n} messages");
                    }
                    Err(broadcast::error::RecvError::Closed) => {
                        // Channel closed.
                        break;
                    }
                }
            }
        }
    }
}

The broadcast::channel creates a sender and receiver. The buffer size limits backpressure. If producers send faster than consumers, the buffer fills. Once full, producers block or drop messages depending on configuration. tx.clone() creates a new sender handle. This is cheap. It increments a reference count. Every task gets its own sender.

ws_stream.split() divides the stream into read and write halves. This allows concurrent access. tokio::select! waits on multiple futures. It runs the first one that completes. This pattern merges reading from the client and receiving from the channel into one loop. Without select!, you would need two separate loops or complex synchronization.

The receiver handles lag. If a client is slow, it falls behind the channel. recv() returns a Lagged error with the number of skipped messages. You can log this or drop the connection. Ignoring lag silently causes clients to receive stale data.

Share state through channels, not references. The compiler will thank you.

Pitfalls and errors

Async Rust has specific traps. The borrow checker protects you, but the error messages can be opaque if you do not know the patterns.

If you try to spawn a task that holds a reference to a local variable, the compiler stops you. The task might outlive the variable. You will see E0597 (borrowed value does not live long enough). The task requires 'static bounds. It cannot borrow data from the stack. Move the data into the task or use an Arc to share ownership.

// BAD: This fails to compile.
let data = String::from("hello");
tokio::spawn(async {
    println!("{data}"); // Error: data does not live long enough
});

// GOOD: Move data into the task.
let data = String::from("hello");
tokio::spawn(async move {
    println!("{data}");
});

If you split a stream and try to use the original variable, you get E0382 (use of moved value). split consumes the stream. You must use the returned halves.

Blocking the async runtime stalls all connections. If you call std::thread::sleep or run a CPU-heavy loop inside an async function, you block the worker thread. Other tasks cannot run. Use tokio::task::spawn_blocking for heavy work. It runs the closure on a separate thread pool.

// BAD: Blocks the runtime.
async fn heavy_work() {
    std::thread::sleep(std::time::Duration::from_secs(1));
}

// GOOD: Offloads to blocking pool.
async fn heavy_work() {
    tokio::task::spawn_blocking(|| {
        std::thread::sleep(std::time::Duration::from_secs(1));
    }).await.unwrap();
}

WebSocket clients send Ping frames to check if the server is alive. If you do not respond with Pong, the client assumes you are dead and disconnects. tungstenite handles pongs automatically. If you disable this behavior, you must implement ping/pong handling manually. Ignoring pings causes silent disconnects.

The runtime is a shared resource. Block it, and you block everyone.

Decision matrix

Choosing the right tool depends on your application's complexity. Rust offers several options for WebSockets.

Use tokio-tungstenite when you want a lightweight, low-level WebSocket server with minimal dependencies. It gives you direct control over the protocol and integrates seamlessly with tokio. It is ideal for custom servers, embedded systems, or when you are building a higher-level abstraction.

Use axum when you are building a full web application with routing, middleware, and structured error handling. Axum provides a WebSocketUpgrade extractor and integrates with the broader ecosystem. It is the default choice for new web services.

Use actix-web when you need a battle-tested ecosystem with high performance and extensive community plugins. Actix has been around longer and has a vast library of extensions. It uses its own runtime, which is compatible with tokio but has a different API.

Use hyper when you are building a custom HTTP stack and need maximum control over the protocol implementation. Hyper is lower level than axum or actix. It requires more boilerplate but offers fine-grained tuning.

Reach for tokio-tungstenite when you just need WebSockets and do not want the overhead of a full web framework.

Pick the tool that matches your complexity. Do not bring a framework to a knife fight.

Where to go next