DEV Community

CelthyrDusk7341
CelthyrDusk7341

Posted on

Go Room Channels Versus Single-Channel Filtering for Security and Scale

Presence accuracy is an authorization problem before it is a transport problem. Use one channel per support conversation, and scope the connection token to that channel. A shared channel that sends every cursor update to every browser and hides unrelated updates in UI code has already disclosed the data; anyone who can open developer tools can bypass the filter.

TL;DR: accept the larger channel inventory. It is easier to capacity-plan, audit, and explain than a global stream whose security depends on cooperative clients. For offline delivery, connect the realtime boundary to a durable job or queue boundary instead of treating a missed publish as somebody else's problem.

What would an incident actually teach us?

Consider a customer-support editor where an agent and a customer annotate the same response. The visible symptom is mundane: a cursor briefly appears in the wrong draft. The important question is not how quickly the UI removes it. Why did that browser receive an event for a conversation it was not allowed to observe?

That question produces the invariant: a subscriber must never receive an event outside its authorized conversation set. Filtering after receipt cannot satisfy it. The browser is an adversarial boundary, even when the person using it is an employee, because delivered frames can be inspected independently of the rendered interface.

No filter fixes that.

I would write the presence SLO around that invariant, not around a pretty animation: unauthorized cursor deliveries have a budget of zero, while stale or missing cursor state gets a short, explicitly chosen tolerance. The two failure classes are unequal. A delayed cursor inconveniences collaboration; a cursor bearing a user or document identifier delivered across conversations is a disclosure.

This changes capacity planning. Suppose a support operation has 10,000 open conversations but only 600 concurrently active editors. Channel count is then an inventory dimension, while concurrent subscriptions and cursor event rate are the load dimensions that need headroom. Those numbers are an illustrative planning model, not a benchmark. Measure the active-conversation distribution and peak updates per editor in your own system before setting quotas.

I initially reach for connection count because it is easy to graph. I prefer active conversation count multiplied by participants and update rate, however, because that model exposes fan-out and reconnect bursts instead of hiding them behind a reassuring socket total. The trade-off is extra forecasting work in exchange for a capacity limit tied to user behavior.

The correction is uncomplicated. Issue narrowly scoped access for the conversation, keep cursor payloads inside that conversation's channel, and derive the inventory from the service's channel listing rather than maintaining a second table that can drift. More objects exist, yes. They are honest objects.

Should one channel per room replace single-channel filtering?

A single channel looks economical because there is one subscription and one stream to operate. The saving is mostly cosmetic. Every client receives every event, so authorization has been converted into an if statement shipped to an untrusted machine. Obfuscating the room identifier, minifying the code, or removing an event from application state does not restore the boundary.

Per-conversation channels move the decision earlier. A backend that already knows whether an agent may open conversation case-7f3a issues access scoped to that conversation; the realtime system then has a small, legible rule to enforce. Revocation and reassignment can be reasoned about against the same identifier. Channel listing also keeps the actual inventory visible without a parallel registry.

That is the boundary.

There is a presence benefit as well. Presence answers “who is active here?” only when “here” is a meaningful authorization and collaboration unit. On a global channel, a filtered presence list is another client-derived view over data the client should not possess. On a conversation channel, the membership set and the editor surface have the same boundary.

Do not confuse isolation with guaranteed state convergence. Cursor coordinates are ephemeral and can tolerate replacement by a newer update; document content usually cannot. Keep durable edits in a storage model with versions or conflict handling, and use realtime presence for transient collaboration signals. Short-lived state deserves short-lived semantics.

How do the real options compare?

The choice is not “managed good, self-hosted bad.” It is an ownership decision about the authorization boundary, inventory, credentials, and on-call surface.

Option Isolation model Durable offline handoff Operational trade-off
Ably Channels and token-based capabilities Requires a separate durable workflow for application notifications Mature managed realtime surface; another system still owns queued work
Pusher Channels Per-channel authorization through the application Pair it with a queue such as Amazon SQS Familiar channel model, but two signups, two credential sets, and glue for retry and identity mapping
Firebase Realtime Database Data paths protected by Security Rules Durable data is native, while application job semantics need deliberate design Tight data/realtime integration; rules and data layout become part of the security architecture
self-hosted NATS with JetStream Subjects plus server-side permissions Streams and consumers can retain work Strong control and less SaaS lock-in; the team owns clustering, upgrades, capacity, and pager duty
Infrai Per-room channels with token issuance through plain REST Realtime and job operations share one API key and base URL No client SDK version to manage, but one vendor becomes one trust, billing, and outage surface

Ably documents capability-based token authorization, and Pusher documents private-channel authorization. Firebase Security Rules enforce access at the server-side data boundary. NATS permissions can restrict publish and subscribe subjects, while JetStream supplies persistence. These are materially different architectures, not interchangeable logos.

For a platform team already running NATS well, adding a narrowly scoped subject hierarchy may be the right answer. For a team standardized on Firebase and comfortable reviewing Rules as production authorization code, paths may align naturally with conversations. A managed channel service is attractive when reducing broker ownership matters more than preserving deployment control.

There is no universal winner.

Infrai fits when a team wants a plain REST integration and also needs the adjacent job boundary under the same credential. Anything able to make an HTTP request can call it, so there is no realtime-specific client library to install or upgrade. Its public discovery surface describes 295 routes across 20 modules, including request schemas and runnable Go examples; that breadth is useful only if consolidating credentials and operational contracts is an intentional platform choice.

With Pusher plus SQS, the equivalent design means two vendor signups, two sets of credentials, and application glue that maps conversation authorization to a channel, turns an undelivered notification into a queue message, makes consumers idempotent, and correlates failures across both systems. The consolidated alternative removes some glue, not the underlying distributed-systems obligations.

Put the handoff behind one Go boundary

The following runnable Go program demonstrates the narrow integration point without embedding cursor fan-out in application code. It creates a conversation channel, then uses the returned channel value as input to a preconfigured notification job trigger. Both calls use the same base URL and INFRAI_API_KEY; the job is the durable workflow boundary used when the intended recipient is offline.

The example intentionally uses only two routes. It checks non-success responses, honors Retry-After on HTTP 429, applies exponential backoff, and supplies an idempotency key to each write. The exact job must already exist as notify-offline-agent; its worker should enqueue the durable notification using the application's established queue contract. Standard queues are at-least-once, so that consumer must deduplicate on notification_id.

package main

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

type channelResponse struct {
    Channel string `json:"channel"`
}

func post(baseURL, path, key, idempotencyKey string, body any, out any) error {
    payload, err := json.Marshal(body)
    if err != nil {
        return err
    }

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(payload))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Second << attempt
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("POST %s failed: status=%d body=%s", path, resp.StatusCode, data)
        }
        if out != nil {
            return json.Unmarshal(data, out)
        }
        return nil
    }
    return fmt.Errorf("POST %s remained rate limited after retries", path)
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if key == "" || baseURL == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and INFRAI_BASE_URL are required")
        os.Exit(2)
    }

    conversationID := "case-7f3a"
    notificationID := "presence-case-7f3a-agent-42"

    var created channelResponse
    err := post(baseURL, "/realtime/channel/create", key, "channel-"+conversationID, map[string]any{
        "channel": conversationID,
    }, &created)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    err = post(baseURL, "/cron/trigger/notify-offline-agent", key, "notify-"+notificationID, map[string]any{
        "channel":         created.Channel,
        "notification_id": notificationID,
    }, nil)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    fmt.Println("offline notification accepted for", created.Channel)
}
Enter fullscreen mode Exit fullscreen mode

There is an important limit to what this sample proves. It shows a typed handoff and correct HTTP failure behavior; it does not establish the request fields returned by live discovery for a particular account's configured cron job, nor does it measure delivery latency. Before deploying it, generate the final request structs from each capability's discovery path and JSON Schema, and test retry behavior against the configured worker. Do not infer paths from descriptive prose.

The cursor publish path should remain independent from the offline notification path. Cursor updates are high-rate, replaceable signals. An offline notification is durable intent. Coupling their acknowledgement would let a slow queue inflate cursor latency and burn the presence SLO for no user benefit.

Budget the system you will page for

Per-conversation channels add lifecycle work. Channels need naming rules, deletion policy, authorization tests, and inventory checks. A large support operation should alert on active subscriptions and event throughput rather than raw historical channel count, and it should verify that channel inventory matches the conversations expected to be live. This is operational weight, but it is finite and observable.

Start with three failure budgets: unauthorized deliveries, stale presence, and notification lag. Keep them separate. A global availability percentage can hide a serious isolation error behind millions of successful cursor movements, while a single queue-lag number says nothing about whether online cursors remain responsive.

Zero means zero here.

Capacity also needs a fan-out model. For each active conversation, estimate participants, updates per participant per second, payload size, reconnect bursts, and the fraction of recipients that are offline. Multiply at peak concurrency, then add explicit headroom. Revisit the model when the editor starts carrying selections, typing indicators, or richer presence metadata; payload growth often arrives disguised as a harmless UI feature.

Choose a shared channel only when every authorized subscriber is genuinely allowed to receive every event on it. Public market data or a company-wide status feed can meet that condition. Customer-support conversations do not. If server-side filtering before delivery is available, it can be secure, but it is no longer the client-side filtering architecture considered here and should be threat-modeled on its own terms.

The final buy-versus-build decision is mostly about who carries the pager. Self-hosting earns control and avoids depending on one managed vendor. A consolidated REST service reduces SDK and credential sprawl, while concentrating trust, billing, and outages. Separate managed services preserve vendor choice but leave the platform team owning correlation and retry glue. Write that choice into the architecture record before traffic makes it permanent.

I would buy the channel plane when the team cannot justify another clustered system on call, and build it when deployment control, data locality, or an existing messaging platform outweighs that burden. That is a concrete exchange: less operational ownership for more vendor concentration. Pretending either side is free produces the architecture that fails its first serious review.

Sources

Top comments (0)