Skip to main content
  1. Languages/
  2. Rust Guides/

Building High-Performance Real-Time Apps with Rust, Axum, and WebSockets

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

In the landscape of modern web development in 2025, user expectations for interactivity are non-negotiable. Whether it’s a financial trading dashboard, a collaborative document editor, or a live gaming server, real-time communication is the backbone of user engagement.

While Node.js has historically been the go-to for WebSockets due to its event loop, it often hits a ceiling with CPU-bound tasks and heavy concurrency. Enter Rust. With its async ecosystem maturing significantly over the last few years, Rust offers a compelling alternative: memory safety without garbage collection pauses, predictable latency, and massive concurrency capabilities.

In this article, we will build a production-grade real-time chat server using Axum (a web framework built on top of Hyper and Tokio). We won’t just write “Hello World”; we will tackle state management, broadcasting, and handle the complexities of async Rust.


Prerequisites and Environment
#

Before we dive into the code, ensure your environment is ready. We are targeting intermediate-to-advanced Rust developers, so we assume familiarity with ownership and basic async concepts.

Requirements:

  • Rust Version: 1.85+ (Stable).
  • IDE: VS Code (with rust-analyzer) or JetBrains RustRover.
  • Toolchain: Standard cargo installation.

We will use the Tokio runtime. By 2025, Tokio has solidified itself as the de-facto standard for async I/O in Rust, and Axum provides the ergonomic layer on top of it.

Project Setup
#

Create a new binary project:

cargo new rust-realtime-chat
cd rust-realtime-chat

Update your Cargo.toml with the necessary dependencies. We need axum for the server, tokio for the runtime, serde for JSON serialization, and tracing for logging.

Cargo.toml

[package]
name = "rust-realtime-chat"
version = "0.1.0"
edition = "2021"

[dependencies]
# Web Framework
axum = { version = "0.8", features = ["ws"] }

# Async Runtime
tokio = { version = "1", features = ["full"] }

# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# Utilities
futures = "0.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }

Architecture: The Broadcast Pattern
#

Handling WebSockets in Rust requires a mental shift from the shared-mutable-state pattern often seen in other languages. Instead of protecting a list of clients with a global Mutex, we will use Channels.

Specifically, we will use a MPMC (Multi-Producer, Multi-Consumer) strategy using tokio::sync::broadcast.

How it works
#

  1. The Hub: A central broadcast channel is created when the server starts.
  2. Subscription: Every time a new WebSocket connection is established, it subscribes to the channel.
  3. Broadcasting: When a user sends a message, the handler sends it to the broadcast channel, which fan-outs the message to all other active subscribers.

Here is a visual representation of our data flow:

flowchart TD subgraph Server_Internal BC[Tokio Broadcast Channel] end C1[Client A] -- WebSocket --> H1[Handler A] C2[Client B] -- WebSocket --> H2[Handler B] C3[Client C] -- WebSocket --> H3[Handler C] %% Data Flow H1 -- Send Msg --> BC BC -. Fan out .-> H1 BC -. Fan out .-> H2 BC -. Fan out .-> H3 H1 -. Push Msg .-> C1 H2 -. Push Msg .-> C2 H3 -. Push Msg .-> C3 style BC fill:#dea,stroke:#333,stroke-width:2px style Server_Internal fill:#f9f9f9,stroke:#333,stroke-dasharray: 5 5

Step 1: Setting up the Axum Server
#

Let’s start by building the entry point in src/main.rs. We need to initialize the application state (which holds our broadcast transmitter) and bind the server to a port.

We will create an AppState struct to hold the broadcast::Sender. Note that we don’t need to hold the Receiver in the state; receivers are created on demand for each client.

src/main.rs (Part 1: Setup)

use axum::{
    extract::{ws::{Message, WebSocket, WebSocketUpgrade}, State},
    response::{Html, IntoResponse},
    routing::get,
    Router,
};
use futures::{sink::SinkExt, stream::StreamExt};
use std::{net::SocketAddr, sync::Arc};
use tokio::sync::broadcast;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

// Our shared state
struct AppState {
    // We only need the sender (tx) to broadcast messages.
    // Each client will create their own receiver (rx).
    tx: broadcast::Sender<String>,
}

#[tokio::main]
async fn main() {
    // 1. Initialize logging
    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::new(
            std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
        ))
        .with(tracing_subscriber::fmt::layer())
        .init();

    // 2. Create the broadcast channel
    // Capacity of 100 messages. If a client lags behind by >100 messages,
    // they will see a 'Lagged' error (we handle this later).
    let (tx, _rx) = broadcast::channel(100);

    let app_state = Arc::new(AppState { tx });

    // 3. Define Routes
    let app = Router::new()
        .route("/", get(index_page))
        .route("/ws", get(ws_handler))
        .with_state(app_state);

    // 4. Start Server
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::info!("listening on {}", addr);
    
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

// Simple HTML handler for testing
async fn index_page() -> Html<&'static str> {
    Html(include_str!("index.html"))
}

Create a dummy src/index.html file (we will fill it later) to satisfy the compiler.


Step 2: The WebSocket Upgrade
#

The HTTP handshake is the gateway. Axum makes this trivial with the WebSocketUpgrade extractor. If the headers are correct (Connection: Upgrade, Upgrade: websocket), Axum hands us a socket.

src/main.rs (Part 2: The Handler)

async fn ws_handler(
    ws: WebSocketUpgrade,
    State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
    // Upgrade the connection to a WebSocket
    ws.on_upgrade(|socket| handle_socket(socket, state))
}

This simply offloads the heavy lifting to handle_socket. This keeps our routing logic clean.


Step 3: Handling the Connection Loop
#

This is the core of the article. We need to handle two concurrent tasks for every connected client:

  1. Reading: Listen for incoming messages from this client and broadcast them to everyone.
  2. Writing: Listen for messages from the broadcast channel and send them to this client.

In Rust, we typically use tokio::select! to race these futures, or split the socket into a Stream (reader) and a Sink (writer). Splitting is usually cleaner for full-duplex communication.

src/main.rs (Part 3: The Logic)

async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
    // Split the socket into a sender and receiver
    let (mut sender, mut receiver) = socket.split();

    // Subscribe to the broadcast channel
    let mut rx = state.tx.subscribe();

    // Spawn a task to handle *incoming* broadcast messages and send them to this client.
    // We spawn this because the "read" loop below will block waiting for client input.
    let mut send_task = tokio::spawn(async move {
        while let Ok(msg) = rx.recv().await {
            // In a real app, you might want to serialize complex structs here
            if sender.send(Message::Text(msg)).await.is_err() {
                break; // Client disconnected
            }
        }
    });

    // Main task: Read from client, broadcast to others
    // We tag the sender with a simplified unique ID (for demo purposes)
    let tx = state.tx.clone();
    let name = format!("User-{}", fastrand::u16(0..1000)); 

    // Announce join
    let _ = tx.send(format!("{} joined the chat", name));

    while let Some(Ok(msg)) = receiver.next().await {
        if let Message::Text(text) = msg {
            // Broadcast the message
            let broadcast_msg = format!("{}: {}", name, text);
            let _ = tx.send(broadcast_msg);
        } else if let Message::Close(_) = msg {
            break;
        }
    }

    // Cleanup when the client disconnects
    let _ = tx.send(format!("{} left the chat", name));
    send_task.abort(); // Kill the writer task
}

Note: You’ll need to add fastrand = "2" to dependencies for the random ID, or just use uuid.

Key Concepts in the Code:
#

  • socket.split(): Separates the reading and writing halves. This allows us to move the sender into a separate Tokio task.
  • tokio::spawn: We spawn the writing logic. If we didn’t, we would have to use select!, which can be tricky if one side is much faster than the other.
  • tx.subscribe(): Crucial. Every client gets their own receiver handle pointed at the same broadcast stream.

Step 4: The Frontend Client
#

To verify our backend works, we need a simple client. Create src/index.html at the root of your src folder.

<!DOCTYPE html>
<html>
<head>
    <title>Rust Real-Time Chat</title>
    <style>
        body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #f4f4f9; }
        #chat { border: 1px solid #ddd; height: 400px; overflow-y: scroll; background: white; padding: 10px; margin-bottom: 10px; }
        .msg { padding: 5px; border-bottom: 1px solid #eee; }
        input { padding: 10px; width: 70%; }
        button { padding: 10px; width: 20%; background: #de5a22; color: white; border: none; cursor: pointer; }
    </style>
</head>
<body>
    <h2>Rust + Axum WebSocket Chat</h2>
    <div id="chat"></div>
    <input id="input" type="text" placeholder="Type a message..." autofocus>
    <button onclick="send()">Send</button>

    <script>
        const chat = document.getElementById('chat');
        const input = document.getElementById('input');
        // Connect to the WS endpoint
        const ws = new WebSocket("ws://" + location.host + "/ws");

        ws.onmessage = function(event) {
            const div = document.createElement('div');
            div.className = 'msg';
            div.textContent = event.data;
            chat.appendChild(div);
            chat.scrollTop = chat.scrollHeight; // Auto scroll
        };

        function send() {
            if(input.value) {
                ws.send(input.value);
                input.value = '';
            }
        }
        
        input.addEventListener("keypress", function(event) {
            if (event.key === "Enter") send();
        });
    </script>
</body>
</html>

Running the Project
#

  1. Run cargo run.
  2. Open http://localhost:3000 in multiple browser tabs.
  3. Type in one tab; see it appear instantly in the others.

Performance Analysis & Comparison
#

Why go through the trouble of Rust instead of writing a 20-line Node.js script? The answer lies in scalability and predictability.

When managing thousands of concurrent WebSocket connections, the overhead per connection becomes critical. Rust’s futures are zero-cost abstractions—they are state machines compiled down to efficient code, requiring no heap allocation per state transition (unlike Promises in JS which generate garbage).

Technology Stack Comparison
#

Feature Rust (Axum + Tokio) Node.js (Socket.io) Go (Gorilla/Nhooyr)
Concurrency Model Async/Await (Poll-based) Event Loop (Callback/Promise) Goroutines (Green Threads)
Memory Footprint Extremely Low (No GC) Moderate to High (V8 Overhead) Low (GC, but smaller stack)
CPU Bound Tasks Excellent (Multi-threaded) Poor (Single-threaded) Good
Garbage Collection None (RAII) Yes (Stop-the-world risk) Yes (Low latency)
Dev Velocity Moderate (Compiler strictness) Very High High

The Verdict: If you are building a simple prototype, Node.js is faster to write. However, for a high-frequency trading platform or a massive multiplayer game server where tail latency (p99) matters, Rust is the superior choice. The absence of Garbage Collection pauses ensures that a broadcast to 10,000 users doesn’t stutter because the runtime decided to clean up memory.


Common Pitfalls and Best Practices
#

Developing with WebSockets in Rust introduces specific challenges. Here is how to handle them in a production environment.

1. Handling Backpressure (The “Slow Client” Problem)
#

In our code, we used broadcast::channel(100). The Issue: If the server generates 200 messages per second, but a client on a poor mobile connection can only process 50, the channel buffer fills up. The Fix: tokio::sync::broadcast handles this by returning a Lagged error to the receiver. You must handle this error in your loop, usually by sending a “You missed messages” alert or simply skipping ahead.

// Inside the receive loop
match rx.recv().await {
    Ok(msg) => { /* send */ },
    Err(broadcast::error::RecvError::Lagged(count)) => {
        tracing::warn!("Client lagged by {} messages", count);
    },
    Err(_) => break,
}

2. File Descriptor Limits
#

WebSockets are persistent TCP connections. Linux typically defaults to a limit of 1024 open files. The Fix: In production (systemd or Docker), ensure you raise the ulimit (e.g., ulimit -n 65535) to allow massive concurrency.

3. Heartbeats (Ping/Pong)
#

Proxies and Load Balancers (like Nginx or AWS ALB) will drop idle connections (usually after 60 seconds). The Fix: You must implement a heartbeat. Axum/Tungstenite handles responding to Pings automatically, but you should configure the client or a server-side timer to send Pings periodically.

4. Serialization Overhead
#

Broadcasting a String requires cloning the string for every user (or using Arc). For JSON, serializing the struct to a string once and then broadcasting the Arc<str> or Bytes is significantly more CPU efficient than serializing it individually for every connected client.


Conclusion
#

We have successfully built a concurrent, real-time chat application using Rust and Axum. By leveraging Tokio’s broadcast channel, we achieved a fan-out architecture that is both memory-efficient and thread-safe.

Key Takeaways:

  1. Axum abstracts the low-level handshake, letting you focus on logic.
  2. Shared State in Rust is best handled via Channels (Message Passing) rather than complex Mutex locks for real-time data.
  3. Tokio Tasks allow you to handle reading and writing independently for full-duplex communication.

Rust is no longer just a systems language; it is a premier choice for the real-time web. As we move through 2025, the tooling is robust, the crates are stable, and the performance benefits are too large to ignore.

Further Reading:

Happy coding, and may your latencies be low!