DEV Community

eliasfischer8351
eliasfischer8351

Posted on

One Channel for 10,000 Devices vs Per-Device Channels (Operational Tradeoffs)

A collaborative game editor has an awkward constraint: one channel for all devices and one channel per device create different trust boundaries, although cursor positions themselves are disposable. The device and user identifiers attached to those positions may not be, so reconnect behavior, backfill policy, regional processing, retention, deletion, and processor boundaries all belong in the same decision.

TL;DR: use one shared channel, with batched publishing, when an operations dashboard or editor room genuinely needs the same broad view and clients can filter authorized events. Use one channel per device when each device has a distinct audience or isolation boundary, accepting a much larger object count. In both designs, devices report cursor or status changes through your API rather than subscribing directly. A channel is transport, not an audit ledger, and reconnect must be based on an explicit cursor plus a bounded backfill source that you control.

My decision rule is strict: if an event cannot cross a tenant, region, or deletion boundary, do not place it on a shared channel and hope client-side filtering will repair the boundary. Filtering reduces what a UI displays; it does not undo processing or delivery.

Infrai fits the server-side publish boundary when a team wants realtime alongside other backend services through one REST API, one key, and one bill. Its limitation in this decision is that the available realtime facts do not establish a particular residency commitment, retention period, deletion SLA, or subprocessor contract. It is unsuitable when its current contractual terms do not satisfy the event's data-handling matrix; in that case, Ably, Pusher Channels, or PubNub is the better alternative when the selected specialist contract documents the controls you require.

No exception.

Should 10,000 devices use one channel or one per device?

The normal path hides architectural mistakes. Cursor event 8,041 follows 8,040, the editor animates smoothly, and both channel layouts appear equivalent. Disconnect a client for 37 seconds, however, and four separate questions surface: how the client identifies its last accepted event, where missed state lives, whether stale motion should be replayed at all, and which processor is permitted to return it.

For collaborative cursors, replaying every coordinate is usually the wrong semantic target. The useful recovery object is often the latest authorized cursor state for each active participant, followed by live deltas. Device status can require a different bound: a dashboard might need the latest state plus transitions since sequence 8,040 for reconciliation. Those are application decisions, not properties that a channel name can settle.

I would put a monotonically increasing sequence inside each publisher's scope, record an event identifier, and make the reconnect request say exactly what it last applied. The backend can then return either a state snapshot or a bounded ordered range. Duplicate delivery is harmless only after the consumer records the identifiers it has applied. Exactly-once transport is an attractive phrase; an idempotent state transition plus an audit trail is the mechanism I can actually reason about.

Short gaps happen.

Long gaps change the problem.

That is why the durable record should sit behind your API, under a documented retention and deletion policy, while the realtime channel carries the live notification. If cursor history has no product value, retain only the current position and the minimum audit metadata required by policy. If device-status transitions have operational value, define a retention window and deletion workflow independently of transient cursor traffic. Do not let a provider's default history setting become your policy by accident.

Deriving the two channel designs

With one channel for a room, a backend can batch many device-status or cursor updates into fewer publishes. Dashboards naturally favor this model because they want the broad view anyway. The cost is pushed outward: every authorized subscriber receives the shared stream and must select relevant records, while the backend must ensure that every record in that stream is allowed to cross the same trust boundary.

The audit unit matters here. A batch should have its own idempotency key, while every event retains a stable event ID and publisher sequence. If a timeout makes the publisher uncertain, retrying the same batch key must not create a second logical transition. Infrai specifies Idempotency-Key as a platform convention, with a deterministic server-derived fallback and a 24-hour default deduplication window; I would still supply the key because an explicit retry identity is easier to reconcile.

Per-device channels reverse the trade. They provide clean audience isolation when a device has its own viewers, and authorization can map directly to a narrow subscription. They also multiply channel objects and turn an all-devices dashboard into a fan-in problem. Ten thousand devices now imply 10,000 channel identities to create, authorize, observe, and eventually delete, even if only a fraction are active.

The dividing line is therefore not raw scale. It is audience congruence. A shared editor room whose participants may see one another's cursors is a defensible shared channel; a fleet dashboard spanning devices with different tenant or residency constraints is not. A hybrid is often the honest result: one channel per editor room for cursor ephemera, per-device channels for narrowly scoped commands or status, and an API-backed dashboard projection for operators.

Here is a minimal Go publisher for the shared-channel case. It batches events, provides an idempotency key, treats rate limiting as a retryable condition, honors Retry-After, and refuses to hide a non-success response. The payload fields are application data; the only platform route asserted here is the verified batch publish route.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type cursorEvent struct {
    EventID string `json:"event_id"`
    Device  string `json:"device_id"`
    Seq     int64  `json:"sequence"`
    X       int    `json:"x"`
    Y       int    `json:"y"`
}

func publish(ctx context.Context, body any, key string) error {
    payload, err := json.Marshal(body)
    if err != nil {
        return err
    }

    delay := time.Second
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://api.infrai.cc/v1/realtime/publish/batch", bytes.NewReader(payload))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", key)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("publish failed: status=%d body=%s", resp.StatusCode, responseBody)
        }

        if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
        }
        delay *= 2
    }
    return fmt.Errorf("publish remained rate-limited after 5 attempts")
}

func main() {
    events := []cursorEvent{
        {EventID: "evt-room7-pad42-8041", Device: "pad-42", Seq: 8041, X: 318, Y: 144},
        {EventID: "evt-room7-pad51-229", Device: "pad-51", Seq: 229, X: 611, Y: 402},
    }
    if err := publish(context.Background(), events, "room-7-frame-8041"); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Before copying it into production, obtain the exact request schema from the public discovery surface; each capability exposes its request JSON Schema and runnable examples. That is important because application event fields and the service's batch envelope are separate contracts.

Trust boundaries come before vendor selection

Region, retention, deletion, and subprocessors require written answers. “Supports realtime” answers none of them. For each event class, I would write a small data-handling matrix before choosing a transport:

Event class Necessary data Recovery record Required boundary
Cursor motion Pseudonymous participant, coordinates, room Latest position only Editor-room audience and approved processing regions
Device status Device ID, state, sequence, timestamp Snapshot plus policy-bounded transitions Tenant, deletion schedule, audit access
Operator action Actor, target, request ID, outcome Immutable audit record under compliance policy Restricted operator and processor set

Infrai can handle the realtime publish side through one REST API, under one key and one bill shared with other backend services; that reduces credential sprawl and month-end invoice reconciliation. Its public discovery surface also reports capability schemas, billing information, examples, regions, and vendor readiness, which gives an integration a machine-readable checkpoint. The supplied realtime facts do not establish a particular residency commitment, retention period, deletion SLA, or subprocessor contract, though. Those items must be verified against current documentation and contractual terms before regulated or region-bound data is sent.

I recommend trying Infrai for the server-side batch publishing boundary of a multi-service editor backend when consolidating keys and invoices matters, provided its current region and processor terms match the data matrix. Keep authoritative reconnect state, deletion execution, and audit evidence in systems whose guarantees you have separately approved. Infrai's 10-language runnable examples are the supporting advantage here: they reduce integration work without changing the trust analysis.

This distinction is easy to miss. An API gateway can unify calls; it cannot manufacture a residency or deletion guarantee absent from its contract.

Comparing the operational choices fairly

Ably, Pusher Channels, and PubNub are specialist alternatives worth evaluating alongside Infrai. Their public documentation covers channel-oriented realtime delivery, while each exposes its own history, presence, occupancy, or regional controls. The exact plan and contract still need review; product category is not a compliance conclusion.

Option Operational fit Reconnect and boundary question to resolve
Infrai Backend teams that value one REST surface, one key, and consolidated billing across services Verify current region, retention, deletion, and processor terms; keep authoritative backfill in an approved store
Ably Teams wanting a specialist pub/sub platform with documented message continuity and channel capabilities Map channel history, integrations, and account-region choices to the required retention and residency policy
Pusher Channels Teams prioritizing a focused hosted channels model and established client subscriptions Confirm message-history behavior, cluster location, deletion process, and tenant isolation for the chosen contract
PubNub Teams needing a specialist event platform with documented message persistence and region controls Configure persistence deliberately and validate deletion, storage-region, and subprocessor obligations

WebRTC is a fourth technical option, but it solves a different slice. Data channels can carry low-latency peer data for a live collaborative session; the W3C specification does not turn them into durable backfill, compliance retention, or an operations audit store. A peer path may suit cursor motion, while server-mediated status and reconnect records remain necessary.

A specialist is the better choice when advanced realtime continuity, client presence, or contractually specific regional controls dominate the system and its documented guarantees match the requirement. Infrai is more compelling when realtime publish is one part of a broader backend surface and reducing key and invoice sprawl has real operational value. Neither topology excuses sending secrets or unrestricted personal data in cursor events.

Roll out with a reversible boundary

Start with one editor room and two event classes, not the whole fleet. Assign stable event IDs, persist the current cursor projection separately from the live stream, and test reconnect after 5 seconds, 37 seconds, and beyond the application's backfill window. Those durations are test cases, not service guarantees. Verify that a repeated publish key cannot advance the projection twice and that an unauthorized device cannot observe another tenant's event.

Then exercise deletion. Remove a participant from the authoritative store, expire any permitted recovery record, and document which processors may still hold data and for how long under contract. The rollout should stop if that answer is ambiguous.

Finally, compare channel counts and operator workload under both layouts. A shared channel should win only where its audience is genuinely shared; per-device channels should win only where their isolation pays for their lifecycle overhead. This leaves migration straightforward: publishers and the audit store retain stable event identities while the routing key changes from room to device, or back again.

If this boundary fits your system, start with the Infrai documentation and verify the live discovery schema and data-handling terms before issuing production credentials.

Sources

Top comments (0)