WebSockets and
Real-Time Push
WebSocket begins as a regular HTTP/1.1 request with an Upgrade: websocket header. The server responds with 101 Switching Protocols and from that moment the TCP connection is no longer HTTP — it carries WebSocket frames: binary or text messages, ping/pong heartbeats, and a closing handshake. Both sides can send at any time without waiting for the other to request first. This is the fundamental difference from HTTP's request-response model.
WEBSOCKET HANDSHAKE AND MESSAGE FLOW ───────────────────────────────────────────────────────── Client (NOC screen) Server (Axum) ───────────────── ────────────── GET /ws/alerts HTTP/1.1 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: Ynq... → HTTP/1.1 101 Switching Protocols Upgrade: websocket ← Sec-WebSocket-Accept: hT... [TCP connection remains open — no more HTTP framing] ──────────────── Normal operation ──────────────── [Zabbix fires alert via POST /internal/alerts] broadcast channel ← new AlertEvent for each WS subscriber: ← TEXT frame: {"id":"...","site":"Kilimanjaro"...} ← TEXT frame: {"id":"...","site":"Serengeti"...} [Screen receives, renders alert immediately] ──────── Heartbeat (prevent idle timeout) ───────── ← PING frame PONG frame → [Connection stays alive for hours]
The problem: when Zabbix posts an alert, we need to notify all currently-connected NOC screens. A simple Vec<WebSocket> would require a Mutex and sequential writes. Tokio's broadcast::channel is the right primitive: one sender, arbitrarily many receivers, each receiving every message. Lagging receivers (slow screens) get a RecvError::Lagged error and miss old messages rather than blocking the sender.
Choose the right channel
mpsc (multi-producer single-consumer) — many senders, one receiver. Use when collecting work from many tasks into one processor. Example: sensor readings from many Embassy tasks feeding into one logger.
broadcast (multi-producer multi-consumer) — every receiver gets every message. Use for publish-subscribe, fan-out, event notification. Example: one Zabbix webhook → all NOC screens. The capacity (buffer size) is fixed. If receivers are too slow, they get RecvError::Lagged — they skip old messages. Set capacity based on your peak message rate × acceptable lag tolerance.
oneshot — single message, consumed once. Use for request-response patterns, spawning a task and waiting for its result.
use axum::{ extract::{State, WebSocketUpgrade, ws::{Message, WebSocket}}, response::Response, }; use tokio::sync::broadcast; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AlertEvent { pub id: String, pub site: String, pub severity: String, pub message: String, pub event: String, // "alert.created" | "alert.resolved" } /// HTTP upgrade handler — called when a client connects to /ws/alerts pub async fn ws_handler( ws: WebSocketUpgrade, State(state): State<AppState>, ) -> Response { // Upgrade the HTTP connection to WebSocket // The closure receives the upgraded socket — runs in its own task ws.on_upgrade(move |socket| handle_socket(socket, state.alert_tx.subscribe())) } /// Runs for the lifetime of each connected client async fn handle_socket( mut socket: WebSocket, mut rx: broadcast::Receiver<AlertEvent>, ) { tracing::info!("NOC screen connected"); loop { tokio::select! { // Branch 1: new alert from broadcast channel → send to client result = rx.recv() => { match result { Ok(event) => { let json = serde_json::to_string(&event).unwrap(); if socket.send(Message::Text(json.into())).await.is_err() { tracing::info!("NOC screen disconnected"); break; // client closed connection } } Err(broadcast::error::RecvError::Lagged(n)) => { // Slow client — missed n messages. Log and continue. tracing::warn!("WS client lagged, missed {} events", n); } Err(broadcast::error::RecvError::Closed) => break, } } // Branch 2: client sent a message (ping, or disconnect) msg = socket.recv() => { match msg { Some(Ok(Message::Close(_))) | None => { tracing::info!("NOC screen disconnected cleanly"); break; } Some(Ok(Message::Ping(data))) => { let _ = socket.send(Message::Pong(data)).await; } _ => {} // ignore other message types } } } } }
The tokio::select! macro waits on multiple async futures simultaneously and proceeds with whichever one completes first. It is the async equivalent of the Unix select() or epoll system calls you have used in network programming. In our WebSocket handler, select! lets us simultaneously wait for new broadcast messages and for the client to send data (like a close frame) — without either waiting blocking the other. The pattern is fundamental to building responsive concurrent systems.
// In the POST /api/v1/alerts handler: pub async fn create_alert_handler( State(state): State<AppState>, user: AuthUser, Json(body): Json<CreateAlert>, ) -> Result<Json<Alert>, ApiError> { let alert = db::create_alert(&state.db, body).await?; // Broadcast to all connected WebSocket clients // send() returns Err only if no receivers — that's fine, just ignore let _ = state.alert_tx.send(AlertEvent { id: alert.id.to_string(), site: alert.site.clone(), severity: alert.severity.clone(), message: alert.message.clone(), event: "alert.created".to_string(), }); tracing::info!( "Alert created by {} — site={} severity={}", user.email, alert.site, alert.severity ); Ok(Json(alert)) } // Register the WS route alongside REST routes: let app = Router::new() .route("/api/v1/alerts", get(list_alerts_handler).post(create_alert_handler)) .route("/ws/alerts", get(ws_handler)) // WebSocket endpoint .with_state(state);
The NOC wall screen is a browser page on a dedicated monitor. The JavaScript WebSocket API is simple: connect to the endpoint, listen for messages, render alerts into the DOM. The browser handles reconnection automatically with a small wrapper. No frontend framework needed — this is plain JavaScript that Irene Chalya Phillip's team can modify without a build system.
<!DOCTYPE html>
<html><head>
<title>SprintTZ NOC — Live Alerts</title>
<style>
body { background: #0D2340; color: #F9F6EF; font-family: monospace; padding: 20px; }
.alert { padding: 12px 16px; margin: 8px 0; border-radius: 6px; animation: fadein .3s; }
.critical { background: #5C1A1A; border-left: 4px solid #FF5555; }
.warning { background: #3D2A00; border-left: 4px solid #B8922A; }
.info { background: #0D2340; border-left: 4px solid #3970B8; border: 1px solid #1E4D8C; }
.site { font-size: 11px; opacity: .6; text-transform: uppercase; letter-spacing: .1em; }
@keyframes fadein { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; } }
</style>
</head><body>
<h2 style="color:#B8922A;font-family:'Georgia',serif;margin-bottom:20px">
⬡ SprintTZ NOC — Live Alerts
</h2>
<div id="alerts"></div>
<script>
function connect() {
const ws = new WebSocket('ws://localhost:8080/ws/alerts');
ws.onmessage = (e) => {
const event = JSON.parse(e.data);
const div = document.createElement('div');
div.className = `alert ${event.severity}`;
div.innerHTML = `
<div class="site">${event.site} · ${new Date().toLocaleTimeString()}</div>
<strong>${event.severity.toUpperCase()}</strong> — ${event.message}
`;
const alerts = document.getElementById('alerts');
alerts.insertBefore(div, alerts.firstChild);
// Keep max 50 alerts on screen
while (alerts.children.length > 50) alerts.removeChild(alerts.lastChild);
};
ws.onclose = () => {
// Auto-reconnect after 3 seconds
console.log('Reconnecting...');
setTimeout(connect, 3000);
};
}
connect();
</script>
</body></html>A complete real-time pipeline: Zabbix fires a webhook → POST /internal/alerts → alert written to PostgreSQL → broadcast::Sender fires AlertEvent → every connected WebSocket client receives the event within milliseconds → NOC screen renders the alert. This architecture scales to hundreds of concurrent NOC screens with no additional infrastructure — Tokio's async I/O handles all connections on your server's existing thread pool.
Heartbeat Pings
The current server is passive — it only sends when there are alerts. Add a background task that sends a JSON ping message ({ "event": "heartbeat", "ts": 1234567890 }) to all connected clients every 30 seconds. This prevents idle WebSocket connections from being dropped by NAT routers and load balancers. Use tokio::time::interval() inside the select! loop.
Site Filter Subscription
Allow NOC screens to subscribe to only specific sites. When the client connects, it sends a JSON message: { "subscribe": ["Kilimanjaro", "Serengeti"] }. The server reads this from the WebSocket and stores the filter. Subsequent broadcast events are only forwarded to the client if event.site is in the filter. The Dar es Salaam screen should show only Tanzania sites; the Kampala screen only Uganda sites.