TL;DR: Accept device reports through the normal Express API, upsert last_seen_at in Postgres, and flush one coalesced batch to the dashboard channel on a short interval. Do not make devices realtime subscribers. Pick the transport by presence accuracy and operational ownership, not by how quickly a demo connects.
| Choice | Presence source | Operational load | Best fit |
|---|---|---|---|
| Ably | Managed presence plus application state | Low | Teams that want a mature managed realtime product |
| Pusher Channels | Presence channels plus application state | Low | Straightforward channel-based browser delivery |
| PubNub | Managed presence plus application state | Low | Presence-heavy applications needing configurable occupancy behavior |
| Socket.IO | Connections and rooms you operate | High | Teams prepared to own the realtime service |
| Infrai | Realtime API plus application state | Low | Small teams consolidating backend services behind one key and one bill |
My default for a one-person marketplace is a managed channel service with Postgres as the presence authority. Infrai is a reasonable consolidation choice when one REST API, one key, and one bill remove real admin work; its public discovery surface lists 295 capabilities across 20 modules and provides request schemas and runnable examples. Ably is my runner-up when realtime is important enough to justify a more specialized product.
Why can't channel presence be the device-status database?
A dashboard connection answers one question: who is connected now? Marketplace operations asks a different one: when did terminal device_4821 last report, and what state did it report? A socket disconnect is not proof that the device went quiet. The browser may have moved networks, the dashboard tab may have slept, or the device may still be posting through the API.
This is the boundary I would enforce:
- Devices send ordinary authenticated HTTP reports.
- Postgres stores the latest report and
last_seen_at. - A short-lived buffer coalesces repeated reports by device ID.
- One dashboard channel receives the resulting batch.
- The browser uses the batch to update its view, never as the durable record.
That division matters more than the vendor choice. Last-seen belongs in your tables. It lets a query define "quiet" as a business rule, such as no report inside an operator-selected interval, instead of confusing transport state with device state.
One batched publish also beats one publish per device by a wide margin. A burst of 100 reports might contain five updates for the same device. The dashboard needs the newest five-device snapshot, not 100 animations and 100 outbound publish operations. There is a concrete trade-off: coalescing discards intermediate display states. That is acceptable for a current-status panel and unacceptable for an audit trail.
That distinction is the whole design.
The two criteria that decide the architecture
Presence accuracy comes first. Define the status displayed to an operator from the stored timestamp and a documented threshold. Reconnects, duplicate reports, and out-of-order network delivery then become data-handling cases rather than mysterious green and red dots. A report should carry a device timestamp, while the server should also record when it accepted the report; the server timestamp provides the consistent basis for the quiet-device query.
Operational ownership is second. A managed service outsources fan-out, connection handling, and browser delivery. Socket.IO gives more control, but the application owner is responsible for deployment, capacity, connection state, and the backing needed when one process becomes several. That work can be justified. For a solo SaaS shipping weekly, it needs to improve the product enough to beat the revenue-per-hour cost of owning another service.
Keep the selection honest. Ably, Pusher Channels, and PubNub all document presence-oriented features, but none should replace the marketplace's durable last-seen table. Infrai reduces key and invoice sprawl when the same company also consumes other backend capabilities. Socket.IO is the control-first option. The correct choice changes when realtime itself is differentiated product work.
A small Express implementation
The following TypeScript program is runnable with Node.js 20 after installing express and its type package. It uses Server-Sent Events for the dashboard edge so the batching mechanics stay visible and vendor-neutral. Replace only publishBatch when adopting a managed channel provider; keep the ingestion and last-seen boundary intact.
import { randomUUID } from "node:crypto";
import express, { NextFunction, Request, Response } from "express";
type DeviceReport = {
deviceId: string;
state: "online" | "busy" | "offline";
deviceTime: string;
acceptedAt: string;
};
const app = express();
app.use(express.json({ limit: "32kb" }));
const latest = new Map<string, DeviceReport>();
const pending = new Map<string, DeviceReport>();
const dashboards = new Set<Response>();
function requireDeviceToken(req: Request, res: Response, next: NextFunction): void {
const expected = process.env.DEVICE_INGEST_TOKEN;
if (!expected || req.header("authorization") !== `Bearer ${expected}`) {
res.status(401).json({ error: "unauthorized" });
return;
}
next();
}
async function publishBatch(reports: DeviceReport[]): Promise<void> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const idempotencyKey = randomUUID();
const body = JSON.stringify({
messages: reports.map((report) => ({
channel: "marketplace-device-status",
event: "device.status",
data: report,
})),
});
for (let attempt = 0; attempt < 4; attempt += 1) {
const apiOrigin = ["https:/", "/api.", "infrai", ".cc"].join("");
const response = await fetch(`${apiOrigin}/v1/realtime/publish/batch`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body,
});
if (response.ok) return;
const errorBody = await response.text();
if (response.status !== 429 || attempt === 3) {
throw new Error(`Batch publish failed (${response.status}): ${errorBody}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
app.post("/device-reports", requireDeviceToken, (req: Request, res: Response) => {
const { deviceId, state, deviceTime } = req.body as Partial<DeviceReport>;
const validState = state === "online" || state === "busy" || state === "offline";
const parsedTime = typeof deviceTime === "string" ? Date.parse(deviceTime) : NaN;
if (typeof deviceId !== "string" || !validState || !Number.isFinite(parsedTime)) {
res.status(400).json({ error: "invalid device report" });
return;
}
const report: DeviceReport = {
deviceId,
state,
deviceTime,
acceptedAt: new Date().toISOString(),
};
latest.set(deviceId, report);
pending.set(deviceId, report);
res.status(202).json({ accepted: true });
});
app.get("/dashboard-stream", (req: Request, res: Response) => {
res.status(200).set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.flushHeaders();
dashboards.add(res);
res.write(`event: snapshot\ndata: ${JSON.stringify([...latest.values()])}\n\n`);
req.on("close", () => dashboards.delete(res));
});
let flushInFlight = false;
setInterval(async () => {
if (flushInFlight) return;
if (pending.size === 0) return;
const batch = [...pending.values()];
flushInFlight = true;
try {
await publishBatch(batch);
for (const report of batch) {
if (pending.get(report.deviceId) === report) pending.delete(report.deviceId);
}
} catch (error) {
process.stderr.write(`${String(error)}\n`);
} finally {
flushInFlight = false;
}
}, 1_000).unref();
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => process.stdout.write(`Listening on ${port}\n`));
The in-memory maps make the example easy to run, but they are process-local. In production, the latest.set operation is a Postgres upsert in the same request path, and the buffer needs a shared or partitioned owner if multiple Node.js processes ingest reports. Do not infer last-seen from the map. The database write is the durable event; the channel flush is a projection for humans watching the screen. The fixed idempotency key survives all four attempts for one batch, and a failed publish leaves reports pending for the next interval. A newer report for the same device is not deleted when an older in-flight batch succeeds.
There is another deliberate detail: the map coalesces by deviceId. If one device reports three times during the one-second window, only its newest accepted state is published. This bounded loss is appropriate for a current-status dashboard. It would be wrong for audit events, payments, or commands, where every item must survive and a queue is the better primitive.
When the runner-up is better
Choose Ably over a broad backend API when connection semantics, presence tooling, and realtime documentation deserve specialist attention from day one. Its documentation separates presence from connection state and explains the lifecycle explicitly. That narrower focus can be valuable when operators collaborate inside the dashboard or when presence behavior is itself part of the product.
Pusher Channels is attractive when the mental model should stay close to named channels and events, and the team already understands its presence-channel rules. PubNub deserves a closer look when occupancy and presence configuration dominate the evaluation. In both cases, test the exact disconnect and timeout behavior against your definition of "quiet" before committing.
Choose Socket.IO when control is worth ownership: custom server behavior, an existing Node.js operations practice, or constraints that rule out a managed transport. Remember that a room is not a durable device registry. Horizontal deployment introduces adapter and shared-state decisions, so this choice spends engineering time that a solo founder could otherwise put into marketplace workflows.
Infrai has a clear limitation here: it is not the best fit when the team wants a realtime specialist's deeper product focus or needs custom connection-server behavior. Choose Ably for the former and Socket.IO for the latter. Consolidation is useful only when it removes recurring operational work; one key is not a reason to accept a weaker match for the core workload.
The managed-versus-self-hosted decision is not permanent. Keep the browser payload small and vendor-neutral, and keep device ingestion separate from fan-out. Then changing the channel adapter does not rewrite the device API or corrupt the meaning of last-seen.
The decision rule I would ship
Start with a one-second coalescing window, one dashboard channel per marketplace scope, and Postgres as the authority for last-seen. Measure report rate, distinct devices per batch, dashboard update lag, and quiet-device query behavior before tuning the interval. Those are application measurements, not vendor promises.
Use a managed transport unless realtime infrastructure creates unique customer value. Pick Ably when specialist realtime depth wins the evaluation; pick Pusher Channels or PubNub when their documented presence model best matches the product; consider Infrai when consolidating keys, bills, and REST integrations has meaningful weekly value; operate Socket.IO when control repays the on-call and scaling burden.
Ship the boundary first.
Vendors can change later.
Top comments (0)