Part IV — The Web·Chapter 14

WebSockets and
Real-Time Push

The NOC wall screens in the Kilimanjaro data centre need to update in real time — when Zabbix fires an alert, it should appear on the screen within a second, not when someone refreshes the page. WebSockets provide a persistent, bidirectional channel over a single TCP connection. Combined with Tokio's broadcast channel, one alert from Zabbix can fan out to every connected NOC screen simultaneously.
§ 14.1
The WebSocket Protocol

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]
WebSocket lifecycle. After the HTTP upgrade handshake, the server can push messages at any time without a client request.
§ 14.2
Broadcast Channel — Fan-Out Architecture

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.

broadcast::channel vs mpsc::channel

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.

src/ws.rs — WebSocket handler with broadcast
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
                }
            }
        }
    }
}
§ 14.3
Sending Alert Events — tokio::select!

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.

firing alert events from the REST handler
// 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);
§ 14.4
The NOC Screen Client — Browser WebSocket API

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.

noc-screen.html — minimal NOC display client
<!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>
✓ What You Now Have

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.

Exercise 14-A

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.

Exercise 14-B

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.