Web Socket #
WebSocket is a protocol that provides two-way (full-duplex) communication between a browser and a server over a single persistent TCP connection. Different from HTTP where the client always initiates the request, in WebSocket the server can send data at any time without waiting for the client to request — this is what makes it suitable for chat, real-time notifications, live data feeds, and multiplayer games. In Rust, the most widely used crates are tokio-tungstenite which provides full control over the WebSocket protocol on top of tokio, and axum which integrates WebSocket directly into the HTTP server.
How WebSocket Works #
WebSocket starts as a regular HTTP request, then upgrades to the WebSocket protocol via a special handshake:
sequenceDiagram
participant B as Browser/Client
participant S as Server
B->>S: HTTP GET /ws\nUpgrade: websocket\nSec-WebSocket-Key: abc123
S->>B: HTTP 101 Switching Protocols\nUpgrade: websocket\nSec-WebSocket-Accept: xyz789
Note over B,S: Koneksi TCP tetap terbuka, protokol berganti ke WebSocket
B->>S: Frame: Text "Halo!"
S->>B: Frame: Text "Echo: Halo!"
S->>B: Frame: Text "Pesan push dari server"
B->>S: Frame: Ping
S->>B: Frame: Pong
B->>S: Frame: Close
S->>B: Frame: CloseAfter the handshake (HTTP 101), the connection changes to a frame-based WebSocket. No more HTTP headers per message — just small frames with minimal overhead.
Installation #
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.21"
futures-util = "0.3"
Basic Echo Server #
The simplest server — receives messages and returns them exactly the same:
use futures_util::{SinkExt, StreamExt};
use tokio::net::TcpListener;
use tokio_tungstenite::{accept_async, tungstenite::Message};
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:8080")
.await
.expect("Gagal bind ke port 8080");
println!("WebSocket server di ws://127.0.0.1:8080");
while let Ok((tcp_stream, addr)) = listener.accept().await {
println!("Koneksi TCP dari {}", addr);
tokio::spawn(async move {
tangani_koneksi(tcp_stream, addr).await;
});
}
}
async fn tangani_koneksi(
stream: tokio::net::TcpStream,
addr: std::net::SocketAddr,
) {
// Upgrade TCP → WebSocket
let ws_stream = match accept_async(stream).await {
Ok(ws) => ws,
Err(e) => {
eprintln!("[{}] Gagal upgrade WebSocket: {}", addr, e);
return;
}
};
println!("[{}] WebSocket terhubung", addr);
// Separate into sender and receiver
let (mut ws_kirim, mut ws_terima) = ws_stream.split();
// Loop receive message and echo back
while let Some(pesan) = ws_terima.next().await {
match pesan {
Ok(msg) => {
println!("[{}] Diterima: {:?}", addr, msg);
match msg {
Message::Text(teks) => {
let respons = Message::Text(format!("Echo: {}", teks).into());
if ws_kirim.send(respons).await.is_err() {
break;
}
}
Message::Binary(data) => {
// Echo binary as it is
if ws_kirim.send(Message::Binary(data)).await.is_err() {
break;
}
}
Message::Ping(data) => {
// Reply to Ping with Pong — mandatory for keep-alive
if ws_kirim.send(Message::Pong(data)).await.is_err() {
break;
}
}
Message::Close(frame) => {
println!("[{}] Client menutup koneksi: {:?}", addr, frame);
let _ = ws_kirim.send(Message::Close(None)).await;
break;
}
_ => {} // Message::Pong, Message::Frame — ignore
}
}
Err(e) => {
eprintln!("[{}] Error: {}", addr, e);
break;
}
}
}
println!("[{}] Koneksi ditutup", addr);
}
WebSocket Message Type #
| Type | When to use |
|---|---|
Message::Text(String) | Text message — JSON, plain text |
Message::Binary(Vec<u8>) | Binary data — image, audio, protobuf |
Message::Ping(Vec<u8>) | Check connection is still alive |
Message::Pong(Vec<u8>) | Ping Reply — reply immediately |
Message::Close(Option<CloseFrame>) | Close connection with code and reason |
Chat Room — Broadcast to All Clients #
WebSocket real use case: one client sends a message, all clients receive it. This requires a shared state containing all active connections:
use futures_util::{SinkExt, StreamExt};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::{accept_async, tungstenite::Message};
// Each client has a unique ID and channel to receive broadcast messages
type ClientId = u64;
type Pengirim = mpsc::UnboundedSender<Message>;
type DaftarClient = Arc<Mutex<HashMap<ClientId, Pengirim>>>;
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
let klien: DaftarClient = Arc::new(Mutex::new(HashMap::new()));
let mut id_berikutnya: ClientId = 1;
println!("Chat server di ws://127.0.0.1:8080");
while let Ok((stream, addr)) = listener.accept().await {
let id = id_berikutnya;
id_berikutnya += 1;
let klien = Arc::clone(&klien);
tokio::spawn(async move {
tangani_chat(stream, addr, id, klien).await;
});
}
}
async fn tangani_chat(
stream: tokio::net::TcpStream,
addr: std::net::SocketAddr,
id: ClientId,
klien: DaftarClient,
) {
let ws = match accept_async(stream).await {
Ok(ws) => ws,
Err(_) => return,
};
// Channel to receive messages that will be broadcast to this client
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
// List of this client
klien.lock().await.insert(id, tx);
println!("Client {} ({}) terhubung. Total: {}", id, addr, klien.lock().await.len());
// Send join message to all
broadcast(&klien, id, format!("Client {} bergabung", id)).await;
let (mut ws_kirim, mut ws_terima) = ws.split();
// Task to forward broadcast messages to this WebSocket client
let kirim_task = tokio::spawn(async move {
while let Some(pesan) = rx.recv().await {
if ws_kirim.send(pesan).await.is_err() {
break;
}
}
});
// Loop receives messages from clients and broadcasts them to all
while let Some(Ok(pesan)) = ws_terima.next().await {
if let Message::Text(teks) = pesan {
let teks_str = teks.to_string();
println!("Client {}: {}", id, teks_str);
let pesan_chat = format!("[Client {}]: {}", id, teks_str);
broadcast(&klien, 0, pesan_chat).await; // 0 = from server (broadcast to all)
}
}
// Client disconnect — clear
kirim_task.abort();
klien.lock().await.remove(&id);
println!("Client {} terputus. Sisa: {}", id, klien.lock().await.len());
broadcast(&klien, id, format!("Client {} keluar", id)).await;
}
// Send message to all clients except the sender (if except_id != 0)
async fn broadcast(klien: &DaftarClient, kecuali_id: ClientId, pesan: String) {
let klien_terkunci = klien.lock().await;
for (&id, tx) in klien_terkunci.iter() {
if id != kecuali_id {
let _ = tx.send(Message::Text(pesan.clone().into()));
}
}
}
WebSocket Client #
For testing or server-to-server communication, you can create a WebSocket client with tokio-tungstenite:
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::{connect_async, tungstenite::Message};
#[tokio::main]
async fn main() {
let url = "ws://127.0.0.1:8080";
// Connect to server
let (ws_stream, respons) = connect_async(url)
.await
.expect("Gagal terhubung ke server WebSocket");
println!("Terhubung! Status HTTP: {}", respons.status());
let (mut kirim, mut terima) = ws_stream.split();
// Recipient task — display all messages from the server
let task_terima = tokio::spawn(async move {
while let Some(pesan) = terima.next().await {
match pesan {
Ok(Message::Text(teks)) => println!("← Server: {}", teks),
Ok(Message::Binary(data)) => println!("← Binary: {} byte", data.len()),
Ok(Message::Ping(_)) => println!("← Ping"),
Ok(Message::Close(_)) => {
println!("Server menutup koneksi");
break;
}
Err(e) => {
eprintln!("Error: {}", e);
break;
}
_ => {}
}
}
});
// Send some messages to the server
let pesan_list = ["Halo server!", "Ini pesan kedua", "Sampai jumpa"];
for pesan in &pesan_list {
println!("→ Kirim: {}", pesan);
kirim.send(Message::Text((*pesan).into())).await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
// Close connection cleanly
kirim.send(Message::Close(None)).await.unwrap();
task_terima.await.unwrap();
}
Heartbeat — Ping/Pong for Keep-Alive #
WebSocket connections can be dropped without notification if they pass through a proxy or firewall that closes idle connections. Periodic heartbeats prevent this:
use futures_util::{SinkExt, StreamExt};
use tokio::time::{interval, Duration};
use tokio_tungstenite::{accept_async, tungstenite::Message};
async fn tangani_dengan_heartbeat(stream: tokio::net::TcpStream) {
let ws = accept_async(stream).await.unwrap();
let (mut kirim, mut terima) = ws.split();
let mut ping_interval = interval(Duration::from_secs(30));
let mut menunggu_pong = false;
loop {
tokio::select! {
// Receive message from client
pesan = terima.next() => {
match pesan {
Some(Ok(Message::Pong(_))) => {
menunggu_pong = false; // Pong accepted — connection still alive
}
Some(Ok(Message::Text(t))) => {
let _ = kirim.send(Message::Text(format!("Echo: {}", t).into())).await;
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
}
// Send Ping every 30 seconds
_ = ping_interval.tick() => {
if menunggu_pong {
// Pong not coming after previous Ping — disconnect
println!("Timeout: tidak ada Pong, putuskan koneksi");
break;
}
if kirim.send(Message::Ping(vec![])).await.is_err() {
break;
}
menunggu_pong = true;
}
}
}
}
WebSocket with Axum #
axum provides more ergonomic WebSocket integration than tokio-tungstenite directly, including HTTP and WebSocket routing in one server:
[dependencies]
axum = { version = "0.7", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
use axum::{
extract::ws::{Message, WebSocket, WebSocketUpgrade},
response::IntoResponse,
routing::get,
Router,
};
use futures_util::{SinkExt, StreamExt};
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(|| async { "HTTP server aktif" }))
.route("/ws", get(handler_ws));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Server di http://127.0.0.1:3000");
println!("WebSocket di ws://127.0.0.1:3000/ws");
Axum's ZZL9ZZ automatically handles the HTTP Upgrade handshake
axum::serve(listener, app).await.unwrap();
}
// automatically handles the HTTP Upgrade handshake
async fn handler_ws(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(tangani_ws_axum)
}
async fn tangani_ws_axum(mut socket: WebSocket) {
// Send a welcome message
if socket.send(Message::Text("Selamat datang!".into())).await.is_err() {
return;
}
// Message loop
while let Some(Ok(pesan)) = socket.recv().await {
match pesan {
Message::Text(teks) => {
println!("Diterima: {}", teks);
let balas = format!("Axum echo: {}", teks);
if socket.send(Message::Text(balas.into())).await.is_err() {
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
}
WSS — WebSocket with TLS #
For production, always use wss:// (WebSocket over TLS). The easiest way is to put a reverse proxy like Nginx or Caddy in front of the WebSocket server and let the proxy handle the TLS:
flowchart LR
Browser[Browser] -->|"wss://"| NginxCaddy[Nginx/Caddy: TLS termination]
NginxCaddy -->|"ws://"| RustWSServer[Rust WS Server]Nginx configuration for TLS WebSocket termination:
server {
listen 443 ssl;
server_name contoh.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s; # jangan putus koneksi idle
}
}
With this approach, the Rust code does not need to handle certificates at all.
Summary #
- WebSocket starts as an upgraded HTTP request —
accept_async()fromtokio-tungstenitehandles the entire handshake process automatically.- Always handle all Message types —
Text,Binary,Ping,Pong, andClose. Especially Ping must be immediately responded to with Pong to maintain the connection.split()for simultaneous read and write — separatesWebSocketStreamintoSinkHalf(send) andStreamHalf(receive) which can be operated from different tasks.- Use
mpsc::unbounded_channelfor broadcast — each client has a private channel; for broadcast, iterate over all senders and send a copy of the message to each one.- Heartbeat Ping/Pong prevents idle connections from dropping — send Ping every 30 seconds, disconnect if no Pong returns within the next interval.
axumfor integrated HTTP + WebSocket server — more ergonomic than straighttokio-tungsteniteif the server already uses axum for HTTP routing.- For production, use reverse proxy for TLS — Nginx or Caddy handles
wss://→ Rust server accepts plainws://. It’s easier than managing certificates in Rust code.tokio::select!for heartbeat along with message read — allows handling two event sources (timer channel and WebSocket stream) simultaneously without blocking.