Skip to content

WebSocket Proxy

WsProxy listens for incoming HTTP/1.1 connections, validates that each connection is a WebSocket upgrade, forwards the upgrade handshake to a backend, and then tunnels all subsequent WebSocket frames bidirectionally.

Basic usage

use rust_web_server::ws_proxy::WsProxy;
// Plain backends, round-robin
WsProxy::new(["ws://chat-backend:9000", "ws://chat-backend:9001"])
.connect_timeout_ms(3_000)
.bind("0.0.0.0:8080")
.unwrap();
// TLS backend (requires http-client or http2 feature)
WsProxy::new(["wss://chat.example.com"])
.bind("0.0.0.0:8080")
.unwrap();

.bind() blocks the calling thread indefinitely. Run it in a dedicated thread or process.

Connection flow

For each incoming TCP connection, WsProxy follows this sequence:

  1. Read the initial HTTP request (up to 8 192 bytes).
  2. Validate that the request contains a WebSocket Upgrade header. Non-upgrade requests receive 400 Bad Request and are closed.
  3. Connect to the selected backend using the configured connect timeout.
  4. TLS handshake (wss:// only) — establishes a rustls connection using the WebPKI root store.
  5. Forward the HTTP upgrade request to the backend (rewriting the Host header).
  6. Verify the backend responds with HTTP/1.1 101 Switching Protocols.
  7. Send 101 Switching Protocols to the client, completing the client-side handshake.
  8. Relay all subsequent bytes bidirectionally — two threads for ws://, single-thread polling loop for wss://.

For plain backends, relay threads use std::io::copy and shut down their half of the connection when the remote side closes.

Round-robin backend selection

An AtomicUsize counter cycles through backends evenly across connections with no locking:

WsProxy::new([
"ws-backend-1:9000",
"ws-backend-2:9000",
"ws-backend-3:9000",
])

WsProxy::new(...) treats this list as fixed and always live — round-robin cycles through every entry, healthy or not, exactly like before health checks existed. See Health checks below for the opt-in behavior where backends can drop out of (and back into) rotation.

Health checks

By default a WsProxy has no health checker — every configured backend is always considered live, and a WebSocket upgrade request to a dead backend simply fails with a connect error. Two ways to make it health-check-aware:

Config-driven proxy — add [ws_proxy.health_check], same fields as [upstream.health_check] (see Health Checks):

[[ws_proxy]]
name = "chat"
listen = "0.0.0.0:9000"
backends = ["wss://chat-a:8443", "wss://chat-b:8443"]
[ws_proxy.health_check]
path = "/healthz" # plain HTTP GET, not a WebSocket handshake
interval_secs = 10
timeout_ms = 2000
healthy_threshold = 2
unhealthy_threshold = 3

builder.rs spawns the same background checker [[upstream]] pools use and shares its live-backend list with the WsProxy instance. A backend that fails unhealthy_threshold consecutive probes stops receiving new connections; it’s restored after healthy_threshold consecutive successes. If every backend is currently unhealthy, new upgrade attempts get 503 Service Unavailable instead of being routed to a backend known to be down.

Library usageWsProxy::with_live_backends(all_backends, live) takes an Arc<RwLock<Vec<String>>> you update yourself, from any probe logic you want (e.g. a real WebSocket handshake instead of a plain HTTP GET):

use std::sync::{Arc, RwLock};
use rust_web_server::ws_proxy::WsProxy;
let all = vec!["ws://chat-a:9000".to_string(), "ws://chat-b:9000".to_string()];
let live = Arc::new(RwLock::new(all.clone()));
let checker_live = Arc::clone(&live);
std::thread::spawn(move || loop {
std::thread::sleep(std::time::Duration::from_secs(10));
// probe each backend in `all` however you like, then:
// *checker_live.write().unwrap() = healthy_subset;
let _ = &checker_live;
});
WsProxy::with_live_backends(all, live)
.bind("0.0.0.0:8080")
.unwrap();

Timeout configuration

WsProxy::new(backends)
.connect_timeout_ms(5_000) // TCP connect to backend (default: 5 000 ms)
.read_timeout_ms(30_000) // idle read timeout on client connections (default: 30 000 ms)

The read timeout applies to the initial HTTP upgrade read from the client. After the tunnel is established, there is no application-level read timeout — the connection stays open until either side closes it.

Config-file usage

Activate WsProxy from rws.config.toml with a [[ws_proxy]] section:

# Plain TCP backends
[[ws_proxy]]
name = "chat-plain"
listen = "0.0.0.0:8080"
backends = ["ws://ws-backend-1:9000", "ws://ws-backend-2:9000"]
connect_timeout_ms = 3000
# TLS backend (requires http-client or http2 feature)
[[ws_proxy]]
name = "chat-tls"
listen = "0.0.0.0:8443"
backends = ["wss://chat.example.com"]

Multiple [[ws_proxy]] sections can run independently on separate ports.

Backend URL schemes

WsProxy accepts three backend URL forms:

SchemeTransportDefault port
host:portplain TCP(explicit)
ws://host:portplain TCP80
wss://host:portTLS443
// Plain backends — all three forms are equivalent for port 9000:
WsProxy::new(["chat:9000", "ws://chat:9000"])
// TLS backends (port defaults to 443):
WsProxy::new(["wss://chat.example.com"])
WsProxy::new(["wss://chat.example.com:8443"])

wss:// requires the http-client or http2 Cargo feature (both include rustls + webpki-roots). Without these features, wss:// backends return 502 Bad Gateway with a clear error message.

TLS relay internals

Plain ws:// backends use two threads — one per direction — each running std::io::copy. This is the most efficient approach for plain TCP because TcpStream::try_clone() lets each thread independently own one direction.

wss:// backends cannot use this pattern because rustls::StreamOwned cannot be cloned. Sharing it between two threads via Arc<Mutex<>> would deadlock: the read thread holds the mutex while blocking for data, preventing the write thread from ever sending client data to the backend.

Instead, wss:// uses a single-thread polling relay:

  • Both streams are set to a 5 ms read timeout.
  • The loop reads from the client, writes to the TLS backend; then reads from the TLS backend, writes to the client.
  • When neither side has data, the loop sleeps 1 ms to avoid busy-waiting.

For interactive WebSocket applications (chat, games, collaborative editing), the maximum added latency from the polling interval is imperceptible.

Inbound TLS (clients connecting over wss://)

The above covers TLS to the upstream backend. For TLS-terminated inbound connections (clients using wss:// to reach the proxy itself), place an rws instance with --tls-cert-file in front:

wss:// client ──► rws (TLS termination, port 443) ──► WsProxy (plain, port 8080) ──► backend

Use cases

  • Chat servers and real-time messaging (Socket.IO, Phoenix Channels, Action Cable)
  • Live data feeds and dashboards
  • Multiplayer game backends
  • Collaborative editing services
  • Any service that upgrades HTTP to the WebSocket protocol