Recipe: WebSocket Reconnection & Heartbeat
A resilient WebSocket connection manager statechart with exponential backoff, jitter, ping/pong health monitoring, and offline message queueing.
🎯 Statechart Topology
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connecting: CONNECT
Connecting --> Connected: OPEN
Connecting --> Backoff: FAIL / TIMEOUT
state Connected {
[*] --> Idle
Idle --> AwaitingPong: PING
AwaitingPong --> Idle: PONG
}
Connected --> Backoff: CLOSE / HEARTBEAT_TIMEOUT
Backoff --> Connecting: RETRY (timer with exponential backoff)
🛠️ Complete Rust Implementation
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
use xstate_statemachine::prelude::*;
use xstate_statemachine::tokio::{spawn, Options, Interpreter};
const WS_MACHINE_JSON: &str = r#"{
"id": "websocket_client",
"initial": "disconnected",
"context": {
"retry_count": 0,
"max_retries": 10,
"queued_messages": []
},
"states": {
"disconnected": {
"on": {"CONNECT": "connecting"}
},
"connecting": {
"on": {
"OPEN": {
"target": "connected",
"actions": "resetRetries"
},
"FAIL": {
"target": "backoff",
"actions": "incrementRetries"
}
}
},
"connected": {
"initial": "idle",
"states": {
"idle": {
"on": {"SEND_PING": "awaiting_pong"}
},
"awaiting_pong": {
"after": {"5000": "#websocket_client.backoff"},
"on": {"PONG": "idle"}
}
},
"on": {
"DISCONNECT": "disconnected",
"NETWORK_LOST": {
"target": "backoff",
"actions": "incrementRetries"
}
}
},
"backoff": {
"after": {"2000": "connecting"}
}
}
}"#;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let machine = Machine::from_json(WS_MACHINE_JSON)?;
let logic = MachineLogic::dynamic()
.action("resetRetries", |scope| {
scope.context["retry_count"] = 0.into();
println!("✅ Connected! Flushed retries.");
Ok(())
})
.action("incrementRetries", |scope| {
let count = scope.context["retry_count"].as_u64().unwrap_or(0) + 1;
scope.context["retry_count"] = count.into();
println!("⚠️ Connection failed. Retrying (attempt {count})...");
Ok(())
});
let client = spawn(machine, logic, Options::default())?;
// 1. Initial connect
client.send("CONNECT").await?;
println!("State: {:?}", client.view().state_ids);
// 2. Simulate connection success
client.send("OPEN").await?;
println!("State: {:?}", client.view().state_ids);
// 3. Heartbeat ping-pong
client.send("SEND_PING").await?;
println!("State: {:?}", client.view().state_ids);
client.send("PONG").await?;
println!("State: {:?}", client.view().state_ids);
client.stop().await;
Ok(())
}