#Polling, long polling, SSE and WebSockets for device status
AddedA video device's status changes all the time, and the people looking at it care now: a call comes in, someone mutes the room, a USB camera is unplugged, a firmware update reaches 60%, the network link drops and comes back. Plain REST gives you GET /status, and in HTTP the server can't speak first. Something has to carry changes from the device to whoever is watching.
The four ways to do that (short polling, long polling, Server-Sent Events, WebSockets) are compared for web-scale systems in the System Design course's Real-time options topic, with the cost of each worked out for a million clients. On a device the question changes shape. The server is the device itself: a small box whose main job is the call, often serving HTTP/1.1 from an embedded web server with a self-signed certificate. And its listeners are a handful of very different clients: an admin's browser tabs, the room's touch controller, a room control system a partner programmed years ago, a monitoring tool, a cloud management service. Each of those has different abilities and needs, so a device often offers more than one transport over the same events.
A mental model: the device is a receptionist at a front desk. Short polling is visitors walking up every few seconds to ask "anything for me?". Long polling is a visitor waiting at the desk until something arrives, then leaving and queueing again. SSE is the lobby loudspeaker: one announcement reaches everyone listening, but nobody can answer through it. A WebSocket is a walkie-talkie handed to each guest: both sides can talk at any time, but the receptionist has to carry every one of them, and can only carry so many.
| Client | Usually the best fit | Why |
|---|---|---|
| Admin web UI tab | SSE for status, REST for commands | Status flows one way; commands already have REST's validation, ETags and error bodies. EventSource reconnects and resumes by itself. |
| Admin UI with continuous control (camera joystick, live meters) | WebSocket | Many small messages both ways with low latency; one connection instead of a request per joystick nudge. |
| Touch controller or paired accessory | WebSocket (or the vendor's own protocol) | Long-lived, two-way, and it owns the room's buttons, so it sends as much as it receives. |
| Room control system, older partner integration | Telnet/TCP line protocol with notifications, or long polling | Control processors and scripts often speak raw TCP or plain HTTP only; see Asynchronous notifications on a command channel. |
| Monitoring tool, inventory script | Short polling at minutes | Delay is fine; a stateless GET is the cheapest thing a device can serve. |
| Cloud management service | A connection the device opens outwards | The device sits behind the customer's firewall and NAT, so the cloud can't connect in; the device dials out and keeps that connection up. |
What Poly publishes here is narrow, so it's worth saying exactly. The Poly VideoOS REST API Reference Guide (for VideoOS 4.4.0, "Last update: August 2025", HP) documents status as plain request/response endpoints, such as GET /rest/system/status and GET /rest/audio, and a POST /rest/system/reboot, all over HTTPS with an admin session from /rest/session. I found no push channel (WebSocket, SSE or event subscription) in that guide. Whether PolyOS devices offer one, the public documents I found don't say, so everything below teaches the general pattern, not a Poly implementation.
The smallest version of the main pattern is SSE: one long GET that the device keeps writing events to. Here's a simulated device with its real HTTP server, and a client reading the stream the way EventSource does:
// A simulated device streams status changes to a web UI as Server-Sent Events (SSE).
import http from "node:http";
const state = { "audio.muted": false, "audio.volume": 40, "call.state": "idle" };
let seq = 0;
const streams = new Set(); // open SSE responses, one per connected UI tab
let subscriberJoined;
const joined = new Promise((resolve) => (subscriberJoined = resolve));
function set(path, value) { // every state change on the device goes through here
if (state[path] === value) return; // nothing changed: no event
state[path] = value;
const frame = `id: ${++seq}\nevent: status\ndata: ${JSON.stringify({ path, value })}\n\n`;
for (const res of streams) res.write(frame);
}
const server = http.createServer((req, res) => {
if (req.url !== "/api/v1/status/events") return res.writeHead(404).end();
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store" });
res.write("retry: 2000\n: hello from the device\n\n"); // reconnect delay + a comment, no event
streams.add(res);
req.on("close", () => streams.delete(res));
subscriberJoined();
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
// The web UI's side: fetch the stream and parse it the way EventSource does.
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/v1/status/events`);
console.log(res.status, res.headers.get("content-type"));
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
await joined; // the device changes state only once the UI is listening
set("audio.muted", true);
set("audio.muted", true); // a second press of an already-muted button
set("call.state", "ringing");
set("call.state", "connected");
let buffer = "";
let dispatched = 0;
while (dispatched < 3) {
const { value } = await reader.read();
buffer += value;
let end;
while ((end = buffer.indexOf("\n\n")) !== -1) { // a blank line ends one event block
const block = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
const msg = { event: "message", data: [] };
for (const line of block.split("\n")) {
if (line.startsWith(":")) { console.log("comment :", line.slice(1).trim()); continue; }
const [field, ...rest] = line.split(": ");
if (field === "data") msg.data.push(rest.join(": "));
else msg[field] = rest.join(": ");
}
if (msg.retry) console.log("retry :", msg.retry, "ms");
if (msg.data.length === 0) continue; // no data lines: nothing to dispatch
dispatched++;
console.log(`event #${msg.id}: ${msg.event}`, msg.data.join("\n"));
}
}
await reader.cancel();
server.closeAllConnections();
server.close();
console.log("device state:", JSON.stringify(state));200 text/event-stream
comment : hello from the device
retry : 2000 ms
event #1: status {"path":"audio.muted","value":true}
event #2: status {"path":"call.state","value":"ringing"}
event #3: status {"path":"call.state","value":"connected"}
device state: {"audio.muted":true,"audio.volume":40,"call.state":"connected"}- The device keeps its status in one
stateobject and changes it only throughset(). That single doorway is what makes events trustworthy: every change produces exactly one event, and a "change" to the same value produces none, which is why the secondset("audio.muted", true)(someone pressing mute on an already-muted room) sends nothing. - The UI's
fetchopensGET /api/v1/status/events. The device answers200withContent-Type: text/event-streamand never ends the response. Its first write isretry: 2000(reconnect after 2 s if the stream drops) plus a comment line starting with:. Neither is an event: the parser prints the comment and the retry, and dispatches nothing because the block has nodata:line. await joinedmakes the device change state only once the client is listening, so the output is the same on every run. Eachset()writes a block ofid:,event:anddata:lines ending with a blank line. TCP may deliver several blocks in one chunk or split one across two, so the client appends tobufferand cuts on"\n\n"; it never assumes one read is one event.- The client dispatches three events,
#1,#2and#3. The ids are the device's sequence numbers. A realEventSourceremembers the last one and sends it back asLast-Event-IDwhen it reconnects, which is how the device knows what the client missed (Snapshot plus events builds on this). - The client then cancels its reader and the device closes its connections. The final line confirms the client's view and the device agree:
muted: true,call.state: "connected". Commands (mute, volume) would go to the device's REST API, not down this stream: SSE is one-way by design.
The WebSocket version suits clients that also send a lot. The client subscribes to topics and sends commands on the same connection, so every message needs a type, and every command needs an id so its reply can be matched:
// The same device over a WebSocket: the client subscribes to topics and sends commands on the same connection.
import { WebSocketServer } from "ws";
const state = { "audio.muted": false, "audio.volume": 40, "call.state": "idle" };
const writable = new Set(["audio.muted", "audio.volume"]);
let seq = 0;
const topicsOf = new Map(); // socket -> Set of topics it subscribed to
function set(path, value) {
if (state[path] === value) return;
state[path] = value;
const frame = JSON.stringify({ type: "event", seq: ++seq, path, value });
for (const [ws, topics] of topicsOf) if (topics.has(path.split(".")[0])) ws.send(frame);
}
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
wss.on("connection", (ws) => {
topicsOf.set(ws, new Set());
ws.send(JSON.stringify({ type: "hello", seq }));
ws.on("message", (raw) => {
const msg = JSON.parse(raw);
if (msg.op === "subscribe") {
for (const t of msg.topics) topicsOf.get(ws).add(t);
ws.send(JSON.stringify({ type: "result", id: msg.id, ok: true }));
} else if (msg.op === "set") {
if (!writable.has(msg.path)) {
ws.send(JSON.stringify({ type: "result", id: msg.id, ok: false, error: "read-only" }));
return;
}
set(msg.path, msg.value); // publishes the event first...
ws.send(JSON.stringify({ type: "result", id: msg.id, ok: true })); // ...then answers the command
set("call.state", "ringing"); // meanwhile a call comes in (topic "call": this client didn't subscribe)
}
});
ws.on("close", () => topicsOf.delete(ws));
});
await new Promise((resolve) => wss.on("listening", resolve));
// The client uses Node's built-in WebSocket, the same API a browser has.
const client = new WebSocket(`ws://127.0.0.1:${wss.address().port}/api/v1/ws`);
const inbox = [];
let wake = () => {};
client.onmessage = (e) => { inbox.push(JSON.parse(e.data)); wake(); };
const next = async () => { while (!inbox.length) await new Promise((r) => (wake = r)); return inbox.shift(); };
await new Promise((resolve) => (client.onopen = resolve));
console.log("<-", JSON.stringify(await next()));
const commands = [
{ op: "subscribe", id: 1, topics: ["audio"] },
{ op: "set", id: 2, path: "audio.muted", value: true },
{ op: "set", id: 3, path: "call.state", value: "idle" },
];
for (const cmd of commands) {
console.log("->", JSON.stringify(cmd));
client.send(JSON.stringify(cmd));
let msg;
do console.log("<-", JSON.stringify((msg = await next())));
while (!(msg.type === "result" && msg.id === cmd.id)); // read until the reply to *this* command
}
client.close();
await new Promise((resolve) => (client.onclose = resolve));
wss.close();
console.log("device seq:", seq, "state:", JSON.stringify(state));<- {"type":"hello","seq":0}
-> {"op":"subscribe","id":1,"topics":["audio"]}
<- {"type":"result","id":1,"ok":true}
-> {"op":"set","id":2,"path":"audio.muted","value":true}
<- {"type":"event","seq":1,"path":"audio.muted","value":true}
<- {"type":"result","id":2,"ok":true}
-> {"op":"set","id":3,"path":"call.state","value":"idle"}
<- {"type":"result","id":3,"ok":false,"error":"read-only"}
device seq: 2 state: {"audio.muted":true,"audio.volume":40,"call.state":"ringing"}- On connect the device sends
hellowith its currentseq(0: nothing has happened yet). The client side uses Node's built-inWebSocket, the same API browsers have, so this client code would run in an admin page unchanged. subscribewithid: 1adds the topicaudioto this socket's set; the device answers{"type":"result","id":1,"ok":true}. From now on the device only sends this client events whose path starts withaudio..setwithid: 2mutes the room. Inside the device,set()publishes the event first (seq 1) and then the result. The client reads messages until it sees the result forid: 2, and prints the event it passed on the way. That's the key habit with a shared channel: replies and events interleave, so a client matches replies byidand handles events whenever they come, never "the next message is my answer".- Right after replying, the device takes an incoming call (
call.state→ringing,seq 2). This client didn't subscribe tocall, so nothing is sent: the final line shows the device atseq: 2while this client only ever sawseq 1. Hold on to that: with filtered subscriptions, a gap in the global sequence is not a lost event, which matters in Designing device events. setwithid: 3tries to changecall.state, which isn't writable over this API. The device answersok: false, error: "read-only"instead of closing the connection: a bad command is a normal reply, not a protocol failure.
Long polling is plain HTTP, so it works for clients that can't hold a WebSocket or parse a stream: older control systems, scripts, some partner middleware. The client sends the last sequence number it has; the device answers at once if there's news, or holds the request until there is:
// Long polling on a device: GET /api/v1/status/changes?since=N answers at once if there is news after N,
// otherwise holds the request until the next change or until the wait runs out (then 204, ask again).
import http from "node:http";
const log = []; // [{ seq, path, value }], the device's recent changes
const waiting = new Set(); // held requests: { since, res, timer }
const WAIT_MS = 300; // a real device would hold for ~25 s, under every proxy's idle timeout
function reply(res, since) {
const changes = log.filter((c) => c.seq > since);
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ seq: log.at(-1)?.seq ?? 0, changes }));
}
function change(path, value) {
log.push({ seq: log.length + 1, path, value });
for (const w of waiting) { clearTimeout(w.timer); waiting.delete(w); reply(w.res, w.since); }
}
let held;
const heldPromise = new Promise((resolve) => (held = resolve));
const server = http.createServer((req, res) => {
const since = Number(new URL(req.url, "http://device").searchParams.get("since") ?? 0);
if (log.some((c) => c.seq > since)) return reply(res, since); // news already: answer now
const w = { since, res, timer: setTimeout(() => { waiting.delete(w); res.writeHead(204).end(); }, WAIT_MS) };
waiting.add(w);
held();
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const base = `http://127.0.0.1:${server.address().port}/api/v1/status/changes`;
async function poll(since) {
const res = await fetch(`${base}?since=${since}`);
const body = res.status === 200 ? await res.json() : null;
console.log(`GET ?since=${since} -> ${res.status}`, body ? JSON.stringify(body) : "(no news, ask again)");
return body ? body.seq : since;
}
change("audio.muted", true); // happened before the client asked
let since = await poll(0); // answered at once with seq 1
const pending = poll(since); // nothing new: the device holds this one
await heldPromise;
change("call.state", "ringing"); // the held request is answered with this change
since = await pending;
since = await poll(since); // nothing happens for WAIT_MS: 204, then the client asks again
server.close();GET ?since=0 -> 200 {"seq":1,"changes":[{"seq":1,"path":"audio.muted","value":true}]}
GET ?since=1 -> 200 {"seq":2,"changes":[{"seq":2,"path":"call.state","value":"ringing"}]}
GET ?since=2 -> 204 (no news, ask again)- Before the client asks, the device has already recorded change
1(audio.muted→true). The first poll,?since=0, finds news immediately and gets200withseq: 1and the change. The client storesseqas its new cursor. - The second poll,
?since=1, finds nothing newer, so the device parks the request inwaitingwith a timer instead of answering.await heldPromisewaits until the device has actually parked it, which keeps the output in order. - The call comes in (
change 2).change()answers every parked request straight away with the changes after its cursor:?since=1 → 200withseq: 2. Delivery is as fast as SSE's, using nothing but request and response. - The third poll,
?since=2, gets nothing forWAIT_MSand the device answers204 No Content: "nothing new, ask again". The demo waits 300 ms to stay quick; a real device would hold for about 25 s, below the idle timeout of every proxy or client library in the path. - The cursor is what makes this correct. Changes that happen between one response and the next request aren't lost, because the next request says
since=Nand gets everything afterNfrom the device's recent log. Without the cursor, long polling silently drops whatever happens in those gaps.
- SSE heartbeats. Send a comment line (
: hb) every 15 s or so when nothing else is happening. The WHATWG HTML standard's own authoring note suggests "every 15 seconds or so" (checked October 2026), so proxies and NAT boxes don't drop the idle connection. - SSE and the six-connection limit. Over HTTP/1.1, browsers allow only six connections per browser and domain, and each open
EventSourceholds one; MDN's SSE guide (checked October 2026) says Chrome and Firefox marked this "Won't fix". Many embedded web servers speak only HTTP/1.1, so an admin with several tabs open on the same device can run out, and the seventh tab's requests hang. Share one stream between tabs (aSharedWorker, or one leader tab rebroadcasting overBroadcastChannel), or serve HTTP/2. - Stopping SSE on purpose. Under the HTML standard,
EventSourcedoes not reconnect after a non-200 status or a wrongContent-Type; a204is the documented way to tell it to stop. So a device that refuses a stream with503stops the browser's automatic retries too; the page's code has to schedule the retry itself. - Auth. Neither
EventSourcenor the browserWebSocketconstructor lets page code set anAuthorizationheader. Same-origin cookies are sent, so a device UI usually authenticates streams with its session cookie. That makes checking theOriginheader on the WebSocket handshake mandatory: otherwise any page the admin visits could open a socket to the device with the admin's cookie (cross-site WebSocket hijacking). See Securing the on-device web UI. - Self-signed certificates. A browser shows no warning page for a background
fetch,EventSourceor WebSocket; if it doesn't already trust the device's certificate for that origin, the connection just fails. Keep streams on the same origin as the admin page, so accepting the certificate once covers both.
The admin UI shows a progress bar while the device installs a firmware update, which ends with a reboot. Do you stream progress over SSE, a WebSocket, or poll?
Show answer
Poll the update's operation resource (for example GET /api/v1/operations/{id} every 2 s), or stream it if a stream already exists, but design for the stream dying, because it will.
- The update is a long-running operation:
POSTreturns202 Acceptedand an operation resource whoseprogressandstatethe device updates (Long-running operations). - Progress changes slowly (a few percent every few seconds), so a 2 s poll costs the device almost nothing, and polling one URL is the simplest client to write and test.
- The update ends with a reboot, which kills every stream and every in-flight request. A stream-based progress bar must reconnect anyway, and after the reboot it's talking to a new boot with new sequence numbers.
- So the robust design is the same either way: the UI treats "connection lost" during the reboot phase as expected, keeps probing with the reconnect rules in Reconnection and back-off, and reads the operation's final state (
succeeded,failed,rolled-back) from the device once it's back. Streaming is a nicety; the operation resource is the source of truth.