Tutorial · about 25 minutes
Build a live board
A display that shows today's tasks and keeps itself current, without polling, and without going stale after the Wi-Fi drops.
The shape of it
The dotted line is the part people leave out. While you were disconnected, things changed and nobody told you, so a reconnect must re-load the snapshot, not just resume listening. A board that silently drifts out of date is worse than one that is obviously broken.
Step 1: connect and take a snapshot
import { io } from "socket.io-client";
const socket = io("https://gateway.octopusoperations.co.za/v1", {
transports: ["websocket"],
auth: { apiKey: process.env.OCTOPUS_API_KEY },
});
const tasks = new Map();
function call(event, payload = {}) {
return new Promise((resolve, reject) => {
socket.once(`${event}Success`, resolve);
socket.once(`${event}Error`, (e) => reject(new Error(e.message)));
socket.emit(event, payload);
});
}
async function loadSnapshot() {
const result = await call("listTasks");
tasks.clear();
for (const task of result.populatedItem) tasks.set(task.id, task);
render();
}Step 2: load on connect, and on every reconnect
Socket.IO reconnects for you. Hanging the snapshot off the connect event means it runs the first time and every time after, which is exactly what you want, and one line rather than two code paths.
socket.on("connect", () => {
loadSnapshot().catch((err) => console.error("snapshot failed:", err.message));
});
socket.on("connect_error", (err) => {
// A bad or revoked key lands here, not on "connect".
console.error("cannot connect:", err.message);
});Do not hang your snapshot off ready
ready is pushed the instant the connection completes. If you attach that handler after awaiting connection, the packet has already been delivered to a socket with no listener and nothing replays it. Use connect, or ask for describe explicitly.
Step 3: apply changes as they arrive
socket.on("change", async (change) => {
if (change.resource !== "tasks") return;
if (change.action === "deleted") {
tasks.delete(change.id);
return render();
}
// The change says WHAT moved, not the whole record — fetch the fresh one.
try {
const { populatedItem } = await call("getTask", { taskId: change.recordId ?? change.id });
tasks.set(populatedItem.id, populatedItem);
render();
} catch (err) {
console.error("could not refresh", change.id, err.message);
}
});You only hear what you may see
Change events are delivered per .read scope. A key with only tasks.read hears task changes and nothing else, so you do not have to filter for safety, only for relevance.
Step 4: make the failure visible
A wall display nobody is watching will happily show yesterday. Put the connection state on screen, so a stale board is obviously stale:
let connected = false;
socket.on("connect", () => { connected = true; render(); });
socket.on("disconnect", () => { connected = false; render(); });
function render() {
statusEl.textContent = connected ? "Live" : "Reconnecting…";
statusEl.dataset.state = connected ? "live" : "stale";
// ...draw the tasks
}What you have built
- A snapshot on connect, and again on every reconnect.
- Row-level updates, with no polling at all.
- A visible state, so nobody trusts a frozen screen.
