← Back to Lessons Lesson 36 of 48
Advanced api

Wasm + WebRTC

What is WebRTC?

WebRTC (Web Real-Time Communication) enables peer-to-peer audio, video, and data transfer directly between browsers — with no server relay. Rust/Wasm can drive the entire client-side logic: encoding, protocol handling, and application state.

  Traditional Client-Server             WebRTC Peer-to-Peer

  ┌──────┐     ┌──────┐     ┌──────┐   ┌──────┐          ┌──────┐
  │Peer A│────>│Server│<────│Peer B│   │Peer A│<========>│Peer B│
  └──────┘     └──────┘     └──────┘   └──────┘  direct  └──────┘
                  ▲                                 │
             All data                         Lower latency
             goes through                     No server cost
             server                           E2E encrypted

WebRTC Architecture Overview

A WebRTC connection involves several components:

  ┌─────────────────────────────────────────────────────────┐
  │                    Browser (Peer A)                      │
  │                                                         │
  │  ┌─────────────┐   ┌──────────────┐   ┌─────────────┐  │
  │  │  Your Rust   │──>│RTCPeerConn.  │──>│  ICE Agent  │  │
  │  │  Wasm Code   │   │  (browser)   │   │ (STUN/TURN) │  │
  │  └─────────────┘   └──────────────┘   └──────┬──────┘  │
  │         │                  │                   │         │
  │         ▼                  ▼                   │         │
  │  ┌─────────────┐   ┌──────────────┐           │         │
  │  │Data Channel │   │ Media Stream │           │         │
  │  │ (text/bin)  │   │ (audio/video)│           │         │
  │  └─────────────┘   └──────────────┘           │         │
  └───────────────────────────────────────────────┼─────────┘
                                                   │
                          ┌────────────────────────┘
                          │  STUN / TURN / Direct
                          ▼
  ┌─────────────────────────────────────────────────────────┐
  │                    Browser (Peer B)                      │
  │              (mirror of above)                          │
  └─────────────────────────────────────────────────────────┘

The Signaling Process

Before peers can connect directly, they must exchange offers, answers, and ICE candidates through a signaling server (typically a WebSocket server):

  Alice                    Signaling Server              Bob
    │                            │                        │
    │── 1. Create Offer ───────> │                        │
    │                            │── 2. Forward Offer ──> │
    │                            │                        │
    │                            │ <── 3. Create Answer ──│
    │ <── 4. Forward Answer ──── │                        │
    │                            │                        │
    │── 5. ICE Candidate ──────> │                        │
    │                            │── 6. Forward ICE ────> │
    │                            │                        │
    │                            │ <── 7. ICE Candidate ──│
    │ <── 8. Forward ICE ─────── │                        │
    │                            │                        │
    │ <══════════ Direct P2P Connection ═════════════════> │
    │            (signaling server no longer needed)       │

SDP (Session Description Protocol)

The offer and answer are SDP strings that describe capabilities:

v=0
o=- 4567890 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-ufrag:abcd
a=ice-pwd:efghijklmnop
a=fingerprint:sha-256 AA:BB:CC:...
a=setup:actpass
a=mid:0
a=sctp-port:5000

WebRTC in Rust/Wasm with web-sys

The web-sys crate provides bindings to all WebRTC browser APIs:

[dependencies.web-sys]
version = "0.3"
features = [
    "RtcPeerConnection",
    "RtcPeerConnectionIceEvent",
    "RtcSessionDescriptionInit",
    "RtcSdpType",
    "RtcDataChannel",
    "RtcDataChannelInit",
    "RtcDataChannelEvent",
    "RtcIceCandidateInit",
    "RtcConfiguration",
    "RtcIceServer",
    "MessageEvent",
]

Creating a Peer Connection

use web_sys::{RtcPeerConnection, RtcConfiguration, RtcIceServer};
use wasm_bindgen::prelude::*;
use js_sys::{Array, Object, Reflect};

fn create_peer_connection() -> Result<RtcPeerConnection, JsValue> {
    // Configure STUN/TURN servers
    let ice_server = Object::new();
    Reflect::set(&ice_server, &"urls".into(),
        &"stun:stun.l.google.com:19302".into())?;

    let ice_servers = Array::new();
    ice_servers.push(&ice_server);

    let config = RtcConfiguration::new();
    config.set_ice_servers(&ice_servers);

    RtcPeerConnection::new_with_configuration(&config)
}

Creating an Offer

use wasm_bindgen_futures::JsFuture;

async fn create_offer(pc: &RtcPeerConnection) -> Result<String, JsValue> {
    let offer = JsFuture::from(pc.create_offer()).await?;
    let offer_sdp = Reflect::get(&offer, &"sdp".into())?
        .as_string()
        .unwrap();

    let mut desc = RtcSessionDescriptionInit::new(RtcSdpType::Offer);
    desc.sdp(&offer_sdp);
    JsFuture::from(pc.set_local_description(&desc)).await?;

    Ok(offer_sdp)
}

Setting Up a Data Channel

fn setup_data_channel(pc: &RtcPeerConnection) -> RtcDataChannel {
    let mut init = RtcDataChannelInit::new();
    init.ordered(true);

    let channel = pc.create_data_channel_with_data_channel_dict("chat", &init);

    // Handle incoming messages
    let onmessage = Closure::wrap(Box::new(move |evt: MessageEvent| {
        if let Some(text) = evt.data().as_string() {
            web_sys::console::log_1(&format!("Received: {}", text).into());
        }
    }) as Box<dyn FnMut(_)>);

    channel.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
    onmessage.forget();

    // Handle channel open
    let channel_clone = channel.clone();
    let onopen = Closure::wrap(Box::new(move |_: JsValue| {
        channel_clone.send_with_str("Hello from Rust/Wasm!").unwrap();
    }) as Box<dyn FnMut(_)>);

    channel.set_onopen(Some(onopen.as_ref().unchecked_ref()));
    onopen.forget();

    channel
}

ICE: Finding a Path Between Peers

ICE (Interactive Connectivity Establishment) discovers the best network path between peers:

  ICE Candidate Types (in preference order):

  1. Host Candidate         ← Direct LAN connection
     ┌──────┐               ┌──────┐
     │Peer A│═══════════════│Peer B│
     └──────┘  same network └──────┘

  2. Server-Reflexive (srflx) ← Via STUN (learns public IP)
     ┌──────┐     ┌──────┐     ┌──────┐
     │Peer A│────>│ STUN │<────│Peer B│
     └──────┘     │Server│     └──────┘
       NAT ─ ─ ─ └──────┘ ─ ─ ─ NAT
     Peer A ═════════════════════ Peer B
              (through NAT)

  3. Relay (relay)          ← Via TURN (relayed traffic)
     ┌──────┐     ┌──────┐     ┌──────┐
     │Peer A│────>│ TURN │<────│Peer B│
     └──────┘     │Server│     └──────┘
                  └──────┘
               (all data relayed)

Handling ICE Candidates in Rust

fn setup_ice_handling(pc: &RtcPeerConnection, ws: &WebSocket) {
    let ws_clone = ws.clone();

    let onicecandidate = Closure::wrap(Box::new(move |evt: RtcPeerConnectionIceEvent| {
        if let Some(candidate) = evt.candidate() {
            // Serialize and send via signaling server
            let msg = format!(
                r#"{{"type":"ice","candidate":"{}","sdpMid":"{}","sdpMLineIndex":{}}}"#,
                candidate.candidate(),
                candidate.sdp_mid().unwrap_or_default(),
                candidate.sdp_m_line_index().unwrap_or(0)
            );
            ws_clone.send_with_str(&msg).unwrap();
        }
    }) as Box<dyn FnMut(_)>);

    pc.set_onicecandidate(Some(onicecandidate.as_ref().unchecked_ref()));
    onicecandidate.forget();
}

Data Channel Message Types

Data channels support both text and binary data:

// Text message
channel.send_with_str("Hello!")?;

// Binary message (e.g., game state, file chunks)
let data: Vec<u8> = vec![0x01, 0x02, 0x03, 0x04];
let array = js_sys::Uint8Array::from(&data[..]);
channel.send_with_array_buffer(&array.buffer())?;

Ordered vs Unordered Channels

Property Ordered (ordered: true) Unordered (ordered: false)
Message order Guaranteed May arrive out of order
Latency Higher (head-of-line) Lower
Use case Chat, commands Game state, video frames
Retransmission Yes Optional (maxRetransmits)
// Unreliable, unordered — ideal for real-time game state
let mut init = RtcDataChannelInit::new();
init.ordered(false);
init.max_retransmits(0);  // fire-and-forget

let game_channel = pc.create_data_channel_with_data_channel_dict("game-state", &init);

Signaling with WebSocket

A minimal signaling server relays SDP and ICE messages between peers:

// Client-side: connect to signaling server
use web_sys::WebSocket;

let ws = WebSocket::new("wss://signal.example.com/room/abc")?;

let onmessage = Closure::wrap(Box::new(move |evt: MessageEvent| {
    let text = evt.data().as_string().unwrap();
    // Parse and handle: offer, answer, or ICE candidate
    // Then call pc.set_remote_description() or pc.add_ice_candidate()
}) as Box<dyn FnMut(_)>);

ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
onmessage.forget();

Signaling Protocol Example

// Alice -> Server -> Bob
{"type": "offer",  "sdp": "v=0\r\no=..."}

// Bob -> Server -> Alice
{"type": "answer", "sdp": "v=0\r\no=..."}

// Both directions
{"type": "ice", "candidate": "candidate:1 1 UDP ...",
 "sdpMid": "0", "sdpMLineIndex": 0}

Connection Lifecycle

  State Machine:

  ┌─────┐  create offer/answer  ┌────────────┐  ICE done  ┌───────────┐
  │ New │───────────────────────>│ Connecting │───────────>│ Connected │
  └─────┘                       └────────────┘            └─────┬─────┘
                                      │                         │
                                      │ failure                 │ network
                                      ▼                         │ change
                                ┌──────────┐                    ▼
                                │  Failed  │              ┌──────────────┐
                                └──────────┘              │Disconnected  │
                                                          └──────┬───────┘
                                                                 │ ICE
                                                                 │ restart
                                                                 ▼
                                                          ┌───────────┐
                                                          │ Connected │
                                                          └───────────┘

Performance Tips for Rust/Wasm WebRTC

  1. Process binary data in Rust — decode video frames, compress game state, encrypt payloads on the Wasm side before sending through data channels.

  2. Use SharedArrayBuffer for zero-copy data transfer between Wasm and JavaScript (requires cross-origin isolation headers).

  3. Batch small messages — each send() call has overhead. Pack multiple game events into a single binary message.

  4. Use unordered channels for real-time data where the latest state matters more than every update.

Pattern Latency Reliability Use Case
Ordered + reliable High Full Chat, file transfer
Ordered + maxRetransmits(3) Medium Partial Important game events
Unordered + maxRetransmits(0) Lowest None Position updates

Summary

WebRTC lets your Rust/Wasm application communicate peer-to-peer without a relay server. The signaling phase (offers, answers, ICE candidates) happens through a WebSocket server, but once connected, all data flows directly between browsers. Data channels give you flexible, low-latency messaging — choose ordered/reliable for correctness-critical data and unordered/unreliable for real-time streams.

Try It