DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building a Real-Time WebSocket-Based Chat Server with Rust and WASM

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;
Enter fullscreen mode Exit fullscreen mode

}

  • 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)