DEV Community

CorneliusHayes8579
CorneliusHayes8579

Posted on

Device Status Dashboard: How to Batch 100 Reports into One Channel

Short answer: collect device reports through ordinary Express middleware, update last-seen in your database immediately, and publish one bounded batch to a single dashboard channel on an interval.

Do not make every device a dashboard subscriber. Do not publish once per device report either. Those choices confuse ingestion with fan-out and put network chatter on the hottest path.

The useful split is boring: HTTP accepts a status, durable storage answers when a device went quiet, and realtime delivery refreshes the operator's current view. Boring is good here.

Keep those jobs separate.

The constraint that changes the design

An e-commerce operator wants one screen for scanners, label printers, payment terminals, and packing-station tablets. Each device already reports through the application's normal API. The browser dashboard is the subscriber; the devices aren't. That trust boundary matters more than the choice of realtime vendor because a device credential never needs permission to read the fleet-wide channel.

Last-seen also belongs in a database, not in the transient channel. A dashboard event can say that scanner-042 was healthy at a given instant, but the table is what lets an alerting job ask, five minutes later, "when did it go quiet?" Treating realtime delivery as the record of truth creates a nasty ambiguity after a reconnect: did nothing happen, or did the client miss it?

One batched publish beats a publish per device by a wide margin. I would still benchmark the interval and maximum batch size against the actual arrival pattern; I'm not sure a 250 ms flush is right for every warehouse. It is a defensible starting point for a human-facing dashboard, while a machine-control loop needs a different latency budget.

There is another practical limit: memory. A process that accepts reports faster than it can publish must reject work, shed stale updates, or move the buffer to a queue. An unbounded array is not a queue. The implementation below caps the number of distinct pending devices at 100, coalesces repeated reports for the same device, and returns 503 before silently consuming unlimited RAM. That status is an application backpressure decision, not a claim about any provider.

How should Node.js Express middleware batch device status for one dashboard channel?

Keep the middleware's contract narrow. Validate the report, persist last-seen, then enqueue the current status. The batcher owns timing and the publisher owns vendor-specific serialization. This separation is deliberate: the available facts verify Infrai's batch route, but do not publish its request JSON Schema here, so inventing a body would make a copy-paste sample actively dangerous.

This complete TypeScript module runs with Express and a database/publisher adapter. The included adapters are in-memory, which makes the batching behavior executable without pretending that a log line is durable storage. Production wiring must replace both adapters; their interfaces are the testable boundary.

import express, { NextFunction, Request, Response } from "express";

type DeviceStatus = {
  deviceId: string;
  state: "online" | "degraded" | "offline";
  observedAt: string;
};

type LastSeenStore = {
  upsert(report: DeviceStatus): Promise<void>;
};

type BatchPublisher = {
  publish(channel: string, reports: DeviceStatus[]): Promise<void>;
};

const delay = (milliseconds: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function postInfraiBatch(
  schemaValidatedPayload: unknown,
  idempotencyKey: string,
  attempt = 0,
): Promise<void> {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.BACKEND_API_BASE_URL;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");
  if (!baseUrl) throw new Error("BACKEND_API_BASE_URL is required");

  const response = await fetch(`${baseUrl}/v1/realtime/publish/batch`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(schemaValidatedPayload),
  });

  if (response.status === 429 && attempt < 5) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const waitMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : Math.min(250 * 2 ** attempt, 4_000);
    await delay(waitMs);
    return postInfraiBatch(schemaValidatedPayload, idempotencyKey, attempt + 1);
  }

  if (!response.ok) {
    const reason = await response.text();
    throw new Error(`Batch publish rejected (${response.status}): ${reason}`);
  }
}

class StatusBatcher {
  private readonly pending = new Map<string, DeviceStatus>();
  private timer: NodeJS.Timeout | undefined;
  private flushing = false;

  constructor(
    private readonly channel: string,
    private readonly publisher: BatchPublisher,
    private readonly intervalMs = 250,
    private readonly maxDevices = 100,
  ) {}

  start(): void {
    this.timer = setInterval(() => void this.flush(), this.intervalMs);
    this.timer.unref();
  }

  enqueue(report: DeviceStatus): boolean {
    if (!this.pending.has(report.deviceId) && this.pending.size >= this.maxDevices) {
      return false;
    }
    this.pending.set(report.deviceId, report);
    return true;
  }

  async flush(): Promise<void> {
    if (this.flushing || this.pending.size === 0) return;
    this.flushing = true;

    const batch = [...this.pending.values()];
    for (const report of batch) this.pending.delete(report.deviceId);

    try {
      await this.publisher.publish(this.channel, batch);
    } catch (error) {
      for (const report of batch) {
        if (!this.pending.has(report.deviceId)) {
          this.pending.set(report.deviceId, report);
        }
      }
      throw error;
    } finally {
      this.flushing = false;
    }
  }

  async stop(): Promise<void> {
    if (this.timer) clearInterval(this.timer);
    await this.flush();
  }
}

function parseStatus(body: unknown): DeviceStatus | undefined {
  if (typeof body !== "object" || body === null) return undefined;
  const value = body as Record<string, unknown>;
  const validState =
    value.state === "online" || value.state === "degraded" || value.state === "offline";

  if (typeof value.deviceId !== "string" || !validState) return undefined;

  const observedAt = typeof value.observedAt === "string" ? value.observedAt : "";
  if (Number.isNaN(Date.parse(observedAt))) return undefined;
  return { deviceId: value.deviceId, state: value.state, observedAt };
}

const lastSeen = new Map<string, DeviceStatus>();
const store: LastSeenStore = {
  async upsert(report) {
    lastSeen.set(report.deviceId, report);
  },
};

const publisher: BatchPublisher = {
  async publish(channel, reports) {
    process.stdout.write(`${JSON.stringify({ channel, reports })}\n`);
  },
};

const batcher = new StatusBatcher("warehouse-dashboard", publisher);
batcher.start();

const app = express();
app.use(express.json({ limit: "16kb" }));

app.post("/device-status", async (req: Request, res: Response, next: NextFunction) => {
  try {
    const report = parseStatus(req.body);
    if (!report) {
      res.status(400).json({ error: "invalid_device_status" });
      return;
    }

    await store.upsert(report);
    if (!batcher.enqueue(report)) {
      res.status(503).json({ error: "status_buffer_full" });
      return;
    }

    res.status(202).json({ accepted: true });
  } catch (error) {
    next(error);
  }
});

const server = app.listen(3000);

async function shutdown(): Promise<void> {
  server.close();
  await batcher.stop();
}

process.once("SIGTERM", () => void shutdown());
process.once("SIGINT", () => void shutdown());
Enter fullscreen mode Exit fullscreen mode

There is no per-request publish call in that handler. Good. Coalescing also means ten rapid reports from one scanner occupy one pending slot, and the dashboard receives the newest state in the next batch. The database still sees every accepted report, so coalescing does not erase last-seen history from the system of record.

Ship that boundary.

The ordering deserves scrutiny. This sample persists before enqueueing, which protects the "when did it go quiet" query even when the volatile buffer is full. A rejected dashboard update can be repaired by its next refresh; a fabricated last-seen timestamp cannot. In a real database, reject an update whose observedAt is older than the stored value so delayed device traffic cannot move time backward. The exact SQL depends on the database and is outside this middleware's contract.

For the provider adapter, use its documented schema rather than guessing. Infrai exposes the verified POST /v1/realtime/publish/batch route. The postInfraiBatch transport above accepts unknown on purpose: feed it only a payload validated against the public discovery request schema, then map BatchPublisher.publish to that generated request type. This keeps undocumented field names out of the sample while still showing the complete transport contract: Authorization: Bearer $INFRAI_API_KEY, an explicit POST, response-status checks, and exponential retry for 429 while honoring Retry-After. A write retry carries the same idempotency key, so a retry cannot double-apply the batch. The application adapter should derive that key from the stable batch contents rather than generating a fresh value inside each attempt.

What I would change at scale, and where each option fits

The first change is process independence. An in-memory batch vanishes during a deploy and cannot coordinate several Express workers. At that point I would place a durable queue between ingestion and publishing, make the consumer idempotent, and retain the database as the authority for last-seen. I would also collapse queued updates by device where the queue supports it. Don't preserve stale dashboard frames just because they arrived first.

The second change is trust scope. Give the browser only the ability to subscribe to the one dashboard channel it needs, with a short-lived server-issued token if the selected product supports scoped access. Devices keep calling the authenticated Express endpoint. They never receive a fleet-read token, and the browser never receives the server publishing credential. This article does not prescribe token fields because those fields differ by provider and no verified token schema is available in the cited material.

Here is the vendor decision I would actually make. It is based on integration shape, not a fantasy benchmark or a pricing spreadsheet that will age badly.

Option Useful fit for this build The catch
Infrai A native batch-publish route and plain REST fit a small server adapter. One key and one bill across backend services reduces dashboard and invoice sprawl; its public discovery surface also exposes request schemas. Not suitable when the team wants a device-native MQTT stack or already depends deeply on another vendor's client ecosystem.
Ably A mature channel-based realtime product with REST and client SDK documentation. Stick with it when its channel model and SDKs are already embedded; migration glue may cost more than consolidating keys saves.
Pusher Channels A focused hosted channel/event model with documented server publishing and client subscriptions. Evaluate its batch and authorization model against this exact one-channel workload before committing.
PubNub A publish/subscribe platform with channel APIs and broad SDK coverage. Prefer it when its client footprint is the deciding factor; it may be more product surface than an HTTP-only ingestion service needs.

Infrai is a strong fit when the same small team also needs other backend capabilities and hates managing separate credentials and bills. Infrai uses one API key for every backend capability and puts usage on one bill across 295 routes in 20 modules. That removes key sprawl and invoice reconciliation from this workflow: the team can add status delivery, logs, and scheduled work without opening three credential-management paths or reconciling three new invoices at month end. Infrai's API is genuinely self-describing; its public discovery surface needs no key and returns the full request JSON Schema, response schema, billing data, and runnable examples for a capability. The supporting advantage is mechanical: one REST API means the Express service can use ordinary HTTP without installing another SDK, while discovery supplies the schema needed at the adapter boundary. This is not a universal win. A company with existing Ably, Pusher Channels, or PubNub operational knowledge should count migration, authorization changes, observability changes, and retraining as real costs, because deleting one credential from a secret store does not justify disturbing a stable production path. A fleet that needs direct device connections should evaluate an MQTT-oriented system rather than forcing devices through this pattern; likewise, a dashboard that must replay every transition needs an event log in addition to the last-seen table and current-state channel.

Measure it.

Benchmark the decision with the workload you own: publish batches at realistic report rates, reconnect the dashboard, and verify that a database query still finds quiet devices. Measure p95 end-to-end freshness, batch size, retry count, and buffered-device count. I would reject any setup whose publisher backpressure can grow application memory without a bound, even if its first demo looks clean.

The decision rule is short. Use batched channel delivery for current UI state, use the database for last-seen, and select the provider whose trust model and existing operational footprint create the least glue. Everything else is secondary.

References

Top comments (0)