M-Bus Gateway
← Tilbage til blog
· WebSocket· polling· SSE· IoT· realtid· FastAPI· React· TanStack Query· dashboard· performance

WebSocket vs. polling til IoT dashboard — hvornår bruge hvad?

WebSocket vs. HTTP polling til realtids IoT dashboards: latens, skalering, ressourceforbrug, Server-Sent Events, FastAPI WebSocket og TanStack Query polling sammenligning.

Af M-Bus Gateway

IoT dashboards kræver realtids-data. Her er hvornår WebSocket, Server-Sent Events (SSE) og HTTP-polling er de rigtige valg.


Sammenligning: Latens og ressourceforbrug

HTTP Polling (setInterval):
  Latens:     Interval/2 i gennemsnit (10s interval = 5s gennemsnit)
  Ressourcer: Ny HTTP-forbindelse pr. poll → overhead
  Skalering:  Lineært: 1.000 klienter × 10 polls/min = 10.000 req/min
  Egnet til:  Data der opdateres sjældnere end polling-interval
  Uegnet til: < 1 sekundslatens, live streaming

Server-Sent Events (SSE):
  Latens:     < 100ms (server pusher øjeblikkeligt)
  Ressourcer: Persistent HTTP-forbindelse (keep-alive)
  Skalering:  1 forbindelse pr. klient — men server holder state
  Egnet til:  Uni-direktionel server → klient (alarmer, status)
  Uegnet til: Bi-direktionel kommunikation

WebSocket (ws://):
  Latens:     < 10ms (full-duplex)
  Ressourcer: Persistent TCP-forbindelse
  Skalering:  1 socket pr. klient — RAM per connection
  Egnet til:  Chat, live kommandoer, bi-direktionel IoT
  Uegnet til: Simple read-only dashboards (overkill)

M-Bus Gateway: Hvilken metode?

Gateway-data: 1 aflæsning pr. dag (kl. 06:00 UTC)
→ Intet behov for WebSocket — data skifter ikke i løbet af dagen

Undtagelser der kræver realtids:
  → Gateway-alarm (batteridød, signaltab, sabotage)
  → Gateway online/offline status
  → MQTT-kommandostatus (OTA, remote reset)

Anbefalet arkitektur:
  → Normal dashboard: TanStack Query polling (30 sek)
  → Alarmer: SSE stream fra /api/v1/alarms/stream
  → Gateway status: SSE stream eller 30 sek polling
  → OTA progress: WebSocket (bi-direktionel status)

FastAPI SSE: Alarmstream

# server/src/alarms/router.py
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
import asyncio
import json

router = APIRouter()

@router.get("/stream")
async def alarm_stream(
    user: TokenPayload = Depends(get_current_user),
):
    """
    Server-Sent Events stream til alarmer.
    Klienten opretter EventSource — server pusher ved nye alarmer.
    """
    async def event_generator():
        # Initial: Send åbne alarmer:
        alarms = await get_open_alarms(user.tenant_id)
        yield f"data: {json.dumps({'type': 'initial', 'alarms': alarms})}\n\n"

        # Løbende: Lyt på Redis pub/sub kanal:
        pubsub = redis_client.pubsub()
        await pubsub.subscribe(f"alarms:{user.tenant_id}")

        try:
            while True:
                message = await pubsub.get_message(ignore_subscribe_messages=True)
                if message:
                    yield f"data: {message['data'].decode()}\n\n"
                else:
                    # Heartbeat hvert 30 sek (forhindrer proxy timeout):
                    yield ": heartbeat\n\n"
                    await asyncio.sleep(30)
        except asyncio.CancelledError:
            await pubsub.unsubscribe()

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # Nginx: Disable buffering for SSE
        },
    )

React SSE klient

// ui/src/hooks/useAlarmStream.ts
import { useState, useEffect } from "react";
import { useAuthStore } from "@/stores/auth";
import type { Alarm } from "@/api";

export function useAlarmStream() {
  const [alarms, setAlarms] = useState<Alarm[]>([]);
  const [connected, setConnected] = useState(false);
  const token = useAuthStore((s) => s.token);

  useEffect(() => {
    if (!token) return;

    const url = `/api/v1/alarms/stream?token=${token}`;
    const eventSource = new EventSource(url);

    eventSource.onopen = () => setConnected(true);

    eventSource.onmessage = (e) => {
      const data = JSON.parse(e.data);
      if (data.type === "initial") {
        setAlarms(data.alarms);
      } else if (data.type === "new_alarm") {
        setAlarms((prev) => [data.alarm, ...prev]);
      } else if (data.type === "alarm_resolved") {
        setAlarms((prev) => prev.filter((a) => a.id !== data.alarm_id));
      }
    };

    eventSource.onerror = () => {
      setConnected(false);
      // Browser auto-reconnects efter 3 sek
    };

    return () => {
      eventSource.close();
      setConnected(false);
    };
  }, [token]);

  return { alarms, connected };
}

FastAPI WebSocket: OTA progress

# server/src/gateway/router.py
from fastapi import WebSocket, WebSocketDisconnect

@router.websocket("/{gateway_id}/ota-progress")
async def ota_progress_ws(
    gateway_id: str,
    websocket: WebSocket,
    token: str,  # JWT som query param (WebSocket kan ikke sende headers)
):
    """
    WebSocket til real-time OTA-progress fra gateway.
    Gateway sender procenttal via MQTT → server forwarder via WS.
    """
    # Validér token:
    try:
        user = verify_token(token)
    except JWTError:
        await websocket.close(code=4001)
        return

    await websocket.accept()

    # Subscribe på MQTT-topic for dette gateway:
    ota_topic = f"meters/{gateway_id}/ota-progress"

    async def mqtt_callback(msg: dict):
        try:
            await websocket.send_json(msg)
        except Exception:
            pass

    try:
        await subscribe_mqtt(ota_topic, mqtt_callback)
        # Hold forbindelsen åben:
        while True:
            await asyncio.sleep(1)
    except WebSocketDisconnect:
        pass
    finally:
        await unsubscribe_mqtt(ota_topic, mqtt_callback)

TanStack Query polling: Enklere alternativ

// For de fleste IoT dashboard use-cases er polling tilstrækkeligt:

function useGatewayFleetStatus() {
  return useQuery({
    queryKey: ["gateways", "fleet-status"],
    queryFn: () => portalGateways.getFleetStatus(),
    // Poll hvert 30 sekunder (gateway sender heartbeat hvert 5 min):
    refetchInterval: 30 * 1000,
    // Stop polling ved tab-skift:
    refetchIntervalInBackground: false,
    // Vis stale data øjeblikkeligt, fetch i baggrunden:
    staleTime: 0,
  });
}

// Verdict: Brug polling til dashboard-data (30-60 sek interval)
// Brug SSE til alarmer (push-baseret, ingen polling)
// Brug WebSocket kun til bi-direktionel real-time (OTA, kommandoer)

Konklusion

For et IoT-dashboard med daglige M-Bus aflæsninger er HTTP-polling (30 sek) via TanStack Query den korrekte løsning — simpelt, fejlresistent og skalerbart. Server-Sent Events egner sig til alarmer og statusopdateringer (server pusher øjeblikkeligt). WebSocket er kun relevant ved bi-direktionel realtidskommunikation som OTA-progress og live MQTT-kommandoer. Undgå WebSocket til simpel read-only data — det er kompleksitet uden fordel.

Se TanStack Query guide eller MQTT QoS guide.