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: Close

After 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 #

TypeWhen 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 requestaccept_async() from tokio-tungstenite handles the entire handshake process automatically.
  • Always handle all Message typesText, Binary, Ping, Pong, and Close. Especially Ping must be immediately responded to with Pong to maintain the connection.
  • split() for simultaneous read and write — separates WebSocketStream into SinkHalf (send) and StreamHalf (receive) which can be operated from different tasks.
  • Use mpsc::unbounded_channel for 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.
  • axum for integrated HTTP + WebSocket server — more ergonomic than straight tokio-tungstenite if the server already uses axum for HTTP routing.
  • For production, use reverse proxy for TLS — Nginx or Caddy handles wss:// → Rust server accepts plain ws://. 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.


← Previous: Socket   Next: Web Server →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact