Rust WebSocket apps with Axum and Tokio handle millions of concurrent connections on modest hardware. This guide covers WebSocket setup, broadcast channels, chat apps, and production patterns for 2026.
By Rustify Team, updated March 2026
TL;DR: Rust's async runtime (Tokio) and Axum's WebSocket support make it one of the best stacks for high-concurrency real-time apps. A Rust WebSocket server handles 100K+ concurrent connections on a single machine that would max out Node.js at 10K–20K.
- Axum WebSocket:
axum::extract::ws::WebSocket: upgrade handler, split into sender/receiver- Broadcast:
tokio::sync::broadcast::channel: fan-out messages to all connected clients- State sharing:
Arc<Mutex<HashMap<...>>>: shared connection registry across tasks- Heartbeat: ping/pong frames prevent silent disconnections from load balancers
- Scale-out: Redis pub/sub bridges WebSocket state across multiple server instances
Who Should Read This?
This article is for backend engineers and full-stack developers building real-time features (chat applications, live dashboards, collaborative tools, multiplayer games, or notification systems) who want to understand why Rust is often the best foundation for high-concurrency WebSocket infrastructure. It is most relevant to Node.js or Go developers who have hit connection limits or latency issues with their current stack, and to Rust developers who want concrete, working patterns for WebSocket architecture. It is also directly useful for engineers at gaming companies, fintech firms, and SaaS platforms where real-time features are core product functionality. Senior backend engineers specializing in real-time infrastructure at US companies earn $160K–$230K.
Why Is Rust Ideal for WebSocket Applications?
WebSocket servers hold thousands of open connections simultaneously; each connection is a long-lived task. Rust's async tasks are lighter than OS threads (~few KB vs ~2 MB), enabling 100K+ concurrent connections on a single machine.
Concurrency model comparison:
| Stack | Concurrency model | Connections per GB RAM | Notes |
|---|---|---|---|
| Node.js (ws) | Event loop + callbacks | ~10K–20K | Single-threaded, GC pauses affect latency |
| Go (gorilla/websocket) | Goroutines | ~50K–100K | GC pauses at high load |
| Rust (Tokio + Axum) | Async tasks | 100K–500K | Zero GC, predictable latency |
| Java (Spring WebSocket) | Thread pool | ~5K–10K | Thread-per-connection model |
The combination of zero garbage collection, M:N async task scheduling, and Rust's memory safety makes it the best choice for WebSocket-heavy applications.
How Do You Add WebSocket Support to Axum?
Axum has first-class WebSocket support. Upgrade an HTTP connection to WebSocket with the WebSocketUpgrade extractor, then handle the bidirectional stream.
Enable the ws feature in axum (axum = { version = "0.8", features = ["ws"] }). A handler takes a WebSocketUpgrade extractor and calls .on_upgrade() with a closure that receives the WebSocket. Inside the handler you receive messages with socket.recv().await, match on Message::Text, Message::Close, etc., and reply with socket.send(). When send() returns an error, the client has disconnected and you break out of the loop.
How Do You Build a Multi-Client Chat Application?
For a chat app, you need to broadcast messages from one client to all connected clients. Use tokio::sync::broadcast::channel as the message bus, with each connection holding a sender/receiver pair.
The AppState holds a broadcast::Sender<String>. Each connection handler calls state.tx.subscribe() to get its own receiver, then splits the WebSocket into a SplitSink and SplitStream. Two tasks run concurrently: one forwards broadcast messages from the channel to this client's WebSocket sender, and the other reads from the WebSocket and publishes to the broadcast channel. tokio::select! waits for either task to finish; on disconnect, it aborts the other. The broadcast channel capacity (e.g., 100) determines how many messages can buffer before slow clients start being dropped.
3 spots open this month → Check if you are eligible.
We help experienced developers transition into Rust roles at €80K–€150K+ in Europe or $130K–$200K+ in the US.
How Do You Track Connected Users?
For per-user messaging and user lists, maintain a connection registry: a HashMap mapping user IDs to their individual senders.
Use Arc<RwLock<HashMap<String, mpsc::UnboundedSender<Message>>>> as the registry type. On connect, insert the user's mpsc sender into the map. On disconnect (after the tokio::select! exits), remove it. To send a targeted message, read-lock the map, look up the target user's sender, and send. RwLock is preferable to Mutex here because reads (every message routed) vastly outnumber writes (connect/disconnect only).
How Do You Implement WebSocket Heartbeat and Ping-Pong?
Load balancers and NAT gateways silently drop idle connections after 30–90 seconds. A ping/pong heartbeat keeps connections alive and detects dead clients.
The pattern: use tokio::time::interval(Duration::from_secs(30)) in a tokio::select! loop alongside socket.recv(). Each tick sends a Message::Ping. Track a pending_pong flag: if the next tick fires before a Message::Pong arrives, the client is gone and you break the loop. On Message::Pong, clear the flag. This prevents silent dead connections accumulating in the registry and triggering the slow-client issues described above.
How Do You Scale WebSockets Across Multiple Servers?
A single Rust WebSocket server handles hundreds of thousands of connections, but when you need multiple instances for redundancy or horizontal scaling, you need a shared message bus.
The standard architecture for multi-server WebSocket scaling uses Redis pub/sub as the shared message bus. Each Rust server subscribes to a Redis channel. When Server 1 receives a message from Client A intended for Client C (connected to Server 3), it publishes to Redis. Server 3's subscription fires and forwards the message to Client C's local connection.
Each server subscribes to a Redis channel. When Server #1 receives a message from Client A intended for Client C (on Server #3), it publishes to Redis. Server #3's Redis subscription fires, and it forwards the message to Client C's local connection.
The redis crate in Rust supports async pub/sub with Tokio.
What Common Mistakes Do Developers Make When Building Rust WebSocket Servers?
-
Not splitting the WebSocket into sender and receiver halves.
socket.recv()andsocket.send()cannot be called concurrently on the sameWebSocketinstance. The correct pattern issocket.split()to get aSplitSinkandSplitStream, then spawn separate tasks for reading and writing. This is the most common beginner mistake in Axum WebSocket code. -
Using a
Mutexinstead ofRwLockfor the connection registry. The connection registry (HashMap of user IDs to senders) is read far more often than it is written (read on every message, written only on connect/disconnect). UseRwLockinstead ofMutexto allow concurrent reads. Under high connection counts, this makes a measurable difference in throughput. -
Not handling slow clients in broadcast scenarios.
broadcast::channelhas a fixed capacity. If a slow client cannot consume messages fast enough, the channel fills up andsend()returnsSendErrorfor slow receivers. Detect this and disconnect slow clients rather than letting them block the entire channel. The channel'slaggederror variant tells you a receiver missed messages. -
Forgetting to remove users from the registry on disconnect. When a WebSocket connection closes, the user must be removed from the connection map. If you forget this, the map grows indefinitely, and future messages routed to the old entry hit a closed sender. Always clean up in the post-select cleanup code.
-
Not implementing heartbeat/ping-pong in production. Without heartbeat, AWS ALB and other load balancers drop idle connections after 60 seconds (configurable). Connections appear active on the server but are silently dead. This causes message delivery failures that are hard to debug. Always implement a 30-second ping interval in production.
-
Using
Arc<Mutex<...>>for the broadcast sender instead of cloning it.broadcast::SenderisClone: you can clone it cheaply and distribute copies to each connection handler task. There is no need to wrap it inArc<Mutex<>>. This is a common pattern mistake from developers applying mutex-thinking to Tokio channels.
Keep Reading
- Rust Developer Salary in the USA (2026)
- How Long to Learn Rust by Background: Hours, Timeline & ROI
- Best Way to Learn Rust in 2026
- Is Rust Hard to Learn?
Frequently Asked Questions
WebSocket connections are stateful; a client connected to server 1 cannot receive messages from server 2 directly. Use Redis pub/sub as a cross-server message bus: each server subscribes to a Redis channel and forwards messages to its local connections. The redis crate in Rust supports async pub/sub with Tokio. This pattern is used in production by Discord, Slack, and most large real-time applications. Alternatively, use a sticky session load balancer to route a given client always to the same server, which is simpler but limits horizontal scaling flexibility.
broadcast::channel sends one message to all current subscribers (fan-out), ideal for chat rooms, live feeds, and push notifications. mpsc::channel (multiple producer, single consumer) sends to one specific receiver, ideal for per-user targeted messages and task coordination. Most real-time apps use both: broadcast for global events (new message in a room), mpsc for user-specific messages (direct messages, personal notifications). The two channel types are not interchangeable; use the right one for each communication pattern.
Server-side: accept new connections normally (WebSockets are stateless from the server's perspective). Client-side (JavaScript): implement exponential backoff reconnect logic with jitter. For session continuity (resuming missed messages after a disconnect), assign each client a session token on connect and buffer messages while disconnected. The server keeps the buffer for a configurable TTL (e.g., 60 seconds) and replays missed messages when the client reconnects with the same token.
SSE is simpler for one-way server-to-client push (notifications, live feeds, stock tickers): it is HTTP-based, works through proxies, and automatically reconnects. WebSockets are bidirectional, necessary for chat, collaborative editing, multiplayer games, and interactive real-time features. If your app only needs server-push, prefer SSE; Axum supports SSE via axum::response::sse::Sse. If you need both directions (client sends events, server pushes updates), use WebSockets.
tokio-tungstenite is the lower-level WebSocket library that Axum uses internally. Use it directly when you need a standalone WebSocket client (connecting to external WebSocket servers), or when integrating with frameworks other than Axum. For server-side WebSockets in an Axum application, use Axum's built-in ws feature, which is simpler and integrates with Axum's extractor and middleware stack. The tokio-tungstenite client is commonly used for consuming WebSocket market data feeds in trading systems and for testing WebSocket servers.
The WebSocket upgrade request is a standard HTTP request with headers. Validate authentication in the HTTP upgrade handler before calling .on_upgrade(). Pass the authenticated user identity to the WebSocket handler as a parameter. Avoid authenticating after the WebSocket connection is established, as it is more complex and less secure. Common patterns: validate a JWT in the Authorization header, validate a session cookie, or validate a token passed as a query parameter (less secure but common for browser clients where setting custom headers is not possible).
A Rust WebSocket server with 100K connections typically uses 100–300 MB of RAM depending on the per-connection state you maintain. Each Tokio async task uses a few KB of stack space plus your application state per connection. A message buffer of 100 messages per connection at average 200 bytes per message adds ~2 GB at 100K connections; use lazy buffers or external queues (Redis) rather than buffering everything in-process. The server itself (binary + shared state) adds another 50–100 MB. Total: 300–500 MB for 100K connections is a realistic estimate for a chat application with moderate per-connection state.
Want structured Rust training?
If you want a structured path into production Rust, covering async patterns, real-time systems, and the WebSocket infrastructure patterns used by high-traffic applications, Rustify's 9-week bootcamp offers 1:1 coaching from engineers who have shipped Rust in production. The curriculum covers Tokio, Axum, and the concurrency patterns that make Rust WebSocket servers reliable at scale.

