Building a Real-Time WebSocket-Based Chat Server with Rust and WASM
Building a Real-Time WebSocket-Based Chat Server with Rust and WASM
In this tutorial, you’ll build a scalable, real-time chat server using Rust on the backend, WebSocket for bidirectional communication, and WebAssembly (WASM) for a fast, interactive frontend. You’ll learn how to structure a minimal, production-ready system with clean code, testable components, and practical deployment considerations.
Overview
- Goals:
- Real-time messaging with low latency
- Safe, fast backend implemented in Rust
- Frontend capable of connecting via WebSocket and rendering messages efficiently
- Basic authentication, message persistence, and reconnection handling
- Testing strategies for end-to-end and unit tests
- Tech stack:
- Backend: Rust, Warp or Actix-Web, tokio, tungstenite or tokio-tungstenite for WebSocket
- Frontend: Rust + WASM (via wasm-bindgen) or a lightweight JS client
- Persistence: SQLite or PostgreSQL for message history
- Deployment: containerized (Docker), with a simple reverse proxy (Nginx) in front
Prerequisites
- Rust toolchain installed (rustup, cargo)
- Basic knowledge of Rust and asynchronous programming
- Node.js/npm if you choose a JS frontend (optional since you can use Rust WASM)
- SQLite or PostgreSQL installed locally for testing
1) System design and data model
- Clients connect via WebSocket and join a chat room.
- The server maintains in-memory state for active connections and broadcasts messages to all connected clients in the same room.
- Messages are persisted to a database for history.
- Reconnection: clients reconnect on network hiccups; server replays recent history upon join.
- Scalability note: for multiple instances, use a message broker (Redis pub/sub) to broadcast messages between workers.
Data model (simplified)
- Users: id, username
- Rooms: id, name
- Messages: id, room_id, user_id, content, timestamp
2) Backend: Rust WebSocket server
Key components
- HTTP upgrade to WebSocket
- Per-room broadcast hub
- Connection management with tasks and channels
- Persistence layer for messages
- Simple auth (token-based, e.g., Authorization header)
Directory layout (suggested)
- server/
- Cargo.toml
- src/
- main.rs
- routes.rs
- ws.rs
- db.rs
- models.rs
- auth.rs
- client/
- (optional) WASM or JS client
- Dockerfile
- docker-compose.yml (optional for local testing)
Code skeleton (backend)
-
Cargo.toml dependencies (illustrative)
- [dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread"] } warp = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" futures = "0.3" tokio-tungstenite = "0.15" sqlx = { version = "0.6", features = ["sqlite", "runtime-tokio-rustls"] } uuid = "1" chrono = { version = "0.4", features = ["serde"] }
src/main.rs (high level)
use warp::Filter;
#[tokio::main]
async fn main() {
// initialize DB, load config
// set up routes for health, and ws endpoint
let ws_route = warp::path("ws")
.and(warp::ws())
.and(with_db())
.and_then(ws_handler);
let health = warp::path("health").map(|| "ok");
let routes = ws_route.or(health);
warp::serve(routes).run((, 3030)).await;
}
- src/ws.rs (WebSocket handling) use warp::ws::{Message, Ws}; use futures::{StreamExt, SinkExt};
pub async fn ws_handler(ws: Ws, db: DbHandle) -> Result {
Ok(ws.on_upgrade(move |socket| handle_socket(socket, db)))
}
async fn handle_socket(stream: tokio_tungstenite::WebSocketStream<...>, db: DbHandle) {
// split, read messages, parse JSON, auth, join a room, broadcast
// maintain a per-room broadcaster (using tokio::sync::broadcast or mpsc)
}
- src/db.rs (persistence) use sqlx::SqlitePool;
pub struct DbHandle { pool: SqlitePool }
impl DbHandle {
pub async fn new(database_url: &str) -> Self { /* create pool / }
pub async fn save_message(&self, room_id: &str, user_id: &str, content: &str) { / insert / }
pub async fn fetch_history(&self, room_id: &str) -> Vec { / select */ }
}
- src/models.rs use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
pub struct ChatMessage { id: String, room_id: String, user_id: String, content: String, timestamp: i64 }
#[derive(Serialize, Deserialize)]
pub struct ClientMessage { room_id: String, user_id: String, content: String }
- src/auth.rs pub fn verify_token(token: &str) -> Option { /* simple check */ }
Implementation notes
- Use tokio for async runtime and tokio-tungstenite for WebSocket support.
- For per-room broadcasting, implement a Hub:
- A HashMap>
- When a client joins, subscribe to the room’s channel
- When a client sends a message, broadcast to the room’s channel and persist to DB
- Reconnection: on connect, client can request recent history (e.g., last 50 messages) and server sends them before live updates
- Security: use a simple token check in the Authorization header (Bearer token). In production, integrate OAuth2 or JWT validation.
Example: a minimal hub concept
- Define a Hub struct with channels
- A worker task runs for each room, receiving messages and broadcasting to clients
- Each connection holds a Receiver for its room and a Sender for outbound messages
3) Frontend: WASM or JavaScript client
Options
- Pure JavaScript client using WebSocket API
- Rust-based WASM client for a more cohesive Rust stack
Plain JS client sketch
- Connect to ws://localhost:3030/ws
- On open, send an authentication payload (token) and join a room
- On message, render chat bubbles
- On error/close, implement exponential backoff reconnect
Code sketch (JS)
- const socket = new WebSocket("ws://localhost:3030/ws");
- socket.addEventListener("open", () => { socket.send(JSON.stringify({ type: "auth", token: "" })); });
- socket.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data); // render message });
- function sendMessage(roomId, userId, content) { socket.send(JSON.stringify({ type: "message", room_id: roomId, user_id: userId, content })); }
WASM route (optional)
- Use wasm-bindgen to expose a WebSocket-like interface from Rust to front-end
- This path is more advanced; for a quick start, JS client is fine.
4) Persistence and history
- Use SQLite for simplicity in local development, PostgreSQL for prod
-
Table schema (simplified)
- users(id TEXT PRIMARY KEY, username TEXT)
- rooms(id TEXT PRIMARY KEY, name TEXT)
- messages(id TEXT PRIMARY KEY, room_id TEXT, user_id TEXT, content TEXT, timestamp INTEGER)
-
Basic queries
- insert into messages (id, room_id, user_id, content, timestamp) values (?, ?, ?, ?, ?)
- select content, username, timestamp from messages join users on messages.user_id = users.id where room_id = ? order by timestamp desc limit 50
5) Testing strategy
- Unit tests
- Test message serialization/deserialization
- Test auth token parsing
- Integration tests
- Spin up an in-memory or test database
- Start a test server and connect two WebSocket clients
- Verify message broadcasting between clients
- End-to-end tests
- Use a real database in a test environment
- Validate history replay on join, reconnection behavior
Test example: unit test for message format (Rust)
-
[derive(Serialize, Deserialize)]
struct ChatMessage { ... }
fn test_message_serialization() {
let msg = ChatMessage { id: "1".into(), room_id: "room1".into(), user_id: "u1".into(), content: "hi".into(), timestamp: 1234 };
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"room_id\":\"room1\""));
}
6) Deployment considerations
- Containerize the server
- Dockerfile that builds Rust binary and runs it
- Orchestration
- Docker Compose for local dev with a database
- Kubernetes for production (deploy a StatefulSet for the server, with a Redis or PostgreSQL service)
- Scaling
- Horizontal scaling: multiple server instances with a shared data store
- Use Redis Pub/Sub or a message broker to sync in-memory broadcasts across instances
- Observability
- Structured logs (JSON)
- Metrics: request rate, error rate, message throughput
- Health checks and readiness probes
7) Practical tips and gotchas
- Keep WebSocket frames concise; send small payloads to reduce latency.
- Implement a graceful shutdown path to flush in-flight messages.
- For large rooms, consider paging history rather than streaming every message.
- Security: never trust client input; validate room IDs and user IDs server-side.
- Use a stable interface between frontend and backend (e.g., a strict JSON payload schema) to avoid version drift.
- If you expect many concurrent connections, consider using a real-time framework or a dedicated chat service to offload the hub logic.
Illustrative example: end-to-end flow
- Client authenticates with a token and joins a room.
- Server validates token, subscribes client to room channel, fetches last 50 messages, and sends them to the client.
- Client sends a message; server persists it, broadcasts to all room members, and clients render the new message in real time.
What you’ll build
- A minimal, production-friendly real-time chat server in Rust
- A simple frontend client (JS or WASM) that connects via WebSocket, authenticates, joins a room, and displays messages
- A persisted message history for each room
- A test plan covering unit, integration, and end-to-end tests
- Deployment guidance for a small-scale production setup with room for growth
Would you like me to tailor this tutorial to your preferred stack (Rust-only backend with Actix, or Warp, or Node.js as a backend), or to focus on a WASM-based frontend? I can provide a concrete, fully fleshed-out codebase starter with Docker and a sample frontend.
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)